policy/v2: match via-grant destinations by prefix overlap

slices.Contains required exact equality between grant dst and the
advertised subnet route. Any non-identical pair was rejected, so a
via grant with broader (or narrower) dst emitted no filter rule and
added no route to the viewer's AllowedIPs. Tailscale SaaS uses
containment in either direction.

Switch to slices.ContainsFunc(routes, dst.Overlaps) for filter rule
emission (keep dst literal in DstPorts), and append overlapping
advertised routes to ViaRoutesForPeer.Include / Exclude. Rewrite the
multi-router HA election and regular-grant overlap detection to key
off the matched routes rather than the dst. Resolve *Host aliases to
*Prefix once in compileOneViaGrant and at the top of ViaRoutesForPeer
so the switch arms reach them.

Fixes #3267
This commit is contained in:
Kristoffer Dalby 2026-05-18 09:42:28 +00:00
parent af7e7a4560
commit e5fcd01ee6
4 changed files with 402 additions and 96 deletions

View file

@ -1813,6 +1813,147 @@ func TestViaRoutesForPeer(t *testing.T) {
"client should NOT be able to access 10.0.0.0/24 via matchers alone; "+
"state.RoutesForPeer adds via routes after ReduceRoutes to fix this")
})
t.Run("broader_dst_includes_narrower_advertised_route", func(t *testing.T) {
t.Parallel()
nodes := types.Nodes{
{
ID: 1,
Hostname: "viewer",
IPv4: ap("100.64.0.1"),
User: new(users[0]),
UserID: new(users[0].ID),
Hostinfo: &tailcfg.Hostinfo{},
},
{
ID: 2,
Hostname: "router",
IPv4: ap("100.64.0.2"),
User: new(users[0]),
UserID: new(users[0].ID),
Tags: []string{"tag:router"},
Hostinfo: &tailcfg.Hostinfo{
RoutableIPs: []netip.Prefix{mp("10.33.5.0/24")},
},
ApprovedRoutes: []netip.Prefix{mp("10.33.5.0/24")},
},
}
pol := `{
"tagOwners": {
"tag:router": ["user1@"]
},
"grants": [{
"src": ["user1@"],
"dst": ["10.0.0.0/8"],
"ip": ["*"],
"via": ["tag:router"]
}]
}`
pm, err := NewPolicyManager([]byte(pol), users, nodes.ViewSlice())
require.NoError(t, err)
result := pm.ViaRoutesForPeer(nodes[0].View(), nodes[1].View())
require.Equal(t, []netip.Prefix{mp("10.33.5.0/24")}, result.Include,
"Include must hold the advertised route /24, not the broader grant dst /8")
require.Empty(t, result.Exclude)
})
t.Run("narrower_dst_includes_advertised_route", func(t *testing.T) {
t.Parallel()
nodes := types.Nodes{
{
ID: 1,
Hostname: "viewer",
IPv4: ap("100.64.0.1"),
User: new(users[0]),
UserID: new(users[0].ID),
Hostinfo: &tailcfg.Hostinfo{},
},
{
ID: 2,
Hostname: "router",
IPv4: ap("100.64.0.2"),
User: new(users[0]),
UserID: new(users[0].ID),
Tags: []string{"tag:router"},
Hostinfo: &tailcfg.Hostinfo{
RoutableIPs: []netip.Prefix{mp("10.33.0.0/16")},
},
ApprovedRoutes: []netip.Prefix{mp("10.33.0.0/16")},
},
}
pol := `{
"tagOwners": {
"tag:router": ["user1@"]
},
"grants": [{
"src": ["user1@"],
"dst": ["10.33.5.0/24"],
"ip": ["*"],
"via": ["tag:router"]
}]
}`
pm, err := NewPolicyManager([]byte(pol), users, nodes.ViewSlice())
require.NoError(t, err)
result := pm.ViaRoutesForPeer(nodes[0].View(), nodes[1].View())
require.Equal(t, []netip.Prefix{mp("10.33.0.0/16")}, result.Include,
"Include must hold the advertised route /16 that covers the narrower grant dst /24")
require.Empty(t, result.Exclude)
})
t.Run("disjoint_dst_emits_nothing", func(t *testing.T) {
t.Parallel()
nodes := types.Nodes{
{
ID: 1,
Hostname: "viewer",
IPv4: ap("100.64.0.1"),
User: new(users[0]),
UserID: new(users[0].ID),
Hostinfo: &tailcfg.Hostinfo{},
},
{
ID: 2,
Hostname: "router",
IPv4: ap("100.64.0.2"),
User: new(users[0]),
UserID: new(users[0].ID),
Tags: []string{"tag:router"},
Hostinfo: &tailcfg.Hostinfo{
RoutableIPs: []netip.Prefix{mp("10.33.0.0/16")},
},
ApprovedRoutes: []netip.Prefix{mp("10.33.0.0/16")},
},
}
pol := `{
"tagOwners": {
"tag:router": ["user1@"]
},
"grants": [{
"src": ["user1@"],
"dst": ["192.168.0.0/16"],
"ip": ["*"],
"via": ["tag:router"]
}]
}`
pm, err := NewPolicyManager([]byte(pol), users, nodes.ViewSlice())
require.NoError(t, err)
result := pm.ViaRoutesForPeer(nodes[0].View(), nodes[1].View())
require.Empty(t, result.Include,
"disjoint dst must produce nothing — the via gate requires advertised-route overlap")
require.Empty(t, result.Exclude)
})
}
// TestBuildPeerMap_AutogroupInternetMakesExitNodeVisible reproduces