servertest: add via grant map compat tests
End-to-end exercise of via-grant compilation against SaaS captures: peer visibility, AllowedIPs, PrimaryRoutes, and per-rule src/dst reachability from each viewer's perspective. Updates #3157
This commit is contained in:
parent
a7c9721faa
commit
7d104b8c8d
@ -1,10 +1,15 @@
|
|||||||
|
// This file implements data-driven via grant compatibility tests using
|
||||||
|
// golden data captured from Tailscale SaaS (v29, v30, v31, v33, v35,
|
||||||
|
// v36). These scenarios exercise via grant steering with peer
|
||||||
|
// connectivity and cross-subnet forwarding.
|
||||||
|
//
|
||||||
|
// Test data source: ../policy/v2/testdata/grant_results/via-grant-v{29,30,31,33,35,36}.hujson
|
||||||
|
// Source format: github.com/juanfont/headscale/hscontrol/types/testcapture
|
||||||
package servertest_test
|
package servertest_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
|
||||||
"net/netip"
|
"net/netip"
|
||||||
"os"
|
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"slices"
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
@ -12,68 +17,24 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/juanfont/headscale/hscontrol/servertest"
|
"github.com/juanfont/headscale/hscontrol/servertest"
|
||||||
|
"github.com/juanfont/headscale/hscontrol/types/testcapture"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
"github.com/tailscale/hujson"
|
|
||||||
"tailscale.com/tailcfg"
|
"tailscale.com/tailcfg"
|
||||||
"tailscale.com/types/netmap"
|
"tailscale.com/types/netmap"
|
||||||
)
|
)
|
||||||
|
|
||||||
// goldenFile represents a golden capture from Tailscale SaaS with full
|
|
||||||
// netmap data per node.
|
|
||||||
type goldenFile struct {
|
|
||||||
TestID string `json:"test_id"`
|
|
||||||
Error bool `json:"error"`
|
|
||||||
Input struct {
|
|
||||||
FullPolicy json.RawMessage `json:"full_policy"`
|
|
||||||
} `json:"input"`
|
|
||||||
Topology struct {
|
|
||||||
Nodes map[string]goldenNode `json:"nodes"`
|
|
||||||
} `json:"topology"`
|
|
||||||
Captures map[string]goldenCapture `json:"captures"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type goldenNode struct {
|
|
||||||
Hostname string `json:"hostname"`
|
|
||||||
Tags []string `json:"tags"`
|
|
||||||
IPv4 string `json:"ipv4"`
|
|
||||||
IPv6 string `json:"ipv6"`
|
|
||||||
AdvertisedRoutes []string `json:"advertised_routes"`
|
|
||||||
IsExitNode bool `json:"is_exit_node"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type goldenCapture struct {
|
|
||||||
PacketFilterRules json.RawMessage `json:"packet_filter_rules"`
|
|
||||||
Netmap *goldenNetmap `json:"netmap"`
|
|
||||||
Whois map[string]goldenWhois `json:"whois"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type goldenNetmap struct {
|
|
||||||
Peers []goldenPeer `json:"Peers"`
|
|
||||||
PacketFilterRules json.RawMessage `json:"PacketFilterRules"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type goldenPeer struct {
|
|
||||||
Name string `json:"Name"`
|
|
||||||
AllowedIPs []string `json:"AllowedIPs"`
|
|
||||||
PrimaryRoutes []string `json:"PrimaryRoutes"`
|
|
||||||
Tags []string `json:"Tags"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type goldenWhois struct {
|
|
||||||
PeerName string `json:"peer_name"`
|
|
||||||
Response *json.RawMessage `json:"response"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// viaCompatTests lists golden captures that exercise via grant steering.
|
// viaCompatTests lists golden captures that exercise via grant steering.
|
||||||
var viaCompatTests = []struct {
|
var viaCompatTests = []struct {
|
||||||
id string
|
id string
|
||||||
desc string
|
desc string
|
||||||
}{
|
}{
|
||||||
{"GRANT-V29", "crossed subnet steering: group-a via router-a, group-b via router-b"},
|
{"via-grant-v29", "crossed subnet steering: group-a via router-a, group-b via router-b"},
|
||||||
{"GRANT-V30", "crossed mixed: subnet via router-a/b, exit via exit-b/a"},
|
{"via-grant-v30", "crossed mixed: subnet via router-a/b, exit via exit-b/a"},
|
||||||
{"GRANT-V31", "peer connectivity + via exit A/B steering"},
|
{"via-grant-v31", "peer connectivity + via exit A/B steering"},
|
||||||
{"GRANT-V36", "full complex: peer connectivity + crossed subnet + crossed exit"},
|
{"via-grant-v33", "single via grant + HA primary election"},
|
||||||
|
{"via-grant-v35", "via grant with unadvertised destination"},
|
||||||
|
{"via-grant-v36", "full complex: peer connectivity + crossed subnet + crossed exit"},
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestViaGrantMapCompat loads golden captures from Tailscale SaaS and
|
// TestViaGrantMapCompat loads golden captures from Tailscale SaaS and
|
||||||
@ -85,9 +46,10 @@ var viaCompatTests = []struct {
|
|||||||
//
|
//
|
||||||
// CROSS-DEPENDENCY WARNING:
|
// CROSS-DEPENDENCY WARNING:
|
||||||
// This test reads golden files from ../policy/v2/testdata/grant_results/
|
// This test reads golden files from ../policy/v2/testdata/grant_results/
|
||||||
// (specifically GRANT-V29, V30, V31, V36). These files are shared with
|
// (specifically via-grant-v29, v30, v31, v33, v35, v36). These files are shared
|
||||||
// TestGrantsCompat in the policy/v2 package. Any changes to the file
|
// with TestGrantsCompat in the policy/v2 package. Any changes to the
|
||||||
// format, field structure, or naming must be coordinated with BOTH tests.
|
// file format, field structure, or naming must be coordinated with
|
||||||
|
// BOTH tests.
|
||||||
//
|
//
|
||||||
// Fields consumed by this test (but NOT by TestGrantsCompat):
|
// Fields consumed by this test (but NOT by TestGrantsCompat):
|
||||||
// - captures[name].netmap (Peers, AllowedIPs, PrimaryRoutes, PacketFilterRules)
|
// - captures[name].netmap (Peers, AllowedIPs, PrimaryRoutes, PacketFilterRules)
|
||||||
@ -106,43 +68,28 @@ func TestViaGrantMapCompat(t *testing.T) {
|
|||||||
path := filepath.Join(
|
path := filepath.Join(
|
||||||
"..", "policy", "v2", "testdata", "grant_results", tc.id+".hujson",
|
"..", "policy", "v2", "testdata", "grant_results", tc.id+".hujson",
|
||||||
)
|
)
|
||||||
data, err := os.ReadFile(path)
|
|
||||||
require.NoError(t, err, "failed to read golden file %s", path)
|
|
||||||
|
|
||||||
ast, err := hujson.Parse(data)
|
c, err := testcapture.Read(path)
|
||||||
require.NoError(t, err, "failed to parse HuJSON in %s", path)
|
require.NoError(t, err, "failed to read %s", path)
|
||||||
ast.Standardize()
|
|
||||||
|
|
||||||
var gf goldenFile
|
if c.Error {
|
||||||
require.NoError(t, json.Unmarshal(ast.Pack(), &gf))
|
|
||||||
|
|
||||||
if gf.Error {
|
|
||||||
t.Skipf("test %s is an error case", tc.id)
|
t.Skipf("test %s is an error case", tc.id)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
runViaMapCompat(t, gf)
|
runViaMapCompat(t, c)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// taggedNodes are the nodes we create in the servertest.
|
func runViaMapCompat(t *testing.T, c *testcapture.Capture) {
|
||||||
var taggedNodes = []string{
|
|
||||||
"exit-a", "exit-b", "exit-node",
|
|
||||||
"group-a-client", "group-b-client",
|
|
||||||
"router-a", "router-b",
|
|
||||||
"subnet-router", "tagged-client",
|
|
||||||
"tagged-server", "tagged-prod",
|
|
||||||
"multi-exit-router",
|
|
||||||
}
|
|
||||||
|
|
||||||
func runViaMapCompat(t *testing.T, gf goldenFile) {
|
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
srv := servertest.NewServer(t)
|
srv := servertest.NewServer(t)
|
||||||
tagUser := srv.CreateUser(t, "tag-user")
|
tagUser := srv.CreateUser(t, "tag-user")
|
||||||
|
|
||||||
policyJSON := convertViaPolicy(gf.Input.FullPolicy)
|
policyJSON := convertCapturePolicy(t, c)
|
||||||
|
|
||||||
changed, err := srv.State().SetPolicy(policyJSON)
|
changed, err := srv.State().SetPolicy(policyJSON)
|
||||||
require.NoError(t, err, "failed to set policy")
|
require.NoError(t, err, "failed to set policy")
|
||||||
@ -154,15 +101,20 @@ func runViaMapCompat(t *testing.T, gf goldenFile) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Create tagged clients matching the golden topology.
|
// Create tagged clients matching the golden topology.
|
||||||
|
// Nodes are created in SaaS registration order so headscale assigns
|
||||||
|
// sequential DB IDs in the same relative order. This matters for
|
||||||
|
// PrimaryRoutes election which uses lowest-node-ID-wins — the
|
||||||
|
// tiebreaker must pick the same node as SaaS.
|
||||||
clients := map[string]*servertest.TestClient{}
|
clients := map[string]*servertest.TestClient{}
|
||||||
|
order := captureNodeOrder(t, c)
|
||||||
|
|
||||||
for _, name := range taggedNodes {
|
for _, name := range order {
|
||||||
topoNode, exists := gf.Topology.Nodes[name]
|
topoNode, exists := c.Topology.Nodes[name]
|
||||||
if !exists || len(topoNode.Tags) == 0 {
|
if !exists || len(topoNode.Tags) == 0 {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if _, inCaptures := gf.Captures[name]; !inCaptures {
|
if _, inCaptures := c.Captures[name]; !inCaptures {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -175,39 +127,63 @@ func runViaMapCompat(t *testing.T, gf goldenFile) {
|
|||||||
require.NotEmpty(t, clients, "no relevant nodes created")
|
require.NotEmpty(t, clients, "no relevant nodes created")
|
||||||
|
|
||||||
// Determine which routes each node should advertise. If the golden
|
// Determine which routes each node should advertise. If the golden
|
||||||
// topology has explicit advertised_routes, use those. Otherwise infer
|
// topology has explicit routable_ips, use those. Otherwise infer
|
||||||
// from the policy's autoApprovers.routes: if a node's tags match an
|
// from the netmap peer AllowedIPs and packet filter dst prefixes.
|
||||||
// approver tag for a route prefix, the node should advertise it.
|
nodeRoutes := inferNodeRoutes(t, c)
|
||||||
nodeRoutes := inferNodeRoutes(gf)
|
|
||||||
|
// Build approved routes from topology. The topology's approved_routes
|
||||||
|
// field records what SaaS actually approved (which may be a subset of
|
||||||
|
// routable_ips). Using this instead of approving all advertised routes
|
||||||
|
// ensures exit routes are only approved when SaaS approved them.
|
||||||
|
nodeApproved := map[string][]netip.Prefix{}
|
||||||
|
|
||||||
|
for name, node := range c.Topology.Nodes {
|
||||||
|
for _, r := range node.ApprovedRoutes {
|
||||||
|
nodeApproved[name] = append(
|
||||||
|
nodeApproved[name], netip.MustParsePrefix(r),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Advertise and approve routes in SaaS registration order. Via
|
||||||
|
// grants depend on routes being advertised for compileViaGrant to
|
||||||
|
// produce filter rules. The order matters because PrimaryRoutes
|
||||||
|
// election is sticky — the first node to register a prefix becomes
|
||||||
|
// primary.
|
||||||
|
for _, name := range order {
|
||||||
|
cl, ok := clients[name]
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
// Advertise and approve routes FIRST. Via grants depend on routes
|
|
||||||
// being advertised for compileViaGrant to produce filter rules.
|
|
||||||
for name, c := range clients {
|
|
||||||
routes := nodeRoutes[name]
|
routes := nodeRoutes[name]
|
||||||
if len(routes) == 0 {
|
if len(routes) == 0 {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
c.Direct().SetHostinfo(&tailcfg.Hostinfo{
|
cl.Direct().SetHostinfo(&tailcfg.Hostinfo{
|
||||||
BackendLogID: "servertest-" + name,
|
BackendLogID: "servertest-" + name,
|
||||||
Hostname: name,
|
Hostname: name,
|
||||||
RoutableIPs: routes,
|
RoutableIPs: routes,
|
||||||
})
|
})
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
_ = c.Direct().SendUpdate(ctx)
|
require.NoError(t, cl.Direct().SendUpdate(ctx),
|
||||||
|
"route advertisement for %s should succeed", name)
|
||||||
|
|
||||||
cancel()
|
cancel()
|
||||||
|
|
||||||
nodeID := findNodeID(t, srv, name)
|
nodeID := findNodeID(t, srv, name)
|
||||||
_, routeChange, err := srv.State().SetApprovedRoutes(nodeID, routes)
|
_, routeChange, err := srv.State().SetApprovedRoutes(
|
||||||
|
nodeID, nodeApproved[name],
|
||||||
|
)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
srv.App.Change(routeChange)
|
srv.App.Change(routeChange)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wait for peers based on golden netmap expected counts.
|
// Wait for peers based on golden netmap expected counts.
|
||||||
for viewerName, c := range clients {
|
for viewerName, cl := range clients {
|
||||||
capture := gf.Captures[viewerName]
|
capture := c.Captures[viewerName]
|
||||||
if capture.Netmap == nil {
|
if capture.Netmap == nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@ -215,62 +191,64 @@ func runViaMapCompat(t *testing.T, gf goldenFile) {
|
|||||||
expected := 0
|
expected := 0
|
||||||
|
|
||||||
for _, peer := range capture.Netmap.Peers {
|
for _, peer := range capture.Netmap.Peers {
|
||||||
peerName := extractHostname(peer.Name)
|
peerName := extractHostname(peer.Name())
|
||||||
if _, isOurs := clients[peerName]; isOurs {
|
if _, isOurs := clients[peerName]; isOurs {
|
||||||
expected++
|
expected++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if expected > 0 {
|
if expected > 0 {
|
||||||
c.WaitForPeers(t, expected, 30*time.Second)
|
cl.WaitForPeers(t, expected, 30*time.Second)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ensure all nodes have received at least one MapResponse,
|
// Ensure all nodes have received at least one MapResponse,
|
||||||
// including nodes with 0 expected peers that skipped WaitForPeers.
|
// including nodes with 0 expected peers that skipped WaitForPeers.
|
||||||
for name, c := range clients {
|
for name, cl := range clients {
|
||||||
c.WaitForCondition(t, name+" initial netmap", 15*time.Second,
|
cl.WaitForCondition(t, name+" initial netmap", 15*time.Second,
|
||||||
func(nm *netmap.NetworkMap) bool {
|
func(nm *netmap.NetworkMap) bool {
|
||||||
return nm != nil
|
return nm != nil
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Compare each viewer's MapResponse against the golden netmap.
|
// Compare each viewer's MapResponse against the golden netmap.
|
||||||
for viewerName, c := range clients {
|
for viewerName, cl := range clients {
|
||||||
capture := gf.Captures[viewerName]
|
capture := c.Captures[viewerName]
|
||||||
if capture.Netmap == nil {
|
if capture.Netmap == nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
t.Run(viewerName, func(t *testing.T) {
|
t.Run(viewerName, func(t *testing.T) {
|
||||||
nm := c.Netmap()
|
nm := cl.Netmap()
|
||||||
require.NotNil(t, nm, "netmap is nil")
|
require.NotNil(t, nm, "netmap is nil")
|
||||||
|
|
||||||
compareNetmap(t, nm, capture.Netmap, clients)
|
compareNetmap(t, nm, capture, clients)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// compareNetmap compares the headscale MapResponse against the golden
|
// compareNetmap compares the headscale MapResponse against the
|
||||||
// netmap data in an IP-independent way. It validates:
|
// captured netmap data in an IP-independent way. It validates:
|
||||||
// - Peer visibility (which peers are present, by hostname)
|
// - Peer visibility (which peers are present, by hostname)
|
||||||
// - Route prefixes in AllowedIPs (non-Tailscale-IP entries like 10.44.0.0/16)
|
// - Route prefixes in AllowedIPs (non-Tailscale-IP entries like 10.44.0.0/16)
|
||||||
// - Number of Tailscale IPs per peer (should be 2: one v4 + one v6)
|
// - Number of Tailscale IPs per peer (should be 2: one v4 + one v6)
|
||||||
// - PrimaryRoutes per peer
|
// - PrimaryRoutes per peer
|
||||||
// - PacketFilter rule count
|
// - PacketFilter rule count and non-Tailscale dst prefixes
|
||||||
func compareNetmap(
|
func compareNetmap(
|
||||||
t *testing.T,
|
t *testing.T,
|
||||||
got *netmap.NetworkMap,
|
got *netmap.NetworkMap,
|
||||||
want *goldenNetmap,
|
want testcapture.Node,
|
||||||
clients map[string]*servertest.TestClient,
|
clients map[string]*servertest.TestClient,
|
||||||
) {
|
) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
// Build golden peer map (only peers in our client set).
|
require.NotNil(t, want.Netmap, "golden Netmap is nil")
|
||||||
wantPeers := map[string]goldenPeer{}
|
|
||||||
|
|
||||||
for _, p := range want.Peers {
|
// Build golden peer map (only peers in our client set).
|
||||||
name := extractHostname(p.Name)
|
wantPeers := map[string]tailcfg.NodeView{}
|
||||||
|
|
||||||
|
for _, p := range want.Netmap.Peers {
|
||||||
|
name := extractHostname(p.Name())
|
||||||
if _, isOurs := clients[name]; isOurs {
|
if _, isOurs := clients[name]; isOurs {
|
||||||
wantPeers[name] = p
|
wantPeers[name] = p
|
||||||
}
|
}
|
||||||
@ -335,7 +313,7 @@ func compareNetmap(
|
|||||||
for name, wantPeer := range wantPeers {
|
for name, wantPeer := range wantPeers {
|
||||||
gotPeer, visible := gotPeers[name]
|
gotPeer, visible := gotPeers[name]
|
||||||
if !visible {
|
if !visible {
|
||||||
wantRoutes := extractRoutePrefixes(wantPeer.AllowedIPs)
|
wantRoutes := extractRoutePrefixesView(wantPeer.AllowedIPs())
|
||||||
t.Errorf("peer %s: visible in Tailscale SaaS (routes=%v), missing in headscale",
|
t.Errorf("peer %s: visible in Tailscale SaaS (routes=%v), missing in headscale",
|
||||||
name, wantRoutes)
|
name, wantRoutes)
|
||||||
|
|
||||||
@ -343,7 +321,7 @@ func compareNetmap(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Compare route prefixes in AllowedIPs (IP-independent).
|
// Compare route prefixes in AllowedIPs (IP-independent).
|
||||||
wantRoutes := extractRoutePrefixes(wantPeer.AllowedIPs)
|
wantRoutes := extractRoutePrefixesView(wantPeer.AllowedIPs())
|
||||||
slices.Sort(wantRoutes)
|
slices.Sort(wantRoutes)
|
||||||
|
|
||||||
assert.Equalf(t, wantRoutes, gotPeer.RoutePrefixes,
|
assert.Equalf(t, wantRoutes, gotPeer.RoutePrefixes,
|
||||||
@ -351,7 +329,7 @@ func compareNetmap(
|
|||||||
|
|
||||||
// Tailscale IPs: count should match, and they must belong to
|
// Tailscale IPs: count should match, and they must belong to
|
||||||
// this peer (not some other node's IPs).
|
// this peer (not some other node's IPs).
|
||||||
wantTSIPCount := countTailscaleIPs(wantPeer.AllowedIPs)
|
wantTSIPCount := countTailscaleIPsView(wantPeer.AllowedIPs())
|
||||||
|
|
||||||
assert.Lenf(t, gotPeer.TailscaleIPs, wantTSIPCount,
|
assert.Lenf(t, gotPeer.TailscaleIPs, wantTSIPCount,
|
||||||
"peer %s: Tailscale IP count mismatch", name)
|
"peer %s: Tailscale IP count mismatch", name)
|
||||||
@ -376,7 +354,12 @@ func compareNetmap(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Compare PrimaryRoutes.
|
// Compare PrimaryRoutes.
|
||||||
assert.ElementsMatchf(t, wantPeer.PrimaryRoutes, gotPeer.PrimaryRoutes,
|
var wantPRoutes []string
|
||||||
|
for i := range wantPeer.PrimaryRoutes().Len() {
|
||||||
|
wantPRoutes = append(wantPRoutes, wantPeer.PrimaryRoutes().At(i).String())
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.ElementsMatchf(t, wantPRoutes, gotPeer.PrimaryRoutes,
|
||||||
"peer %s: PrimaryRoutes mismatch", name)
|
"peer %s: PrimaryRoutes mismatch", name)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -387,15 +370,255 @@ func compareNetmap(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Compare PacketFilter rule count.
|
// Compare PacketFilter rules (IP-independent).
|
||||||
var wantFilterRules []tailcfg.FilterRule
|
wantFilterRules := want.PacketFilterRules
|
||||||
if len(want.PacketFilterRules) > 0 &&
|
|
||||||
string(want.PacketFilterRules) != "null" {
|
if !assert.Lenf(t, got.PacketFilter, len(wantFilterRules),
|
||||||
_ = json.Unmarshal(want.PacketFilterRules, &wantFilterRules)
|
"PacketFilter rule count mismatch") {
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
assert.Lenf(t, got.PacketFilter, len(wantFilterRules),
|
// Resolve SaaS IPs → peer name and HS IPs → peer name so we can
|
||||||
"PacketFilter rule count mismatch")
|
// compare rule sources structurally. Tailscale IPs in SaaS vs HS
|
||||||
|
// allocations never match literally, but each IP belongs to a
|
||||||
|
// peer with a stable hostname.
|
||||||
|
saasAddrs := saasAddrsByPeer(want, clients)
|
||||||
|
hsAddrs := hsAddrsByPeer(clients)
|
||||||
|
|
||||||
|
// Compare destination prefixes per rule — subnet CIDRs like
|
||||||
|
// 10.44.0.0/16 are stable between Tailscale SaaS and headscale.
|
||||||
|
// Source IPs are re-keyed per peer identity before comparison.
|
||||||
|
for i := range wantFilterRules {
|
||||||
|
wantRule := wantFilterRules[i]
|
||||||
|
gotMatch := got.PacketFilter[i]
|
||||||
|
|
||||||
|
wantSrcIdents := canonicaliseSrcStrings(t, wantRule.SrcIPs, saasAddrs, i)
|
||||||
|
gotSrcIdents := canonicaliseSrcPrefixes(t, gotMatch.Srcs, hsAddrs, i)
|
||||||
|
|
||||||
|
assert.Equalf(t, wantSrcIdents, gotSrcIdents,
|
||||||
|
"PacketFilter[%d]: source peer identities mismatch", i)
|
||||||
|
|
||||||
|
// Destination prefixes: extract non-Tailscale-IP CIDRs
|
||||||
|
// from both golden and headscale rules and compare.
|
||||||
|
var wantDstPrefixes []string
|
||||||
|
|
||||||
|
for _, dp := range wantRule.DstPorts {
|
||||||
|
pfx, err := parsePrefixOrAddr(dp.IP)
|
||||||
|
require.NoErrorf(t, err,
|
||||||
|
"golden DstPorts[%d].IP %q should parse as prefix or addr", i, dp.IP)
|
||||||
|
|
||||||
|
if !isTailscaleIP(pfx) {
|
||||||
|
wantDstPrefixes = append(wantDstPrefixes, pfx.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var gotDstPrefixes []string
|
||||||
|
|
||||||
|
for _, dst := range gotMatch.Dsts {
|
||||||
|
pfx := dst.Net
|
||||||
|
if !isTailscaleIP(pfx) {
|
||||||
|
gotDstPrefixes = append(gotDstPrefixes, pfx.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
slices.Sort(wantDstPrefixes)
|
||||||
|
slices.Sort(gotDstPrefixes)
|
||||||
|
|
||||||
|
assert.Equalf(t, wantDstPrefixes, gotDstPrefixes,
|
||||||
|
"PacketFilter[%d]: non-Tailscale destination prefixes mismatch", i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// saasAddrsByPeer builds a map from SaaS Tailscale address to peer
|
||||||
|
// hostname using each capture's SelfNode.Addresses. Peers not in
|
||||||
|
// clients are skipped.
|
||||||
|
func saasAddrsByPeer(
|
||||||
|
want testcapture.Node,
|
||||||
|
clients map[string]*servertest.TestClient,
|
||||||
|
) map[netip.Addr]string {
|
||||||
|
out := map[netip.Addr]string{}
|
||||||
|
|
||||||
|
if want.Netmap == nil {
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// Walk peers listed in this netmap.
|
||||||
|
for _, peer := range want.Netmap.Peers {
|
||||||
|
name := extractHostname(peer.Name())
|
||||||
|
if _, isOurs := clients[name]; !isOurs {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := range peer.Addresses().Len() {
|
||||||
|
pfx := peer.Addresses().At(i)
|
||||||
|
if isTailscaleIP(pfx) {
|
||||||
|
out[pfx.Addr()] = name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The viewer's own SelfNode addresses also appear as possible src.
|
||||||
|
if want.Netmap.SelfNode.Valid() {
|
||||||
|
name := extractHostname(want.Netmap.SelfNode.Name())
|
||||||
|
|
||||||
|
if _, isOurs := clients[name]; isOurs {
|
||||||
|
addrs := want.Netmap.SelfNode.Addresses()
|
||||||
|
for i := range addrs.Len() {
|
||||||
|
pfx := addrs.At(i)
|
||||||
|
if isTailscaleIP(pfx) {
|
||||||
|
out[pfx.Addr()] = name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// hsAddrsByPeer builds a map from headscale Tailscale address to peer
|
||||||
|
// hostname by walking each live client's self addresses.
|
||||||
|
func hsAddrsByPeer(clients map[string]*servertest.TestClient) map[netip.Addr]string {
|
||||||
|
out := map[netip.Addr]string{}
|
||||||
|
|
||||||
|
for name, cl := range clients {
|
||||||
|
nm := cl.Netmap()
|
||||||
|
if nm == nil || !nm.SelfNode.Valid() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
addrs := nm.SelfNode.Addresses()
|
||||||
|
for i := range addrs.Len() {
|
||||||
|
pfx := addrs.At(i)
|
||||||
|
if isTailscaleIP(pfx) {
|
||||||
|
out[pfx.Addr()] = name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// canonicaliseSrcStrings converts a SrcIPs slice (as produced by the
|
||||||
|
// SaaS wire format) into a sorted list of canonical identifiers: "*"
|
||||||
|
// for wildcard, "peer:<name>" for each Tailscale address or prefix
|
||||||
|
// that resolves to a known peer, or the raw CIDR string for
|
||||||
|
// non-Tailscale prefixes. A Tailscale prefix wider than /32 (IPv4)
|
||||||
|
// or /128 (IPv6) expands to the union of its contained peers.
|
||||||
|
// Unresolvable Tailscale-range sources fail the test.
|
||||||
|
func canonicaliseSrcStrings(
|
||||||
|
t *testing.T,
|
||||||
|
srcs []string,
|
||||||
|
addrToPeer map[netip.Addr]string,
|
||||||
|
ruleIndex int,
|
||||||
|
) []string {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
seen := map[string]struct{}{}
|
||||||
|
|
||||||
|
for _, src := range srcs {
|
||||||
|
if src == "*" {
|
||||||
|
seen["*"] = struct{}{}
|
||||||
|
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
pfx, err := parsePrefixOrAddr(src)
|
||||||
|
require.NoErrorf(t, err,
|
||||||
|
"PacketFilter[%d]: unparseable SrcIP %q", ruleIndex, src)
|
||||||
|
|
||||||
|
addIdentsForSrc(t, pfx, addrToPeer, ruleIndex, seen)
|
||||||
|
}
|
||||||
|
|
||||||
|
return sortedKeys(seen)
|
||||||
|
}
|
||||||
|
|
||||||
|
// canonicaliseSrcPrefixes is the headscale-side counterpart of
|
||||||
|
// canonicaliseSrcStrings, reading already-parsed netip.Prefix values
|
||||||
|
// from tailcfg.Match.Srcs.
|
||||||
|
func canonicaliseSrcPrefixes(
|
||||||
|
t *testing.T,
|
||||||
|
srcs []netip.Prefix,
|
||||||
|
addrToPeer map[netip.Addr]string,
|
||||||
|
ruleIndex int,
|
||||||
|
) []string {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
seen := map[string]struct{}{}
|
||||||
|
|
||||||
|
for _, pfx := range srcs {
|
||||||
|
if pfx.Bits() == 0 && pfx.Addr().IsUnspecified() {
|
||||||
|
seen["*"] = struct{}{}
|
||||||
|
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
addIdentsForSrc(t, pfx, addrToPeer, ruleIndex, seen)
|
||||||
|
}
|
||||||
|
|
||||||
|
return sortedKeys(seen)
|
||||||
|
}
|
||||||
|
|
||||||
|
// addIdentsForSrc resolves one source prefix into canonical identity
|
||||||
|
// tokens and inserts them into seen. A non-Tailscale prefix passes
|
||||||
|
// through literally; a Tailscale-range prefix expands to the union
|
||||||
|
// of peer names whose addresses fall within it.
|
||||||
|
func addIdentsForSrc(
|
||||||
|
t *testing.T,
|
||||||
|
pfx netip.Prefix,
|
||||||
|
addrToPeer map[netip.Addr]string,
|
||||||
|
ruleIndex int,
|
||||||
|
seen map[string]struct{},
|
||||||
|
) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
if !prefixInTailscaleRange(pfx) {
|
||||||
|
seen[pfx.String()] = struct{}{}
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
matched := false
|
||||||
|
|
||||||
|
for addr, name := range addrToPeer {
|
||||||
|
if pfx.Contains(addr) {
|
||||||
|
seen["peer:"+name] = struct{}{}
|
||||||
|
matched = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
require.Truef(t, matched,
|
||||||
|
"PacketFilter[%d]: Tailscale-range SrcIP %s does not cover any known peer; addrToPeer=%v",
|
||||||
|
ruleIndex, pfx, addrToPeer)
|
||||||
|
}
|
||||||
|
|
||||||
|
// prefixInTailscaleRange reports whether a prefix lies entirely
|
||||||
|
// within the Tailscale CGNAT range (100.64.0.0/10) or Tailscale ULA
|
||||||
|
// range (fd7a:115c:a1e0::/48), regardless of prefix length.
|
||||||
|
func prefixInTailscaleRange(p netip.Prefix) bool {
|
||||||
|
addr := p.Addr()
|
||||||
|
|
||||||
|
if addr.Is4() {
|
||||||
|
return addr.As4()[0] == 100 && (addr.As4()[1]&0xC0) == 64
|
||||||
|
}
|
||||||
|
|
||||||
|
if addr.Is6() {
|
||||||
|
b := addr.As16()
|
||||||
|
|
||||||
|
return b[0] == 0xfd && b[1] == 0x7a && b[2] == 0x11 && b[3] == 0x5c //nolint:gosec // As16 returns [16]byte, indexing [0..3] is safe
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func sortedKeys(m map[string]struct{}) []string {
|
||||||
|
out := make([]string, 0, len(m))
|
||||||
|
for k := range m {
|
||||||
|
out = append(out, k)
|
||||||
|
}
|
||||||
|
|
||||||
|
slices.Sort(out)
|
||||||
|
|
||||||
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
type peerSummary struct {
|
type peerSummary struct {
|
||||||
@ -404,6 +627,24 @@ type peerSummary struct {
|
|||||||
PrimaryRoutes []string // sorted
|
PrimaryRoutes []string // sorted
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// parsePrefixOrAddr parses a string as a netip.Prefix. If the string
|
||||||
|
// is a bare IP address (no slash), it is converted to a single-host
|
||||||
|
// prefix (/32 for IPv4, /128 for IPv6). Golden data DstPorts.IP can
|
||||||
|
// contain either form.
|
||||||
|
func parsePrefixOrAddr(s string) (netip.Prefix, error) {
|
||||||
|
pfx, err := netip.ParsePrefix(s)
|
||||||
|
if err == nil {
|
||||||
|
return pfx, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
addr, addrErr := netip.ParseAddr(s)
|
||||||
|
if addrErr != nil {
|
||||||
|
return netip.Prefix{}, err // return original prefix error
|
||||||
|
}
|
||||||
|
|
||||||
|
return netip.PrefixFrom(addr, addr.BitLen()), nil
|
||||||
|
}
|
||||||
|
|
||||||
// isTailscaleIP returns true if the prefix is a single-host Tailscale
|
// isTailscaleIP returns true if the prefix is a single-host Tailscale
|
||||||
// address (/32 for IPv4 in CGNAT range, /128 for IPv6 in Tailscale ULA).
|
// address (/32 for IPv4 in CGNAT range, /128 for IPv6 in Tailscale ULA).
|
||||||
func isTailscaleIP(prefix netip.Prefix) bool {
|
func isTailscaleIP(prefix netip.Prefix) bool {
|
||||||
@ -424,37 +665,36 @@ func isTailscaleIP(prefix netip.Prefix) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// extractRoutePrefixes returns the non-Tailscale-IP entries from an
|
// extractRoutePrefixesView returns the non-Tailscale-IP entries from
|
||||||
// AllowedIPs list (subnet routes, exit routes, etc.).
|
// a typed AllowedIPs view (subnet routes, exit routes, etc.).
|
||||||
func extractRoutePrefixes(allowedIPs []string) []string {
|
func extractRoutePrefixesView(allowedIPs interface {
|
||||||
|
Len() int
|
||||||
|
At(i int) netip.Prefix
|
||||||
|
},
|
||||||
|
) []string {
|
||||||
var routes []string
|
var routes []string
|
||||||
|
|
||||||
for _, aip := range allowedIPs {
|
for i := range allowedIPs.Len() {
|
||||||
prefix, err := netip.ParsePrefix(aip)
|
pfx := allowedIPs.At(i)
|
||||||
if err != nil {
|
if !isTailscaleIP(pfx) {
|
||||||
continue
|
routes = append(routes, pfx.String())
|
||||||
}
|
|
||||||
|
|
||||||
if !isTailscaleIP(prefix) {
|
|
||||||
routes = append(routes, aip)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return routes
|
return routes
|
||||||
}
|
}
|
||||||
|
|
||||||
// countTailscaleIPs returns the number of Tailscale IP entries in an
|
// countTailscaleIPsView returns the number of Tailscale IP entries
|
||||||
// AllowedIPs list.
|
// in a typed AllowedIPs view.
|
||||||
func countTailscaleIPs(allowedIPs []string) int {
|
func countTailscaleIPsView(allowedIPs interface {
|
||||||
|
Len() int
|
||||||
|
At(i int) netip.Prefix
|
||||||
|
},
|
||||||
|
) int {
|
||||||
count := 0
|
count := 0
|
||||||
|
|
||||||
for _, aip := range allowedIPs {
|
for i := range allowedIPs.Len() {
|
||||||
prefix, err := netip.ParsePrefix(aip)
|
if isTailscaleIP(allowedIPs.At(i)) {
|
||||||
if err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if isTailscaleIP(prefix) {
|
|
||||||
count++
|
count++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -463,16 +703,17 @@ func countTailscaleIPs(allowedIPs []string) int {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// inferNodeRoutes determines which routes each node should advertise.
|
// inferNodeRoutes determines which routes each node should advertise.
|
||||||
// If the golden topology has explicit advertised_routes, those are used.
|
// If the topology has explicit routable_ips, those are used. Otherwise
|
||||||
// Otherwise, routes are inferred from the golden netmap data: if a node
|
// routes are inferred from the netmap peer AllowedIPs and packet
|
||||||
// appears as a peer with route prefixes in AllowedIPs, it should
|
// filter destination prefixes.
|
||||||
// advertise those routes.
|
func inferNodeRoutes(t *testing.T, c *testcapture.Capture) map[string][]netip.Prefix {
|
||||||
func inferNodeRoutes(gf goldenFile) map[string][]netip.Prefix {
|
t.Helper()
|
||||||
|
|
||||||
result := map[string][]netip.Prefix{}
|
result := map[string][]netip.Prefix{}
|
||||||
|
|
||||||
// First use explicit advertised_routes from topology.
|
// First use explicit routable_ips from topology.
|
||||||
for name, node := range gf.Topology.Nodes {
|
for name, node := range c.Topology.Nodes {
|
||||||
for _, r := range node.AdvertisedRoutes {
|
for _, r := range node.RoutableIPs {
|
||||||
result[name] = append(result[name], netip.MustParsePrefix(r))
|
result[name] = append(result[name], netip.MustParsePrefix(r))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -484,26 +725,53 @@ func inferNodeRoutes(gf goldenFile) map[string][]netip.Prefix {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Infer from the golden netmap: scan all captures for peers with
|
// Tier 2: infer from each capture's netmap — scan peers with
|
||||||
// route prefixes in AllowedIPs. If node X appears as a peer with
|
// route prefixes in AllowedIPs. If node X appears as a peer with
|
||||||
// route prefix 10.44.0.0/16, then X should advertise that route.
|
// route prefix 10.44.0.0/16, then X should advertise that route.
|
||||||
for _, capture := range gf.Captures {
|
for _, node := range c.Captures {
|
||||||
if capture.Netmap == nil {
|
if node.Netmap == nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, peer := range capture.Netmap.Peers {
|
for _, peer := range node.Netmap.Peers {
|
||||||
peerName := extractHostname(peer.Name)
|
peerName := extractHostname(peer.Name())
|
||||||
routes := extractRoutePrefixes(peer.AllowedIPs)
|
|
||||||
|
|
||||||
for _, r := range routes {
|
for i := range peer.AllowedIPs().Len() {
|
||||||
prefix, err := netip.ParsePrefix(r)
|
pfx := peer.AllowedIPs().At(i)
|
||||||
if err != nil {
|
if isTailscaleIP(pfx) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if !slices.Contains(result[peerName], prefix) {
|
if !slices.Contains(result[peerName], pfx) {
|
||||||
result[peerName] = append(result[peerName], prefix)
|
result[peerName] = append(result[peerName], pfx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tier 3: infer from packet_filter_rules DstPorts — secondary HA
|
||||||
|
// routers whose routes don't appear in AllowedIPs (only the
|
||||||
|
// primary gets the route in AllowedIPs) DO receive filter rules
|
||||||
|
// for those routes.
|
||||||
|
for nodeName, node := range c.Captures {
|
||||||
|
for _, rule := range node.PacketFilterRules {
|
||||||
|
for _, dp := range rule.DstPorts {
|
||||||
|
if dp.IP == "*" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
prefix, err := parsePrefixOrAddr(dp.IP)
|
||||||
|
require.NoErrorf(t, err,
|
||||||
|
"golden DstPorts.IP %q for %s unparseable", dp.IP, nodeName)
|
||||||
|
|
||||||
|
if isTailscaleIP(prefix) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if !slices.Contains(result[nodeName], prefix) {
|
||||||
|
result[nodeName] = append(
|
||||||
|
result[nodeName], prefix,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -521,13 +789,3 @@ func extractHostname(fqdn string) string {
|
|||||||
|
|
||||||
return fqdn
|
return fqdn
|
||||||
}
|
}
|
||||||
|
|
||||||
// convertViaPolicy converts Tailscale SaaS policy emails to headscale format.
|
|
||||||
func convertViaPolicy(raw json.RawMessage) []byte {
|
|
||||||
s := string(raw)
|
|
||||||
s = strings.ReplaceAll(s, "kratail2tid@passkey", "tag-user@")
|
|
||||||
s = strings.ReplaceAll(s, "kristoffer@dalby.cc", "tag-user@")
|
|
||||||
s = strings.ReplaceAll(s, "monitorpasskeykradalby@passkey", "tag-user@")
|
|
||||||
|
|
||||||
return []byte(s)
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1146,42 +1146,72 @@ func (s *State) GetNodePrimaryRoutes(nodeID types.NodeID) []netip.Prefix {
|
|||||||
return s.primaryRoutes.PrimaryRoutes(nodeID)
|
return s.primaryRoutes.PrimaryRoutes(nodeID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// RoutesForPeer computes the routes a peer should advertise to a specific viewer,
|
// RoutesForPeer computes the routes a peer should advertise in a viewer's
|
||||||
// applying via grant steering on top of global primary election and exit routes.
|
// AllowedIPs, combining primary routes (from HA election), approved exit
|
||||||
// When no via grants apply, this falls back to existing behavior (global primaries + exit routes).
|
// routes, and via grant steering.
|
||||||
|
//
|
||||||
|
// Approved exit routes (0.0.0.0/0, ::/0) are included alongside subnet
|
||||||
|
// routes — they appear in every peer's AllowedIPs, while unapproved
|
||||||
|
// ones do not.
|
||||||
func (s *State) RoutesForPeer(
|
func (s *State) RoutesForPeer(
|
||||||
viewer, peer types.NodeView,
|
viewer, peer types.NodeView,
|
||||||
matchers []matcher.Match,
|
matchers []matcher.Match,
|
||||||
) []netip.Prefix {
|
) []netip.Prefix {
|
||||||
viaResult := s.polMan.ViaRoutesForPeer(viewer, peer)
|
viaResult := s.polMan.ViaRoutesForPeer(viewer, peer)
|
||||||
|
|
||||||
globalPrimaries := s.primaryRoutes.PrimaryRoutes(peer.ID())
|
globalPrimaries := s.primaryRoutes.PrimaryRoutes(peer.ID())
|
||||||
exitRoutes := peer.ExitRoutes()
|
exitRoutes := peer.ExitRoutes()
|
||||||
|
|
||||||
// Fast path: no via grants affect this pair — existing behavior.
|
var reduced []netip.Prefix
|
||||||
|
|
||||||
|
// Fast path: no via grants affect this pair.
|
||||||
if len(viaResult.Include) == 0 && len(viaResult.Exclude) == 0 {
|
if len(viaResult.Include) == 0 && len(viaResult.Exclude) == 0 {
|
||||||
allRoutes := slices.Concat(globalPrimaries, exitRoutes)
|
allRoutes := slices.Concat(globalPrimaries, exitRoutes)
|
||||||
|
|
||||||
return policy.ReduceRoutes(viewer, allRoutes, matchers)
|
reduced = policy.ReduceRoutes(viewer, allRoutes, matchers)
|
||||||
}
|
} else {
|
||||||
|
// Slow path: drop excluded routes, reduce, append via-included.
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Remove excluded routes (steered to a different peer for this viewer).
|
reduced = policy.ReduceRoutes(viewer, routes, matchers)
|
||||||
var routes []netip.Prefix
|
|
||||||
|
|
||||||
for _, p := range slices.Concat(globalPrimaries, exitRoutes) {
|
// Append via-included routes. The via grant IS the authorization
|
||||||
if !slices.Contains(viaResult.Exclude, p) {
|
// (no matcher filter needed), but HA primary election applies
|
||||||
routes = append(routes, p)
|
// when a regular (non-via) grant also covers the same prefix.
|
||||||
|
//
|
||||||
|
// Rules:
|
||||||
|
// - Peer is HA primary → always include
|
||||||
|
// - Peer is NOT primary, no regular grant → include
|
||||||
|
// (per-viewer via steering)
|
||||||
|
// - Peer is NOT primary, regular grant exists → exclude
|
||||||
|
// (HA primary wins)
|
||||||
|
for _, p := range viaResult.Include {
|
||||||
|
if slices.Contains(reduced, p) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if slices.Contains(globalPrimaries, p) {
|
||||||
|
reduced = append(reduced, p)
|
||||||
|
} else if !slices.Contains(viaResult.UsePrimary, p) {
|
||||||
|
reduced = append(reduced, p)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reduce only the non-via routes through matchers.
|
// Co-router visibility: when the viewer advertises the same prefix
|
||||||
reduced := policy.ReduceRoutes(viewer, routes, matchers)
|
// that the peer is HA primary for, the viewer must see that route
|
||||||
|
// regardless of matcher authorization. HA secondaries need this to
|
||||||
// Append via-included routes directly — the via grant IS the authorization,
|
// know which peer is primary for their shared prefix.
|
||||||
// so these must not be filtered by the viewer's matchers.
|
viewerSubnets := viewer.SubnetRoutes()
|
||||||
for _, p := range viaResult.Include {
|
if len(viewerSubnets) > 0 {
|
||||||
if !slices.Contains(reduced, p) {
|
for _, p := range globalPrimaries {
|
||||||
reduced = append(reduced, p)
|
if slices.Contains(viewerSubnets, p) && !slices.Contains(reduced, p) {
|
||||||
|
reduced = append(reduced, p)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user