policyutil: send a router no capability for its subnet

via-grant-v84 and v86 show SaaS sending none.

Updates #3626
This commit is contained in:
Kristoffer Dalby committed 2026-11-03 15:53:04 +01:00
1 parent 3fdedefbf9
commit aa7b2ab404
2 files changed
+12 -46

No files matched your search

+5 -22
View File
@@ -131,7 +131,9 @@ func destinationNeedsOwnAddressExpansion(
// [tailcfg.CapGrant] entries whose Dsts match the given node's IPs. When a
// broad prefix (e.g. 100.64.0.0/10 from dst:*) contains a node's IP, it is
// narrowed to the node's specific /32 or /128 prefix. Returns nil if
// no [tailcfg.CapGrant] entries are relevant to this node.
// no [tailcfg.CapGrant] entries are relevant to this node. A destination the
// node only routes is not relevant: via-grant-v84 and v86 show SaaS sending a
// router no capability for its subnet.
func reduceCapGrantRule(
node types.NodeView,
rule tailcfg.FilterRule,
@@ -139,7 +141,6 @@ func reduceCapGrantRule(
var capGrants []tailcfg.CapGrant
nodeIPs := node.IPs()
subnetRoutes := node.SubnetRoutes()
for _, cg := range rule.CapGrant {
// Collect the node's IPs that fall within any of this
@@ -164,27 +165,9 @@ func reduceCapGrantRule(
}
}
// Asymmetric on purpose: the IP-match loop above narrows broad
// prefixes to node-specific /32 or /128 so peers receive only
// the minimum routing surface. The route-match loop below
// preserves the original prefix so the subnet-serving node
// receives the full CapGrant scope. [types.NodeView.SubnetRoutes]
// excludes both unapproved and exit routes, matching Tailscale
// SaaS behavior.
for _, dst := range cg.Dsts {
for _, subnetRoute := range subnetRoutes {
if dst.Overlaps(subnetRoute) {
// For route overlaps, keep the original prefix.
matchingDsts = append(matchingDsts, dst)
}
}
}
if len(matchingDsts) > 0 {
// A Dst can be appended twice when a broad prefix both
// contains a node IP and overlaps one of its approved
// subnet routes. Sort + Compact dedups; [netip.Prefix] is
// comparable so Compact works with ==.
// Several Dsts can narrow to the same node IP. Sort + Compact
// dedups; [netip.Prefix] is comparable so Compact works with ==.
slices.SortFunc(matchingDsts, netip.Prefix.Compare)
matchingDsts = slices.Compact(matchingDsts)
+7 -24
View File
@@ -1072,25 +1072,9 @@ func TestReduceFilterRulesCapGrant(t *testing.T) {
},
},
},
want: []tailcfg.FilterRule{
{
SrcIPs: []string{"*"},
CapGrant: []tailcfg.CapGrant{
{
Dsts: []netip.Prefix{
// 10.0.0.0/8 doesn't contain node's
// CGNAT IP (100.64.0.1), so no IP
// narrowing. Only route overlap kept
// as original prefix.
netip.MustParsePrefix("10.0.0.0/8"),
},
CapMap: tailcfg.PeerCapMap{
"tailscale.com/cap/relay-target": nil,
},
},
},
},
},
// 10.0.0.0/8 doesn't contain the node's CGNAT IP (100.64.0.1),
// and routing part of it delivers no capability.
want: []tailcfg.FilterRule{},
},
{
name: "capgrant-exit-route-skipped",
@@ -1264,10 +1248,9 @@ func TestReduceFilterRulesCapGrant(t *testing.T) {
want: []tailcfg.FilterRule{},
},
{
// A broad prefix that both contains the node IP (/32 narrow)
// and overlaps one of its approved subnet routes (route
// preserved) would otherwise be emitted twice.
name: "capgrant-ip-and-route-overlap-dedup",
// Broad prefixes that contain the node IP narrow to the same
// /32, and routing them keeps nothing more.
name: "capgrant-broad-dsts-narrow-once",
node: &types.Node{
IPv4: ap("100.64.0.1"),
IPv6: ap("fd7a:115c:a1e0::1"),
@@ -1287,6 +1270,7 @@ func TestReduceFilterRulesCapGrant(t *testing.T) {
{
Dsts: []netip.Prefix{
netip.MustParsePrefix("100.64.0.0/24"),
netip.MustParsePrefix("100.64.0.0/16"),
},
CapMap: tailcfg.PeerCapMap{
"tailscale.com/cap/relay-target": nil,
@@ -1302,7 +1286,6 @@ func TestReduceFilterRulesCapGrant(t *testing.T) {
{
Dsts: []netip.Prefix{
netip.MustParsePrefix("100.64.0.1/32"),
netip.MustParsePrefix("100.64.0.0/24"),
},
CapMap: tailcfg.PeerCapMap{
"tailscale.com/cap/relay-target": nil,