Kristoffer Dalby 17236fd284 all: annotate complex functions with gocyclo rationale
Splitting these functions does not buy clarity — each has been
extracted before and put back. Pin the //nolint:gocyclo on each
with the reason their shape resists clean decomposition.

  policy/v2/policy.go     ViaRoutesForPeer        — three-pass
                                                    via-grant resolution
  policy/v2/filter.go     compileSSHPolicy        — per-rule
                                                    branches with
                                                    intertwined
                                                    autogroup:self
                                                    handling
                                                    (annotated in the
                                                    earlier nil-error
                                                    commit)
  state/state.go          HandleNodeFromPreAuthKey — security-
                                                    sensitive
                                                    sequential
                                                    validation order
  servertest/routes_test  TestRoutes              — table-driven
                                                    test driver with
                                                    many independent
                                                    subtests

Also: //nolint:recvcheck on policy/v2.SSHUser — UnmarshalJSON
requires a pointer receiver; the other methods on this string
newtype use value receivers by convention.
2026-05-19 09:55:22 +02:00

349 lines
10 KiB
Go

package servertest_test
import (
"context"
"net/netip"
"slices"
"testing"
"time"
"github.com/juanfont/headscale/hscontrol/servertest"
"github.com/juanfont/headscale/hscontrol/types"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"tailscale.com/tailcfg"
"tailscale.com/types/netmap"
)
// TestRoutes verifies that route advertisements and approvals
// propagate correctly through the control plane to all peers.
//
//nolint:gocyclo // table-driven test driver with many independent subtests
func TestRoutes(t *testing.T) {
t.Parallel()
t.Run("node_addresses_in_allowed_ips", func(t *testing.T) {
t.Parallel()
h := servertest.NewHarness(t, 2)
// Each peer's AllowedIPs should contain the peer's addresses.
for _, c := range h.Clients() {
nm := c.Netmap()
require.NotNil(t, nm)
for _, peer := range nm.Peers {
addrs := make(map[netip.Prefix]bool)
for i := range peer.Addresses().Len() {
addrs[peer.Addresses().At(i)] = true
}
for i := range peer.AllowedIPs().Len() {
aip := peer.AllowedIPs().At(i)
if addrs[aip] {
delete(addrs, aip)
}
}
assert.Empty(t, addrs,
"client %s: peer %d AllowedIPs should contain all of Addresses",
c.Name, peer.ID())
}
}
})
t.Run("advertised_routes_in_hostinfo", func(t *testing.T) {
t.Parallel()
srv := servertest.NewServer(t)
user := srv.CreateUser(t, "advroute-user")
routePrefix := netip.MustParsePrefix("192.168.1.0/24")
c1 := servertest.NewClient(t, srv, "advroute-node1",
servertest.WithUser(user))
c2 := servertest.NewClient(t, srv, "advroute-node2",
servertest.WithUser(user))
c1.WaitForPeers(t, 1, 10*time.Second)
// Update hostinfo with advertised routes.
c1.Direct().SetHostinfo(&tailcfg.Hostinfo{
BackendLogID: "servertest-advroute-node1",
Hostname: "advroute-node1",
RoutableIPs: []netip.Prefix{routePrefix},
})
// Send a non-streaming update to push the new [tailcfg.Hostinfo].
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_ = c1.Direct().SendUpdate(ctx)
// The observer should eventually see the advertised routes
// in the peer's [tailcfg.Hostinfo].
c2.WaitForCondition(t, "advertised route in hostinfo",
15*time.Second,
func(nm *netmap.NetworkMap) bool {
for _, p := range nm.Peers {
hi := p.Hostinfo()
if hi.Valid() && hi.Hostname() == "advroute-node1" {
for i := range hi.RoutableIPs().Len() {
if hi.RoutableIPs().At(i) == routePrefix {
return true
}
}
}
}
return false
})
})
t.Run("route_advertise_and_approve", func(t *testing.T) {
t.Parallel()
srv := servertest.NewServer(t)
user := srv.CreateUser(t, "fullrt-user")
route := netip.MustParsePrefix("10.0.0.0/24")
c1 := servertest.NewClient(t, srv, "fullrt-advertiser",
servertest.WithUser(user))
c2 := servertest.NewClient(t, srv, "fullrt-observer",
servertest.WithUser(user))
c1.WaitForPeers(t, 1, 10*time.Second)
c2.WaitForPeers(t, 1, 10*time.Second)
// Step 1: Advertise the route by updating [tailcfg.Hostinfo].
c1.Direct().SetHostinfo(&tailcfg.Hostinfo{
BackendLogID: "servertest-fullrt-advertiser",
Hostname: "fullrt-advertiser",
RoutableIPs: []netip.Prefix{route},
})
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_ = c1.Direct().SendUpdate(ctx)
// Wait for the server to process the [tailcfg.Hostinfo] update
// by waiting for observer to see the advertised route.
c2.WaitForCondition(t, "hostinfo update propagated",
10*time.Second,
func(nm *netmap.NetworkMap) bool {
for _, p := range nm.Peers {
hi := p.Hostinfo()
if hi.Valid() && hi.Hostname() == "fullrt-advertiser" {
return hi.RoutableIPs().Len() > 0
}
}
return false
})
// Step 2: Approve the route on the server.
nodeID := findNodeID(t, srv, "fullrt-advertiser")
_, routeChange, err := srv.State().SetApprovedRoutes(
nodeID, []netip.Prefix{route})
require.NoError(t, err)
srv.App.Change(routeChange)
// Step 3: Observer should see the route in AllowedIPs.
c2.WaitForCondition(t, "approved route in AllowedIPs",
15*time.Second,
func(nm *netmap.NetworkMap) bool {
for _, p := range nm.Peers {
hi := p.Hostinfo()
if hi.Valid() && hi.Hostname() == "fullrt-advertiser" {
for i := range p.AllowedIPs().Len() {
if p.AllowedIPs().At(i) == route {
return true
}
}
}
}
return false
})
})
t.Run("allowed_ips_superset_of_addresses", func(t *testing.T) {
t.Parallel()
h := servertest.NewHarness(t, 3)
for _, c := range h.Clients() {
nm := c.Netmap()
require.NotNil(t, nm)
for _, peer := range nm.Peers {
allowedSet := make(map[netip.Prefix]bool)
for i := range peer.AllowedIPs().Len() {
allowedSet[peer.AllowedIPs().At(i)] = true
}
for i := range peer.Addresses().Len() {
addr := peer.Addresses().At(i)
assert.True(t, allowedSet[addr],
"client %s: peer %d Address %v should be in AllowedIPs",
c.Name, peer.ID(), addr)
}
}
}
})
t.Run("addresses_are_in_cgnat_range", func(t *testing.T) {
t.Parallel()
h := servertest.NewHarness(t, 2)
cgnat := netip.MustParsePrefix("100.64.0.0/10")
ula := netip.MustParsePrefix("fd7a:115c:a1e0::/48")
for _, c := range h.Clients() {
nm := c.Netmap()
require.NotNil(t, nm)
require.True(t, nm.SelfNode.Valid())
for i := range nm.SelfNode.Addresses().Len() {
addr := nm.SelfNode.Addresses().At(i)
inCGNAT := cgnat.Contains(addr.Addr())
inULA := ula.Contains(addr.Addr())
assert.True(t, inCGNAT || inULA,
"client %s: address %v should be in CGNAT or ULA range",
c.Name, addr)
}
}
})
// Reproduces the HA secondary recovery race:
// HA tracking loses the secondary subnet router after all routers serving
// the route have been offline simultaneously and one of them returns.
//
// Two assertions split the failure surface:
// R1 — server-side primary route state restores after reconnect.
// R2 — observer's [netmap.NetworkMap] shows the reconnected router online with
// the route in its primary set.
// If R1 fails the bug is in [state.State.Connect] / primaryRoutes; if R1 passes
// and R2 fails the bug is in change broadcast / [mapper.Batcher].
//
// Caveat: [TestClient.Reconnect] re-registers via [controlclient.Direct.TryLogin] in addition
// to starting a new poll session. Production reconnects after a brief
// network outage may bypass re-registration. If this test passes on
// main, fall back to the integration variant noted in the plan
// (TestHASubnetRouterFailover with all routers offline simultaneously).
t.Run("ha_secondary_recovers_after_all_offline", func(t *testing.T) {
t.Parallel()
srv := servertest.NewServer(t)
user := srv.CreateUser(t, "ha3203-user")
route := netip.MustParsePrefix("10.0.0.0/24")
r1 := servertest.NewClient(t, srv, "ha3203-router1",
servertest.WithUser(user))
r2 := servertest.NewClient(t, srv, "ha3203-router2",
servertest.WithUser(user))
obs := servertest.NewClient(t, srv, "ha3203-observer",
servertest.WithUser(user))
obs.WaitForPeers(t, 2, 10*time.Second)
// Both routers advertise the same route via their [tailcfg.Hostinfo].
advertise := func(c *servertest.TestClient, name string) {
t.Helper()
c.Direct().SetHostinfo(&tailcfg.Hostinfo{
BackendLogID: "servertest-" + name,
Hostname: name,
RoutableIPs: []netip.Prefix{route},
})
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_ = c.Direct().SendUpdate(ctx)
}
advertise(r1, "ha3203-router1")
advertise(r2, "ha3203-router2")
// Approve the route on both routers explicitly. Auto-approvers
// would also work but introduce a policy dependency the harness
// does not currently set up here.
approve := func(name string) {
t.Helper()
id := findNodeID(t, srv, name)
_, ch, err := srv.State().SetApprovedRoutes(id, []netip.Prefix{route})
require.NoError(t, err)
srv.App.Change(ch)
}
approve("ha3203-router1")
approve("ha3203-router2")
// Sanity: r1 starts as primary (lower NodeID by registration order).
r1ID := findNodeID(t, srv, "ha3203-router1")
r2ID := findNodeID(t, srv, "ha3203-router2")
hasRoute := func(id types.NodeID) bool {
return slices.Contains(srv.State().GetNodePrimaryRoutes(id), route)
}
assert.Eventually(t, func() bool { return hasRoute(r1ID) },
10*time.Second, 100*time.Millisecond,
"r1 should be primary initially")
// 1. Take r1 offline. After the 10s grace period, r2 should take over.
r1.Disconnect(t)
assert.Eventually(t, func() bool { return hasRoute(r2ID) && !hasRoute(r1ID) },
20*time.Second, 200*time.Millisecond,
"r2 should take over as primary after r1 offline")
// 2. Take r2 offline. With both routers gone, no primary should remain.
r2.Disconnect(t)
assert.Eventually(t, func() bool { return !hasRoute(r1ID) && !hasRoute(r2ID) },
20*time.Second, 200*time.Millisecond,
"no primary should be assigned while both routers are offline")
// 3. Reconnect r2 (cable plugged back in).
r2.Reconnect(t)
// [tailcfg.Hostinfo] is part of the [controlclient.Direct] state; the [TestClient.Reconnect]
// helper re-registers via [controlclient.Direct.TryLogin] which carries the same [tailcfg.Hostinfo]
// that was set above. Push it again to be sure the announced route
// is registered in the new session.
advertise(r2, "ha3203-router2")
// R1: server-side state must restore r2 as primary.
assert.Eventually(t, func() bool { return hasRoute(r2ID) },
15*time.Second, 200*time.Millisecond,
"R1: r2 should be re-registered as primary after reconnect — issue #3203")
// R2: observer must see r2 online with the route in its primary set.
obs.WaitForCondition(t, "R2: observer sees r2 online with primary route",
15*time.Second,
func(nm *netmap.NetworkMap) bool {
for _, p := range nm.Peers {
hi := p.Hostinfo()
if !hi.Valid() || hi.Hostname() != "ha3203-router2" {
continue
}
online, known := p.Online().GetOk()
if !known || !online {
return false
}
for i := range p.PrimaryRoutes().Len() {
if p.PrimaryRoutes().At(i) == route {
return true
}
}
}
return false
})
})
}
// [findNodeID] is defined in issues_test.go.