Files
headscale/hscontrol/servertest/routes_test.go
T
Kristoffer Dalby 47d82023e7 state: never serve a single address inside the node ranges
Such a route names a node, not a subnet: it stays approved and listed
but no viewer gets it. Via inclusions skip the election, so the rule
sits on everything RoutesForPeer returns.

Fixes ANT-2026-MLP8ZR5T
2026-10-29 14:00:02 +01:00

727 lines
25 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package servertest_test
import (
"context"
"fmt"
"maps"
"net/http"
"net/netip"
"path/filepath"
"slices"
"strings"
"sync"
"testing"
"time"
"github.com/juanfont/headscale/hscontrol/servertest"
"github.com/juanfont/headscale/hscontrol/types"
"github.com/juanfont/headscale/hscontrol/types/testcapture"
"github.com/juanfont/headscale/hscontrol/util"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"tailscale.com/tailcfg"
"tailscale.com/types/netmap"
"tailscale.com/types/views"
)
// 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.
// routeOverlapCompatTests lists captures whose routers announce addresses
// and prefixes of the tailnet's own range, approved by the policy's
// autoApprovers (t1–t3), an admin key (t4) or a devices:routes token (t5).
var routeOverlapCompatTests = []string{
"routes-t1-auto-v4-default",
"routes-t2-auto-v6-default",
"routes-t3-auto-narrow-keys",
"routes-t4-admin",
"routes-t5-oauth-routes-scope",
}
// routeOverlapSkipReasons lists checks where headscale differs from SaaS,
// keyed <test_id>/<node>/<check>. A listed check that matches fails, so a fix
// must delete its entry.
var routeOverlapSkipReasons = map[string]string{
// Once a router is sent its own routes, its self-primary also needs the
// approved singles in the tailnet range left unserved.
"routes-t1-auto-v4-default/ovl-r-ctrl/self-allowed": selfRoutesReduced,
"routes-t1-auto-v4-default/ovl-r-ctrl/self-primary": selfRoutesReduced,
"routes-t1-auto-v4-default/ovl-r-host4/self-allowed": selfRoutesReduced,
"routes-t1-auto-v4-default/ovl-r-narrow/self-allowed": selfRoutesReduced,
"routes-t1-auto-v4-default/ovl-r-narrow/self-primary": selfRoutesReduced,
"routes-t2-auto-v6-default/ovl-r-host6/self-allowed": selfRoutesReduced,
"routes-t2-auto-v6-default/ovl-r-mapped/self-allowed": selfRoutesReduced,
"routes-t2-auto-v6-default/ovl-r-mapped/self-primary": selfRoutesReduced,
"routes-t2-auto-v6-default/ovl-r-narrow/self-allowed": selfRoutesReduced,
"routes-t2-auto-v6-default/ovl-r-narrow/self-primary": selfRoutesReduced,
"routes-t2-auto-v6-default/ovl-r-via/self-allowed": selfRoutesReduced,
"routes-t2-auto-v6-default/ovl-r-via/self-primary": selfRoutesReduced,
"routes-t3-auto-narrow-keys/ovl-r-ctrl/self-allowed": selfRoutesReduced,
"routes-t3-auto-narrow-keys/ovl-r-ctrl/self-primary": selfRoutesReduced,
"routes-t3-auto-narrow-keys/ovl-r-host4/self-allowed": selfRoutesReduced,
"routes-t3-auto-narrow-keys/ovl-r-host6/self-allowed": selfRoutesReduced,
"routes-t3-auto-narrow-keys/ovl-r-narrow/self-allowed": selfRoutesReduced,
"routes-t3-auto-narrow-keys/ovl-r-narrow/self-primary": selfRoutesReduced,
"routes-t3-auto-narrow-keys/ovl-r-via/self-allowed": selfRoutesReduced,
"routes-t3-auto-narrow-keys/ovl-r-via/self-primary": selfRoutesReduced,
"routes-t4-admin/ovl-r-ctrl/self-allowed": selfRoutesReduced,
"routes-t4-admin/ovl-r-ctrl/self-primary": selfRoutesReduced,
"routes-t4-admin/ovl-r-host4/self-allowed": selfRoutesReduced,
"routes-t4-admin/ovl-r-host6/self-allowed": selfRoutesReduced,
"routes-t4-admin/ovl-r-mapped/self-allowed": selfRoutesReduced,
"routes-t4-admin/ovl-r-mapped/self-primary": selfRoutesReduced,
"routes-t4-admin/ovl-r-narrow/self-allowed": selfRoutesReduced,
"routes-t4-admin/ovl-r-narrow/self-primary": selfRoutesReduced,
"routes-t4-admin/ovl-r-via/self-allowed": selfRoutesReduced,
"routes-t4-admin/ovl-r-via/self-primary": selfRoutesReduced,
"routes-t5-oauth-routes-scope/ovl-r-ctrl/self-allowed": selfRoutesReduced,
"routes-t5-oauth-routes-scope/ovl-r-ctrl/self-primary": selfRoutesReduced,
"routes-t5-oauth-routes-scope/ovl-r-host4/self-allowed": selfRoutesReduced,
"routes-t5-oauth-routes-scope/ovl-r-host6/self-allowed": selfRoutesReduced,
"routes-t5-oauth-routes-scope/ovl-r-mapped/self-allowed": selfRoutesReduced,
"routes-t5-oauth-routes-scope/ovl-r-mapped/self-primary": selfRoutesReduced,
"routes-t5-oauth-routes-scope/ovl-r-narrow/self-allowed": selfRoutesReduced,
"routes-t5-oauth-routes-scope/ovl-r-narrow/self-primary": selfRoutesReduced,
"routes-t5-oauth-routes-scope/ovl-r-via/self-allowed": selfRoutesReduced,
"routes-t5-oauth-routes-scope/ovl-r-via/self-primary": selfRoutesReduced,
}
// routeOverlapNotCompared lists, by key prefix, checks left out: SaaS's
// answer there is in doubt, so it is no reference.
var routeOverlapNotCompared = []string{
"routes-t1-auto-v4-default/ovl-r-ctrl/peers",
"routes-t1-auto-v4-default/ovl-r-host4/peers",
"routes-t1-auto-v4-default/ovl-r-host6/peers",
"routes-t1-auto-v4-default/ovl-r-mapped/peers",
"routes-t1-auto-v4-default/ovl-r-narrow/peers",
"routes-t1-auto-v4-default/ovl-r-via/peers",
"routes-t1-auto-v4-default/ovl-r-wide-in/peers",
"routes-t2-auto-v6-default/ovl-r-ctrl/peers",
"routes-t2-auto-v6-default/ovl-r-host4/peers",
"routes-t2-auto-v6-default/ovl-r-host6/peers",
"routes-t2-auto-v6-default/ovl-r-mapped/peers",
"routes-t2-auto-v6-default/ovl-r-narrow/peers",
"routes-t2-auto-v6-default/ovl-r-via/peers",
"routes-t3-auto-narrow-keys/ovl-r-ctrl/peer-routes",
"routes-t3-auto-narrow-keys/ovl-r-ctrl/peers",
"routes-t3-auto-narrow-keys/ovl-r-host4/peers",
"routes-t3-auto-narrow-keys/ovl-r-host6/peers",
"routes-t3-auto-narrow-keys/ovl-r-mapped/peer-routes",
"routes-t3-auto-narrow-keys/ovl-r-mapped/peers",
"routes-t3-auto-narrow-keys/ovl-r-narrow/peer-routes",
"routes-t3-auto-narrow-keys/ovl-r-narrow/peers",
"routes-t3-auto-narrow-keys/ovl-r-via/peer-routes",
"routes-t3-auto-narrow-keys/ovl-r-via/peers",
"routes-t4-admin/ovl-r-ctrl/peer-routes",
"routes-t4-admin/ovl-r-ctrl/peers",
"routes-t4-admin/ovl-r-host4/peers",
"routes-t4-admin/ovl-r-host6/peers",
"routes-t4-admin/ovl-r-mapped/peer-routes",
"routes-t4-admin/ovl-r-mapped/peers",
"routes-t4-admin/ovl-r-narrow/peer-routes",
"routes-t4-admin/ovl-r-narrow/peers",
"routes-t4-admin/ovl-r-via/peer-routes",
"routes-t4-admin/ovl-r-via/peers",
"routes-t5-oauth-routes-scope/ovl-r-ctrl/peer-routes",
"routes-t5-oauth-routes-scope/ovl-r-ctrl/peers",
"routes-t5-oauth-routes-scope/ovl-r-host4/peers",
"routes-t5-oauth-routes-scope/ovl-r-host6/peers",
"routes-t5-oauth-routes-scope/ovl-r-mapped/peer-routes",
"routes-t5-oauth-routes-scope/ovl-r-mapped/peers",
"routes-t5-oauth-routes-scope/ovl-r-narrow/peer-routes",
"routes-t5-oauth-routes-scope/ovl-r-narrow/peers",
"routes-t5-oauth-routes-scope/ovl-r-via/peer-routes",
"routes-t5-oauth-routes-scope/ovl-r-via/peers",
}
// TestRoutesTailnetOverlapCompat replays routers whose routes overlap the
// tailnet's addresses and compares approvals, served routes, peers and
// filters with SaaS. Headscale must hand out SaaS's addresses, so a route
// naming a node's address names the same node here and AllowedIPs compare
// literally.
//
// Test data source: ../policy/v2/testdata/routes_results/, shared with
// TestRoutesCompat* in policy/v2.
func TestRoutesTailnetOverlapCompat(t *testing.T) {
t.Parallel()
for _, id := range routeOverlapCompatTests {
t.Run(id, func(t *testing.T) {
t.Parallel()
c, err := testcapture.Read(filepath.Join(
"..", "policy", "v2", "testdata", "routes_results", id+".hujson",
))
require.NoError(t, err)
srv, clients := startCaptureClients(t, c, routesSetByAPI(c))
names := slices.Sorted(maps.Keys(clients))
for _, name := range names {
want, got := c.Captures[name].Netmap.SelfNode, clients[name].Netmap().SelfNode
require.Equalf(t, prefixList(want.Addresses()), prefixList(got.Addresses()),
"%s: addresses differ from SaaS, so routes would name other nodes", name)
}
saasAddrs := saasAddrsByNode(c, clients)
// Each node's checks read only its own netmap and node, so the
// holds run at once.
var wg sync.WaitGroup
for _, name := range names {
wg.Go(func() {
t.Run(name, func(t *testing.T) {
key := id + "/" + name + "/"
want := c.Captures[name].Netmap
topo := c.Topology.Nodes[name]
nodeID := findNodeID(t, srv, name)
// Approvals and routes reach clients in batches; retry
// the comparisons until they hold.
requireNetmapHolds(t, func(t require.TestingT) {
nm := clients[name].Netmap()
if len(topo.RoutableIPs) > 0 {
node, ok := srv.State().GetNodeByID(nodeID)
require.True(t, ok)
overlapCheck(t, key+"approved",
sortedStrings(topo.ApprovedRoutes),
sortedStrings(util.PrefixesToString(node.ApprovedRoutes().AsSlice())))
}
wantPeers, gotPeers := peerRoutes(want, clients), peerRoutes(nm, clients)
overlapCheck(t, key+"peers",
strings.Join(slices.Sorted(maps.Keys(wantPeers)), "\n"),
strings.Join(slices.Sorted(maps.Keys(gotPeers)), "\n"))
// Peers on one side only are the peers check's; here
// only what both sides show is compared.
var wantRoutes, gotRoutes []string
for peer, routes := range wantPeers {
if got, ok := gotPeers[peer]; ok {
wantRoutes = append(wantRoutes, peer+": "+routes)
gotRoutes = append(gotRoutes, peer+": "+got)
}
}
overlapCheck(t, key+"peer-routes",
strings.Join(slices.Sorted(slices.Values(wantRoutes)), "\n"),
strings.Join(slices.Sorted(slices.Values(gotRoutes)), "\n"))
comparePacketFilter(t, nm, c.Captures[name], clients, saasAddrs,
key+"packet-filter", routeOverlapSkipReasons)
overlapCheck(t, key+"self-primary",
prefixList(want.SelfNode.PrimaryRoutes()), prefixList(nm.SelfNode.PrimaryRoutes()))
overlapCheck(t, key+"self-allowed",
selfAllowed(want.SelfNode), selfAllowed(nm.SelfNode))
})
})
})
}
wg.Wait()
})
}
}
// routesSetByAPI reports whether the capture's routes were approved through
// the routes API rather than by the policy.
func routesSetByAPI(c *testcapture.Capture) bool {
for _, step := range c.Steps {
for _, a := range step.Actions {
if a.Kind == "api" && a.Method == http.MethodPost &&
strings.HasSuffix(a.Path, "/routes") && a.Status == http.StatusOK {
return true
}
}
}
return false
}
// overlapCheck compares one rendering of a check, honouring
// routeOverlapSkipReasons: a listed check must keep differing.
func overlapCheck(t require.TestingT, key, want, got string) {
if notCompared(routeOverlapNotCompared, key) {
return
}
reason, listed := routeOverlapSkipReasons[key]
switch {
case listed && want == got:
t.Errorf("matches SaaS; delete routeOverlapSkipReasons[%q] (%s)", key, reason)
case !listed:
assert.Equalf(t, want, got, "%s", key)
}
}
// notCompared reports whether key falls under one of prefixes.
func notCompared(prefixes []string, key string) bool {
return slices.ContainsFunc(prefixes, func(p string) bool {
return strings.HasPrefix(key, p)
})
}
// peerRoutes maps each peer in clients to its AllowedIPs and PrimaryRoutes.
func peerRoutes(
nm *netmap.NetworkMap,
clients map[string]*servertest.TestClient,
) map[string]string {
out := map[string]string{}
for _, p := range nm.Peers {
name := extractHostname(p.Name())
if hi := p.Hostinfo(); hi.Valid() && hi.Hostname() != "" {
name = hi.Hostname()
}
if _, ok := clients[name]; ok {
out[name] = "allowed " + prefixList(p.AllowedIPs()) + " primary " + prefixList(p.PrimaryRoutes())
}
}
return out
}
// selfAllowed is the node's AllowedIPs without its own addresses.
func selfAllowed(self tailcfg.NodeView) string {
var out []netip.Prefix
for _, pfx := range self.AllowedIPs().All() {
if !views.SliceContains(self.Addresses(), pfx) {
out = append(out, pfx)
}
}
return prefixList(views.SliceOf(out))
}
func prefixList(pfxs views.Slice[netip.Prefix]) string {
return sortedStrings(util.PrefixesToString(pfxs.AsSlice()))
}
func sortedStrings(s []string) string {
return fmt.Sprint(slices.Sorted(slices.Values(s)))
}
// TestRoutesNodeRangeSingleNotServed checks that a router's approved single
// addresses inside the node ranges, in either spelling, reach no viewer
// through a plain grant or a via grant. They stay approved, the router's own
// entry keeps them, and its wider route is still served.
func TestRoutesNodeRangeSingleNotServed(t *testing.T) {
t.Parallel()
subnet := netip.MustParsePrefix("10.9.0.0/24")
singles := []netip.Prefix{
netip.MustParsePrefix("100.64.0.50/32"),
netip.MustParsePrefix("fd7a:115c:a1e0::50/128"),
netip.MustParsePrefix("::ffff:100.64.0.50/128"),
}
routes := append([]netip.Prefix{subnet}, singles...)
for name, grant := range map[string]string{
"plain": `{"src": ["tag:client"], "dst": ["*"], "ip": ["*"]}`,
"via": `{"src": ["tag:client"], "ip": ["*"], "via": ["tag:router"],
"dst": ["10.9.0.0/24", "100.64.0.0/10", "fd7a:115c:a1e0::/48", "::ffff:0:0/96"]}`,
} {
t.Run(name, func(t *testing.T) {
t.Parallel()
srv := servertest.NewServer(t)
user := srv.CreateUser(t, "routes-user")
changed, err := srv.State().SetPolicy([]byte(`{
"tagOwners": {"tag:client": ["routes-user@"], "tag:router": ["routes-user@"]},
"grants": [
{"src": ["tag:client"], "dst": ["tag:router"], "ip": ["*"]},
{"src": ["tag:router"], "dst": ["*"], "ip": ["*"]},
` + grant + `
]
}`))
require.NoError(t, err)
if changed {
changes, err := srv.State().ReloadPolicy()
require.NoError(t, err)
srv.App.Change(changes...)
}
router := servertest.NewClient(t, srv, "router",
servertest.WithUser(user), servertest.WithTags("tag:router"),
servertest.WithHostinfo(&tailcfg.Hostinfo{
BackendLogID: "servertest-router", Hostname: "router", RoutableIPs: routes,
}))
viewer := servertest.NewClient(t, srv, "viewer",
servertest.WithUser(user), servertest.WithTags("tag:client"))
viewer.WaitForPeers(t, 1, 15*time.Second)
routerID := findNodeID(t, srv, "router")
_, routeChange, err := srv.State().SetApprovedRoutes(routerID, routes)
require.NoError(t, err)
srv.App.Change(routeChange)
viewer.WaitForCondition(t, "viewer is served the subnet", 15*time.Second,
func(nm *netmap.NetworkMap) bool {
p, ok := viewer.PeerByName("router")
return ok && views.SliceContains(p.PrimaryRoutes(), subnet)
})
router.WaitForCondition(t, "router holds its routes", 15*time.Second,
func(nm *netmap.NetworkMap) bool {
return views.SliceContains(nm.SelfNode.AllowedIPs(), subnet)
})
peer, _ := viewer.PeerByName("router")
self := router.Netmap().SelfNode
for _, single := range singles {
assert.Falsef(t, views.SliceContains(peer.AllowedIPs(), single),
"%s served in AllowedIPs", single)
assert.Falsef(t, views.SliceContains(peer.PrimaryRoutes(), single),
"%s served in PrimaryRoutes", single)
assert.Truef(t, views.SliceContains(self.AllowedIPs(), single),
"%s missing from the router's own AllowedIPs", single)
}
node, ok := srv.State().GetNodeByID(routerID)
require.True(t, ok)
assert.ElementsMatch(t, routes, node.ApprovedRoutes().AsSlice())
})
}
}