mirror of
https://github.com/juanfont/headscale.git
synced 2026-07-07 16:40:21 +09:00
state: include approved exit routes in RoutesForPeer
SaaS golden data (routes-ea* captures) proves that approved exit routes appear in every peer's AllowedIPs. The previous commit incorrectly dropped them based on a via-grant scenario where exit routes were not approved. TestRoutesCompatPeerAllowedIPs validates this against all captured netmaps: peer AllowedIPs = node IPs + HA primary routes + exit routes, filtered through ReduceRoutes. Updates #3157
This commit is contained in:
@@ -1383,3 +1383,147 @@ func TestRoutesCompatNoFalsePositivePeers(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// prefixStrings converts a slice of netip.Prefix to sorted strings for
|
||||
// readable comparison in test assertions.
|
||||
func prefixStrings(pfxs []netip.Prefix) []string {
|
||||
out := make([]string, len(pfxs))
|
||||
for i, p := range pfxs {
|
||||
out[i] = p.String()
|
||||
}
|
||||
|
||||
sort.Strings(out)
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
// TestRoutesCompatPeerAllowedIPs validates that headscale computes the same
|
||||
// peer AllowedIPs as Tailscale SaaS. For each golden file that contains
|
||||
// netmap captures, the test compares the AllowedIPs that SaaS delivered
|
||||
// for each peer against what headscale's ReduceRoutes (the core of
|
||||
// RoutesForPeer) would produce.
|
||||
//
|
||||
// This is the authoritative proof that approved exit routes (0.0.0.0/0,
|
||||
// ::/0) belong in peer AllowedIPs: the ea-series captures from SaaS show
|
||||
// exit routes in every peer's AllowedIPs when they are approved, and
|
||||
// absent when they are not (b9 series).
|
||||
func TestRoutesCompatPeerAllowedIPs(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
files, err := filepath.Glob(
|
||||
filepath.Join("testdata", "routes_results", "routes-*.hujson"),
|
||||
)
|
||||
require.NoError(t, err, "failed to glob test files")
|
||||
require.NotEmpty(t, files)
|
||||
|
||||
// Count how many files actually had netmap data to test.
|
||||
testedFiles := 0
|
||||
|
||||
for _, file := range files {
|
||||
tf := loadRoutesTestFile(t, file)
|
||||
|
||||
// Only test files that have netmap captures with peers.
|
||||
hasNetmap := false
|
||||
|
||||
for _, capture := range tf.Captures {
|
||||
if capture.Netmap != nil && len(capture.Netmap.Peers) > 0 {
|
||||
hasNetmap = true
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !hasNetmap {
|
||||
continue
|
||||
}
|
||||
|
||||
testedFiles++
|
||||
|
||||
t.Run(tf.TestID, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
if reason, ok := routesSkipReasons[tf.TestID]; ok {
|
||||
t.Skipf("TODO: %s", reason)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if tf.Error {
|
||||
t.Skipf("%s: SaaS rejected the policy", tf.TestID)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
users, nodes := buildRoutesUsersAndNodes(t, tf.Topology)
|
||||
policyJSON := convertPolicyUserEmails(tf.Input.FullPolicy)
|
||||
|
||||
pm, err := NewPolicyManager(policyJSON, users, nodes.ViewSlice())
|
||||
require.NoError(t, err, "%s: failed to create policy manager", tf.TestID)
|
||||
|
||||
for viewerName, capture := range tf.Captures {
|
||||
if capture.Netmap == nil || len(capture.Netmap.Peers) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
viewer := findNodeByGivenName(nodes, viewerName)
|
||||
if viewer == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
t.Run(viewerName, func(t *testing.T) {
|
||||
matchers, err := pm.MatchersForNode(viewer.View())
|
||||
require.NoError(t, err)
|
||||
|
||||
for _, nmPeer := range capture.Netmap.Peers {
|
||||
// Extract the short name from the FQDN.
|
||||
peerName := strings.Split(nmPeer.Name(), ".")[0]
|
||||
|
||||
peer := findNodeByGivenName(nodes, peerName)
|
||||
if peer == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// Compute what headscale would put in AllowedIPs.
|
||||
//
|
||||
// The SaaS netmap PrimaryRoutes tells us which
|
||||
// subnet routes won HA election. Exit routes
|
||||
// (0.0.0.0/0, ::/0) are never in PrimaryRoutes
|
||||
// but DO appear in AllowedIPs when approved.
|
||||
// This mirrors RoutesForPeer: primaryRoutes + exitRoutes
|
||||
// filtered through ReduceRoutes.
|
||||
peerPrimaries := nmPeer.PrimaryRoutes().AsSlice()
|
||||
exitRoutes := peer.ExitRoutes()
|
||||
allRoutes := slices.Concat(peerPrimaries, exitRoutes)
|
||||
|
||||
var reducedRoutes []netip.Prefix
|
||||
|
||||
for _, route := range allRoutes {
|
||||
if viewer.View().CanAccessRoute(matchers, route) {
|
||||
reducedRoutes = append(reducedRoutes, route)
|
||||
}
|
||||
}
|
||||
|
||||
gotAllowedIPs := slices.Concat(
|
||||
peer.View().Prefixes(), reducedRoutes,
|
||||
)
|
||||
slices.SortFunc(gotAllowedIPs, netip.Prefix.Compare)
|
||||
|
||||
wantAllowedIPs := nmPeer.AllowedIPs().AsSlice()
|
||||
slices.SortFunc(wantAllowedIPs, netip.Prefix.Compare)
|
||||
|
||||
assert.Equalf(t,
|
||||
prefixStrings(wantAllowedIPs),
|
||||
prefixStrings(gotAllowedIPs),
|
||||
"%s/%s: peer %s AllowedIPs mismatch",
|
||||
tf.TestID, viewerName, peerName,
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
require.Positive(t, testedFiles,
|
||||
"no golden files with netmap data found — test is vacuous",
|
||||
)
|
||||
}
|
||||
|
||||
+13
-18
@@ -1108,37 +1108,32 @@ func (s *State) GetNodePrimaryRoutes(nodeID types.NodeID) []netip.Prefix {
|
||||
}
|
||||
|
||||
// RoutesForPeer computes the routes a peer should advertise in a viewer's
|
||||
// AllowedIPs, applying via grant steering on top of global primary
|
||||
// election.
|
||||
// AllowedIPs, combining primary routes (from HA election), approved exit
|
||||
// routes, and via grant steering.
|
||||
//
|
||||
// Exit routes (0.0.0.0/0, ::/0) are deliberately NOT included here.
|
||||
// Tailscale SaaS only ever places exit routes in the self node's own
|
||||
// AllowedIPs (handled separately by mapper.WithSelfNode), never in a
|
||||
// peer's AllowedIPs when that peer is viewed from another node.
|
||||
// Verified by polling /localapi/v0/status against a live SaaS tailnet
|
||||
// running the via-grant-v31 scenario: even nodes that hold an
|
||||
// explicit via grant for an exit-route peer see the peer's
|
||||
// AllowedIPs as just its /32 + /128, with ExitNodeOption=false.
|
||||
//
|
||||
// autogroup:internet via grants are also a no-op in this path:
|
||||
// ViaRoutesForPeer skips AutoGroup destinations (see policy.go),
|
||||
// so neither viaResult.Include nor viaResult.Exclude can carry
|
||||
// exit-route prefixes.
|
||||
// Approved exit routes (0.0.0.0/0, ::/0) are included alongside subnet
|
||||
// routes. SaaS golden data (routes-ea* captures) confirms that approved
|
||||
// exit routes appear in every peer's AllowedIPs, while unapproved ones
|
||||
// (routes-b9) do not. TestRoutesCompatPeerAllowedIPs validates this
|
||||
// against all captured netmaps.
|
||||
func (s *State) RoutesForPeer(
|
||||
viewer, peer types.NodeView,
|
||||
matchers []matcher.Match,
|
||||
) []netip.Prefix {
|
||||
viaResult := s.polMan.ViaRoutesForPeer(viewer, peer)
|
||||
globalPrimaries := s.primaryRoutes.PrimaryRoutes(peer.ID())
|
||||
exitRoutes := peer.ExitRoutes()
|
||||
|
||||
// Fast path: no via grants affect this pair.
|
||||
if len(viaResult.Include) == 0 && len(viaResult.Exclude) == 0 {
|
||||
return policy.ReduceRoutes(viewer, globalPrimaries, matchers)
|
||||
allRoutes := slices.Concat(globalPrimaries, exitRoutes)
|
||||
|
||||
return policy.ReduceRoutes(viewer, allRoutes, matchers)
|
||||
}
|
||||
|
||||
// Slow path: drop excluded routes, reduce, append via-included.
|
||||
routes := make([]netip.Prefix, 0, len(globalPrimaries))
|
||||
for _, p := range globalPrimaries {
|
||||
routes := make([]netip.Prefix, 0, len(globalPrimaries)+len(exitRoutes))
|
||||
for _, p := range slices.Concat(globalPrimaries, exitRoutes) {
|
||||
if !slices.Contains(viaResult.Exclude, p) {
|
||||
routes = append(routes, p)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user