mirror of
https://github.com/juanfont/headscale.git
synced 2026-10-31 02:34:07 +09:00
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
727 lines
25 KiB
Go
727 lines
25 KiB
Go
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())
|
||
})
|
||
}
|
||
}
|