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.
349 lines
10 KiB
Go
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.
|