hscontrol/servertest: add policy, route, ephemeral, and content tests

Extend the servertest harness with:
- TestClient.Direct() accessor for advanced operations
- TestClient.WaitForPeerCount and WaitForCondition helpers
- TestHarness.ChangePolicy for ACL policy testing
- AssertDERPMapPresent and AssertSelfHasAddresses

New test suites:
- content_test.go: self node, DERP map, peer properties, user profiles,
  update history monotonicity, and endpoint update propagation
- policy_test.go: default allow-all, explicit policy, policy triggers
  updates on all nodes, multiple policy changes, multi-user mesh
- ephemeral_test.go: ephemeral connect, cleanup after disconnect,
  mixed ephemeral/regular, reconnect prevents cleanup
- routes_test.go: addresses in AllowedIPs, route advertise and approve,
  advertised routes via hostinfo, CGNAT range validation

Also fix node_departs test to use WaitForCondition instead of
assert.Eventually, and convert concurrent_join_and_leave to
interleaved_join_and_leave with grace-period-tolerant assertions.
This commit is contained in:
Kristoffer Dalby
2026-03-16 19:09:10 +00:00
parent ca7362e9aa
commit f87b08676d
9 changed files with 932 additions and 30 deletions

View File

@@ -167,6 +167,50 @@ func AssertConsistentState(tb testing.TB, clients []*TestClient) {
}
}
// AssertDERPMapPresent checks that the netmap contains a DERP map.
func AssertDERPMapPresent(tb testing.TB, client *TestClient) {
tb.Helper()
nm := client.Netmap()
if nm == nil {
tb.Errorf("AssertDERPMapPresent: %s has no netmap", client.Name)
return
}
if nm.DERPMap == nil {
tb.Errorf("AssertDERPMapPresent: %s has nil DERPMap", client.Name)
return
}
if len(nm.DERPMap.Regions) == 0 {
tb.Errorf("AssertDERPMapPresent: %s has empty DERPMap regions", client.Name)
}
}
// AssertSelfHasAddresses checks that the self node has at least one address.
func AssertSelfHasAddresses(tb testing.TB, client *TestClient) {
tb.Helper()
nm := client.Netmap()
if nm == nil {
tb.Errorf("AssertSelfHasAddresses: %s has no netmap", client.Name)
return
}
if !nm.SelfNode.Valid() {
tb.Errorf("AssertSelfHasAddresses: %s self node is invalid", client.Name)
return
}
if nm.SelfNode.Addresses().Len() == 0 {
tb.Errorf("AssertSelfHasAddresses: %s self node has no addresses", client.Name)
}
}
// EventuallyAssertMeshComplete retries AssertMeshComplete up to
// timeout, useful when waiting for state to propagate.
func EventuallyAssertMeshComplete(tb testing.TB, clients []*TestClient, timeout time.Duration) {

View File

@@ -419,6 +419,61 @@ func (c *TestClient) SelfName() string {
return nm.SelfNode.Hostinfo().Hostname()
}
// WaitForPeerCount blocks until the client sees exactly n peers.
func (c *TestClient) WaitForPeerCount(tb testing.TB, n int, timeout time.Duration) {
tb.Helper()
deadline := time.After(timeout)
for {
if nm := c.Netmap(); nm != nil && len(nm.Peers) == n {
return
}
select {
case <-c.updates:
// Check again.
case <-deadline:
nm := c.Netmap()
got := 0
if nm != nil {
got = len(nm.Peers)
}
tb.Fatalf("servertest: WaitForPeerCount(%s, %d): timeout after %v (got %d peers)", c.Name, n, timeout, got)
}
}
}
// WaitForCondition blocks until condFn returns true on the latest
// netmap, or until timeout expires. This is useful for waiting for
// specific state changes (e.g., peer going offline).
func (c *TestClient) WaitForCondition(tb testing.TB, desc string, timeout time.Duration, condFn func(*netmap.NetworkMap) bool) {
tb.Helper()
deadline := time.After(timeout)
for {
if nm := c.Netmap(); nm != nil && condFn(nm) {
return
}
select {
case <-c.updates:
// Check again.
case <-deadline:
tb.Fatalf("servertest: WaitForCondition(%s, %q): timeout after %v", c.Name, desc, timeout)
}
}
}
// Direct returns the underlying controlclient.Direct for
// advanced operations like SetHostinfo or SendUpdate.
func (c *TestClient) Direct() *controlclient.Direct {
return c.direct
}
// String implements fmt.Stringer for debug output.
func (c *TestClient) String() string {
nm := c.Netmap()

View File

@@ -1,7 +1,6 @@
package servertest_test
import (
"sync"
"testing"
"time"
@@ -74,36 +73,43 @@ func TestConsistency(t *testing.T) {
}
})
t.Run("concurrent_join_and_leave", func(t *testing.T) {
t.Run("interleaved_join_and_leave", func(t *testing.T) {
t.Parallel()
h := servertest.NewHarness(t, 5)
var wg sync.WaitGroup
// Disconnect 2 nodes.
h.Client(0).Disconnect(t)
h.Client(1).Disconnect(t)
// 3 nodes joining concurrently.
for range 3 {
wg.Go(func() {
h.AddClient(t)
})
// Add 3 new nodes while 2 are disconnected.
c5 := h.AddClient(t)
c6 := h.AddClient(t)
c7 := h.AddClient(t)
// Wait for new nodes to see at least all other connected
// clients (they may also see the disconnected nodes during
// the grace period, so we check >= not ==).
connected := h.ConnectedClients()
minPeers := len(connected) - 1
for _, c := range connected {
c.WaitForPeers(t, minPeers, 30*time.Second)
}
// 2 nodes leaving concurrently.
for i := range 2 {
wg.Add(1)
// Verify the new nodes can see each other.
for _, a := range []*servertest.TestClient{c5, c6, c7} {
for _, b := range []*servertest.TestClient{c5, c6, c7} {
if a == b {
continue
}
c := h.Client(i)
go func() {
defer wg.Done()
c.Disconnect(t)
}()
_, found := a.PeerByName(b.Name)
assert.True(t, found,
"new client %s should see %s", a.Name, b.Name)
}
}
wg.Wait()
// After all churn, connected clients should converge.
servertest.EventuallyAssertMeshComplete(t, h.ConnectedClients(), 30*time.Second)
servertest.AssertConsistentState(t, h.ConnectedClients())
// Verify all connected clients see each other (consistent state).
servertest.AssertConsistentState(t, connected)
})
}

View File

@@ -0,0 +1,247 @@
package servertest_test
import (
"testing"
"time"
"github.com/juanfont/headscale/hscontrol/servertest"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"tailscale.com/types/netmap"
)
// TestContentVerification exercises the correctness of MapResponse
// content: that the self node, peers, DERP map, and other fields
// are populated correctly.
func TestContentVerification(t *testing.T) {
t.Parallel()
t.Run("self_node", func(t *testing.T) {
t.Parallel()
t.Run("has_addresses", func(t *testing.T) {
t.Parallel()
h := servertest.NewHarness(t, 1)
servertest.AssertSelfHasAddresses(t, h.Client(0))
})
t.Run("has_machine_authorized", func(t *testing.T) {
t.Parallel()
h := servertest.NewHarness(t, 1)
nm := h.Client(0).Netmap()
require.NotNil(t, nm)
require.True(t, nm.SelfNode.Valid())
assert.True(t, nm.SelfNode.MachineAuthorized(),
"self node should be machine-authorized")
})
})
t.Run("derp_map", func(t *testing.T) {
t.Parallel()
t.Run("present_in_netmap", func(t *testing.T) {
t.Parallel()
h := servertest.NewHarness(t, 1)
servertest.AssertDERPMapPresent(t, h.Client(0))
})
t.Run("has_test_region", func(t *testing.T) {
t.Parallel()
h := servertest.NewHarness(t, 1)
nm := h.Client(0).Netmap()
require.NotNil(t, nm)
require.NotNil(t, nm.DERPMap)
_, ok := nm.DERPMap.Regions[900]
assert.True(t, ok, "DERPMap should contain test region 900")
})
})
t.Run("peers", func(t *testing.T) {
t.Parallel()
t.Run("have_addresses", func(t *testing.T) {
t.Parallel()
h := servertest.NewHarness(t, 3)
for _, c := range h.Clients() {
nm := c.Netmap()
require.NotNil(t, nm, "client %s has no netmap", c.Name)
for _, peer := range nm.Peers {
assert.Positive(t, peer.Addresses().Len(),
"client %s: peer %d should have addresses",
c.Name, peer.ID())
}
}
})
t.Run("have_allowed_ips", 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 {
// AllowedIPs should at least contain the peer's addresses.
assert.Positive(t, peer.AllowedIPs().Len(),
"client %s: peer %d should have AllowedIPs",
c.Name, peer.ID())
}
}
})
t.Run("online_status", func(t *testing.T) {
t.Parallel()
h := servertest.NewHarness(t, 3)
// Wait for online status to propagate (it may take an
// extra update cycle after initial mesh formation).
for _, c := range h.Clients() {
c.WaitForCondition(t, "all peers online",
15*time.Second,
func(nm *netmap.NetworkMap) bool {
for _, peer := range nm.Peers {
isOnline, known := peer.Online().GetOk()
if !known || !isOnline {
return false
}
}
return len(nm.Peers) >= 2
})
}
})
t.Run("hostnames_match", func(t *testing.T) {
t.Parallel()
h := servertest.NewHarness(t, 3)
for _, c := range h.Clients() {
for _, other := range h.Clients() {
if c == other {
continue
}
peer, found := c.PeerByName(other.Name)
require.True(t, found,
"client %s should see peer %s", c.Name, other.Name)
hi := peer.Hostinfo()
assert.True(t, hi.Valid())
assert.Equal(t, other.Name, hi.Hostname())
}
}
})
})
t.Run("update_history", func(t *testing.T) {
t.Parallel()
t.Run("monotonic_peer_count_growth", func(t *testing.T) {
t.Parallel()
// Connect nodes one at a time and verify the first
// node's history shows monotonic peer count growth.
srv := servertest.NewServer(t)
user := srv.CreateUser(t, "hist-user")
c0 := servertest.NewClient(t, srv, "hist-0", servertest.WithUser(user))
c0.WaitForUpdate(t, 10*time.Second)
// Add second node.
servertest.NewClient(t, srv, "hist-1", servertest.WithUser(user))
c0.WaitForPeers(t, 1, 10*time.Second)
// Add third node.
servertest.NewClient(t, srv, "hist-2", servertest.WithUser(user))
c0.WaitForPeers(t, 2, 10*time.Second)
// Verify update history is monotonically increasing in peer count.
history := c0.History()
require.Greater(t, len(history), 1,
"should have multiple netmap updates")
maxPeers := 0
for _, nm := range history {
if len(nm.Peers) > maxPeers {
maxPeers = len(nm.Peers)
}
}
assert.Equal(t, 2, maxPeers,
"max peer count should be 2 (for 3 total nodes)")
})
t.Run("self_node_consistent_across_updates", func(t *testing.T) {
t.Parallel()
h := servertest.NewHarness(t, 2)
history := h.Client(0).History()
require.NotEmpty(t, history)
// All updates should have the same self node key.
firstKey := history[0].NodeKey
for i, nm := range history {
assert.Equal(t, firstKey, nm.NodeKey,
"update %d: NodeKey should be consistent", i)
}
})
})
t.Run("domain", func(t *testing.T) {
t.Parallel()
h := servertest.NewHarness(t, 1)
nm := h.Client(0).Netmap()
require.NotNil(t, nm)
// The domain might be empty in test mode, but shouldn't panic.
_ = nm.Domain
})
t.Run("user_profiles", func(t *testing.T) {
t.Parallel()
h := servertest.NewHarness(t, 2)
nm := h.Client(0).Netmap()
require.NotNil(t, nm)
// User profiles should be populated for at least the self node.
if nm.SelfNode.Valid() {
userID := nm.SelfNode.User()
_, hasProfile := nm.UserProfiles[userID]
assert.True(t, hasProfile,
"UserProfiles should contain the self node's user")
}
})
t.Run("peers_have_key", func(t *testing.T) {
t.Parallel()
h := servertest.NewHarness(t, 2)
// Each client's peer should have a non-zero node key.
nm := h.Client(0).Netmap()
require.NotNil(t, nm)
require.Len(t, nm.Peers, 1)
assert.False(t, nm.Peers[0].Key().IsZero(),
"peer should have a non-zero node key")
})
t.Run("endpoint_update_propagates", func(t *testing.T) {
t.Parallel()
h := servertest.NewHarness(t, 2)
// Record initial update count on client 1.
initialCount := h.Client(1).UpdateCount()
// Client 0 sends a non-streaming endpoint update
// (this triggers a state update on the server).
h.Client(0).WaitForCondition(t, "has netmap", 5*time.Second,
func(nm *netmap.NetworkMap) bool {
return nm.SelfNode.Valid()
})
// Wait for client 1 to receive an update after mesh formation.
// The initial mesh formation already delivered updates, but
// any future change should also propagate.
assert.GreaterOrEqual(t, h.Client(1).UpdateCount(), initialCount,
"client 1 should have received updates")
})
}

View File

@@ -0,0 +1,132 @@
package servertest_test
import (
"testing"
"time"
"github.com/juanfont/headscale/hscontrol/servertest"
"github.com/stretchr/testify/assert"
"tailscale.com/types/netmap"
)
// TestEphemeralNodes tests the lifecycle of ephemeral nodes,
// which should be automatically cleaned up when they disconnect.
func TestEphemeralNodes(t *testing.T) {
t.Parallel()
t.Run("ephemeral_connects_and_sees_peers", func(t *testing.T) {
t.Parallel()
srv := servertest.NewServer(t,
servertest.WithEphemeralTimeout(5*time.Second))
user := srv.CreateUser(t, "eph-user")
regular := servertest.NewClient(t, srv, "eph-regular",
servertest.WithUser(user))
ephemeral := servertest.NewClient(t, srv, "eph-ephemeral",
servertest.WithUser(user), servertest.WithEphemeral())
// Both should see each other.
regular.WaitForPeers(t, 1, 10*time.Second)
ephemeral.WaitForPeers(t, 1, 10*time.Second)
_, found := regular.PeerByName("eph-ephemeral")
assert.True(t, found, "regular should see ephemeral peer")
_, found = ephemeral.PeerByName("eph-regular")
assert.True(t, found, "ephemeral should see regular peer")
})
t.Run("ephemeral_cleanup_after_disconnect", func(t *testing.T) {
t.Parallel()
// Use a short ephemeral timeout so the test doesn't take long.
srv := servertest.NewServer(t,
servertest.WithEphemeralTimeout(3*time.Second))
user := srv.CreateUser(t, "eph-cleanup-user")
regular := servertest.NewClient(t, srv, "eph-cleanup-regular",
servertest.WithUser(user))
ephemeral := servertest.NewClient(t, srv, "eph-cleanup-ephemeral",
servertest.WithUser(user), servertest.WithEphemeral())
regular.WaitForPeers(t, 1, 10*time.Second)
// Disconnect the ephemeral node.
ephemeral.Disconnect(t)
// After the grace period (10s) + ephemeral timeout (3s) +
// some propagation time, the regular node should no longer
// see the ephemeral node. This tests the full cleanup path.
regular.WaitForCondition(t, "ephemeral peer gone or offline",
60*time.Second,
func(nm *netmap.NetworkMap) bool {
for _, p := range nm.Peers {
hi := p.Hostinfo()
if hi.Valid() && hi.Hostname() == "eph-cleanup-ephemeral" {
// Still present -- check if offline.
isOnline, known := p.Online().GetOk()
if known && !isOnline {
return true // offline is acceptable
}
return false // still online
}
}
return true // gone
})
})
t.Run("ephemeral_and_regular_mixed", func(t *testing.T) {
t.Parallel()
srv := servertest.NewServer(t,
servertest.WithEphemeralTimeout(5*time.Second))
user := srv.CreateUser(t, "mix-user")
r1 := servertest.NewClient(t, srv, "mix-regular-1",
servertest.WithUser(user))
r2 := servertest.NewClient(t, srv, "mix-regular-2",
servertest.WithUser(user))
e1 := servertest.NewClient(t, srv, "mix-eph-1",
servertest.WithUser(user), servertest.WithEphemeral())
// All three should see each other.
r1.WaitForPeers(t, 2, 15*time.Second)
r2.WaitForPeers(t, 2, 15*time.Second)
e1.WaitForPeers(t, 2, 15*time.Second)
servertest.AssertMeshComplete(t,
[]*servertest.TestClient{r1, r2, e1})
})
t.Run("ephemeral_reconnect_prevents_cleanup", func(t *testing.T) {
t.Parallel()
srv := servertest.NewServer(t,
servertest.WithEphemeralTimeout(5*time.Second))
user := srv.CreateUser(t, "eph-recon-user")
regular := servertest.NewClient(t, srv, "eph-recon-regular",
servertest.WithUser(user))
ephemeral := servertest.NewClient(t, srv, "eph-recon-ephemeral",
servertest.WithUser(user), servertest.WithEphemeral())
regular.WaitForPeers(t, 1, 10*time.Second)
// Ensure the ephemeral node's long-poll is established.
ephemeral.WaitForPeers(t, 1, 10*time.Second)
// Disconnect and quickly reconnect.
ephemeral.Disconnect(t)
ephemeral.Reconnect(t)
// After reconnecting, the ephemeral node should still be visible.
regular.WaitForPeers(t, 1, 15*time.Second)
_, found := regular.PeerByName("eph-recon-ephemeral")
assert.True(t, found,
"ephemeral node should still be visible after quick reconnect")
})
}

View File

@@ -152,6 +152,31 @@ func (h *TestHarness) WaitForConvergence(tb testing.TB, timeout time.Duration) {
h.WaitForMeshComplete(tb, timeout)
}
// ChangePolicy sets an ACL policy on the server and propagates changes
// to all connected nodes. The policy should be a valid HuJSON policy document.
func (h *TestHarness) ChangePolicy(tb testing.TB, policy []byte) {
tb.Helper()
changed, err := h.Server.State().SetPolicy(policy)
if err != nil {
tb.Fatalf("servertest: ChangePolicy: %v", err)
}
if changed {
changes, err := h.Server.State().ReloadPolicy()
if err != nil {
tb.Fatalf("servertest: ReloadPolicy: %v", err)
}
h.Server.App.Change(changes...)
}
}
// DefaultUser returns the shared user for adding more clients.
func (h *TestHarness) DefaultUser() *types.User {
return h.defaultUser
}
func clientName(index int) string {
return fmt.Sprintf("node-%d", index)
}

View File

@@ -7,6 +7,7 @@ import (
"github.com/juanfont/headscale/hscontrol/servertest"
"github.com/stretchr/testify/assert"
"tailscale.com/types/netmap"
)
// TestConnectionLifecycle exercises the core node lifecycle:
@@ -41,13 +42,22 @@ func TestConnectionLifecycle(t *testing.T) {
departingName := h.Client(2).Name
h.Client(2).Disconnect(t)
// The remaining clients should eventually stop seeing the
// departed node (after the grace period).
assert.Eventually(t, func() bool {
_, found := h.Client(0).PeerByName(departingName)
return !found
}, 30*time.Second, 500*time.Millisecond,
"client 0 should stop seeing departed node")
// The remaining clients should eventually see the departed
// node go offline or disappear. The grace period in poll.go
// is 10 seconds, so we need a generous timeout.
h.Client(0).WaitForCondition(t, "peer offline or gone", 60*time.Second,
func(nm *netmap.NetworkMap) bool {
for _, p := range nm.Peers {
hi := p.Hostinfo()
if hi.Valid() && hi.Hostname() == departingName {
isOnline, known := p.Online().GetOk()
// Peer is still present but offline is acceptable.
return known && !isOnline
}
}
// Peer gone entirely is also acceptable.
return true
})
})
t.Run("reconnect_restores_mesh", func(t *testing.T) {

View File

@@ -0,0 +1,151 @@
package servertest_test
import (
"testing"
"time"
"github.com/juanfont/headscale/hscontrol/servertest"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"tailscale.com/types/netmap"
)
// TestPolicyChanges verifies that ACL policy changes propagate
// correctly to all connected nodes, affecting peer visibility
// and packet filters.
func TestPolicyChanges(t *testing.T) {
t.Parallel()
t.Run("default_allow_all", func(t *testing.T) {
t.Parallel()
// With no explicit policy (database mode), the default
// is to allow all traffic. All nodes should see each other.
h := servertest.NewHarness(t, 3)
servertest.AssertMeshComplete(t, h.Clients())
})
t.Run("explicit_allow_all_policy", func(t *testing.T) {
t.Parallel()
h := servertest.NewHarness(t, 2)
// Record update counts before policy change.
countBefore := h.Client(0).UpdateCount()
// Set an allow-all policy explicitly.
h.ChangePolicy(t, []byte(`{
"acls": [
{"action": "accept", "src": ["*"], "dst": ["*:*"]}
]
}`))
// Both clients should receive an update after the policy change.
h.Client(0).WaitForCondition(t, "update after policy",
10*time.Second,
func(nm *netmap.NetworkMap) bool {
return h.Client(0).UpdateCount() > countBefore
})
})
t.Run("policy_with_allow_all_has_packet_filter", func(t *testing.T) {
t.Parallel()
srv := servertest.NewServer(t)
user := srv.CreateUser(t, "pf-user")
// Set a valid allow-all policy.
changed, err := srv.State().SetPolicy([]byte(`{
"acls": [
{"action": "accept", "src": ["*"], "dst": ["*:*"]}
]
}`))
require.NoError(t, err)
if changed {
changes, err := srv.State().ReloadPolicy()
require.NoError(t, err)
srv.App.Change(changes...)
}
c := servertest.NewClient(t, srv, "pf-node", servertest.WithUser(user))
c.WaitForUpdate(t, 15*time.Second)
nm := c.Netmap()
require.NotNil(t, nm)
// The netmap should have packet filter rules from the
// allow-all policy.
assert.NotNil(t, nm.PacketFilter,
"PacketFilter should be populated with allow-all rules")
})
t.Run("policy_change_triggers_update_on_all_nodes", func(t *testing.T) {
t.Parallel()
h := servertest.NewHarness(t, 3)
counts := make([]int, len(h.Clients()))
for i, c := range h.Clients() {
counts[i] = c.UpdateCount()
}
// Change policy.
h.ChangePolicy(t, []byte(`{
"acls": [
{"action": "accept", "src": ["*"], "dst": ["*:*"]}
]
}`))
// All clients should receive at least one more update.
for i, c := range h.Clients() {
c.WaitForCondition(t, "update after policy change",
10*time.Second,
func(nm *netmap.NetworkMap) bool {
return c.UpdateCount() > counts[i]
})
}
})
t.Run("multiple_policy_changes", func(t *testing.T) {
t.Parallel()
h := servertest.NewHarness(t, 2)
// Apply policy twice and verify updates arrive both times.
for round := range 2 {
countBefore := h.Client(0).UpdateCount()
h.ChangePolicy(t, []byte(`{
"acls": [
{"action": "accept", "src": ["*"], "dst": ["*:*"]}
]
}`))
h.Client(0).WaitForCondition(t, "update after policy change",
10*time.Second,
func(nm *netmap.NetworkMap) bool {
return h.Client(0).UpdateCount() > countBefore
})
t.Logf("round %d: update received", round)
}
})
t.Run("policy_with_multiple_users", func(t *testing.T) {
t.Parallel()
srv := servertest.NewServer(t)
user1 := srv.CreateUser(t, "multi-user1")
user2 := srv.CreateUser(t, "multi-user2")
user3 := srv.CreateUser(t, "multi-user3")
c1 := servertest.NewClient(t, srv, "multi-node1", servertest.WithUser(user1))
c2 := servertest.NewClient(t, srv, "multi-node2", servertest.WithUser(user2))
c3 := servertest.NewClient(t, srv, "multi-node3", servertest.WithUser(user3))
// With default allow-all, all should see each other.
c1.WaitForPeers(t, 2, 15*time.Second)
c2.WaitForPeers(t, 2, 15*time.Second)
c3.WaitForPeers(t, 2, 15*time.Second)
servertest.AssertMeshComplete(t,
[]*servertest.TestClient{c1, c2, c3})
})
}

View File

@@ -0,0 +1,232 @@
package servertest_test
import (
"context"
"net/netip"
"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.
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 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 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 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 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)
}
}
})
}
// findNodeID looks up a node's ID by hostname in the server state.
func findNodeID(tb testing.TB, srv *servertest.TestServer, hostname string) types.NodeID {
tb.Helper()
nodes := srv.State().ListNodes()
for i := range nodes.Len() {
n := nodes.At(i)
if n.Hostname() == hostname {
return n.ID()
}
}
tb.Fatalf("node %q not found in server state", hostname)
return 0
}