mirror of
https://github.com/juanfont/headscale.git
synced 2026-11-04 04:34:07 +09:00
policy: reduce merged destination prefixes to the node's own addresses
Adjacent node addresses merge into one prefix, and each node's filter kept all of it. A prefix made only of node addresses now shrinks to the node's own; a prefix the policy wrote, or one the node routes, stays.
This commit is contained in:
1 parent
be07e0f536
commit
ffd4f34841
10 files changed
+115
-27
No files matched your search
@@ -13,11 +13,19 @@ import (
|
||||
// ReduceFilterRules takes a node and a set of global filter rules and removes all rules
|
||||
// and destinations that are not relevant to that particular node.
|
||||
//
|
||||
// nodeAddrs holds every node's addresses. A destination prefix made only of
|
||||
// them merges several nodes' addresses, and the node keeps just its own, as
|
||||
// Tailscale lists them; any other prefix stays whole. nil keeps all whole.
|
||||
//
|
||||
// IMPORTANT: This function is designed for global filters only. Per-node filters
|
||||
// (from autogroup:self policies) are already node-specific and should not be passed
|
||||
// to this function. Use [policy.PolicyManager.FilterForNode] instead, which handles
|
||||
// both cases.
|
||||
func ReduceFilterRules(node types.NodeView, rules []tailcfg.FilterRule) []tailcfg.FilterRule {
|
||||
func ReduceFilterRules(
|
||||
node types.NodeView,
|
||||
rules []tailcfg.FilterRule,
|
||||
nodeAddrs *netipx.IPSet,
|
||||
) []tailcfg.FilterRule {
|
||||
ret := []tailcfg.FilterRule{}
|
||||
nodeIPs := node.IPs()
|
||||
subnetRoutes := node.SubnetRoutes()
|
||||
@@ -40,7 +48,7 @@ func ReduceFilterRules(node types.NodeView, rules []tailcfg.FilterRule) []tailcf
|
||||
|
||||
for _, dest := range rule.DstPorts {
|
||||
if destReachesNode(dest.IP, nodeIPs, subnetRoutes, exitRoutes) {
|
||||
dests = append(dests, dest)
|
||||
dests = appendOwnDests(dests, dest, nodeIPs, subnetRoutes, exitRoutes, nodeAddrs)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,6 +91,34 @@ func destReachesNode(dst string, nodeIPs []netip.Addr, subnetRoutes, exitRoutes
|
||||
slices.ContainsFunc(exitRoutes, overlaps)
|
||||
}
|
||||
|
||||
// appendOwnDests appends dest, which reaches the node, to dests. A prefix
|
||||
// made only of node addresses, which no route of the node overlaps, becomes
|
||||
// one entry per node address inside it; anything else stays whole.
|
||||
func appendOwnDests(
|
||||
dests []tailcfg.NetPortRange,
|
||||
dest tailcfg.NetPortRange,
|
||||
nodeIPs []netip.Addr,
|
||||
subnetRoutes, exitRoutes []netip.Prefix,
|
||||
nodeAddrs *netipx.IPSet,
|
||||
) []tailcfg.NetPortRange {
|
||||
pfx, err := netip.ParsePrefix(dest.IP)
|
||||
if err != nil || pfx.IsSingleIP() || nodeAddrs == nil || !nodeAddrs.ContainsPrefix(pfx) ||
|
||||
slices.ContainsFunc(subnetRoutes, pfx.Overlaps) ||
|
||||
slices.ContainsFunc(exitRoutes, pfx.Overlaps) {
|
||||
return append(dests, dest)
|
||||
}
|
||||
|
||||
for _, ip := range nodeIPs {
|
||||
if pfx.Contains(ip) {
|
||||
own := dest
|
||||
own.IP = ip.String()
|
||||
dests = append(dests, own)
|
||||
}
|
||||
}
|
||||
|
||||
return dests
|
||||
}
|
||||
|
||||
// reduceCapGrantRule filters a [tailcfg.CapGrant] rule to only include
|
||||
// [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
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"github.com/juanfont/headscale/hscontrol/util"
|
||||
"github.com/rs/zerolog/log"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go4.org/netipx"
|
||||
"tailscale.com/net/tsaddr"
|
||||
"tailscale.com/tailcfg"
|
||||
"tailscale.com/util/must"
|
||||
@@ -778,7 +779,7 @@ func TestReduceFilterRules(t *testing.T) {
|
||||
|
||||
got, _ := pm.Filter()
|
||||
t.Logf("full filter:\n%s", must.Get(json.MarshalIndent(got, "", " ")))
|
||||
got = policyutil.ReduceFilterRules(tt.node.View(), got)
|
||||
got = policyutil.ReduceFilterRules(tt.node.View(), got, nil)
|
||||
|
||||
if diff := cmp.Diff(tt.want, got); diff != "" {
|
||||
log.Trace().Interface("got", got).Msg("result")
|
||||
@@ -951,7 +952,7 @@ func TestReduceFilterRulesPartialApproval(t *testing.T) {
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := policyutil.ReduceFilterRules(
|
||||
tt.node.View(), tt.rules,
|
||||
tt.node.View(), tt.rules, nil,
|
||||
)
|
||||
require.Len(t, got, tt.wantCount,
|
||||
"rule count mismatch")
|
||||
@@ -1315,7 +1316,7 @@ func TestReduceFilterRulesCapGrant(t *testing.T) {
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := policyutil.ReduceFilterRules(tt.node.View(), tt.rules)
|
||||
got := policyutil.ReduceFilterRules(tt.node.View(), tt.rules, nil)
|
||||
|
||||
require.Len(t, got, len(tt.want),
|
||||
"rule count mismatch")
|
||||
@@ -1377,7 +1378,7 @@ func TestReduceFilterRulesDestForms(t *testing.T) {
|
||||
rule.DstPorts = append(rule.DstPorts, tailcfg.NetPortRange{IP: dst, Ports: tailcfg.PortRangeAny})
|
||||
}
|
||||
|
||||
got := policyutil.ReduceFilterRules(node.View(), []tailcfg.FilterRule{rule})
|
||||
got := policyutil.ReduceFilterRules(node.View(), []tailcfg.FilterRule{rule}, nil)
|
||||
require.Len(t, got, 1)
|
||||
|
||||
kept := make(map[string]bool)
|
||||
@@ -1397,6 +1398,13 @@ func TestReduceFilterRulesDestForms(t *testing.T) {
|
||||
func TestReduceFilterRulesAllocsIndependentOfDests(t *testing.T) {
|
||||
node := (&types.Node{IPv4: ap("100.64.0.1"), IPv6: ap("fd7a:115c:a1e0::1")}).View()
|
||||
|
||||
var b netipx.IPSetBuilder
|
||||
|
||||
node.AppendToIPSet(&b)
|
||||
|
||||
nodeAddrs, err := b.IPSet()
|
||||
require.NoError(t, err)
|
||||
|
||||
allocs := func(n int) int {
|
||||
rule := tailcfg.FilterRule{SrcIPs: []string{"*"}}
|
||||
for i := range n {
|
||||
@@ -1408,9 +1416,48 @@ func TestReduceFilterRulesAllocsIndependentOfDests(t *testing.T) {
|
||||
|
||||
rules := []tailcfg.FilterRule{rule}
|
||||
|
||||
return int(testing.AllocsPerRun(10, func() { policyutil.ReduceFilterRules(node, rules) }))
|
||||
return int(testing.AllocsPerRun(10, func() { policyutil.ReduceFilterRules(node, rules, nodeAddrs) }))
|
||||
}
|
||||
|
||||
require.Equal(t, allocs(10), allocs(1000),
|
||||
"reducing a filter must not allocate per DstPorts entry")
|
||||
}
|
||||
|
||||
// TestReduceFilterRulesMergedNodeDests checks that a destination merged from
|
||||
// node addresses shrinks to the node's own addresses, while a written prefix,
|
||||
// and any destination a router serves, stays whole.
|
||||
func TestReduceFilterRulesMergedNodeDests(t *testing.T) {
|
||||
node := &types.Node{IPv4: ap("100.64.0.6"), IPv6: ap("fd7a:115c:a1e0::6")}
|
||||
neighbour := &types.Node{IPv4: ap("100.64.0.7"), IPv6: ap("fd7a:115c:a1e0::7")}
|
||||
|
||||
var b netipx.IPSetBuilder
|
||||
|
||||
node.AppendToIPSet(&b)
|
||||
neighbour.AppendToIPSet(&b)
|
||||
|
||||
nodeAddrs, err := b.IPSet()
|
||||
require.NoError(t, err)
|
||||
|
||||
rules := []tailcfg.FilterRule{{
|
||||
SrcIPs: []string{"100.64.0.1"},
|
||||
DstPorts: []tailcfg.NetPortRange{
|
||||
{IP: "100.64.0.6/31", Ports: tailcfg.PortRange{First: 22, Last: 22}},
|
||||
{IP: "fd7a:115c:a1e0::6/127", Ports: tailcfg.PortRange{First: 22, Last: 22}},
|
||||
{IP: "100.64.0.0/12", Ports: tailcfg.PortRangeAny},
|
||||
},
|
||||
}}
|
||||
|
||||
got := policyutil.ReduceFilterRules(node.View(), rules, nodeAddrs)
|
||||
require.Equal(t, []tailcfg.NetPortRange{
|
||||
{IP: "100.64.0.6", Ports: tailcfg.PortRange{First: 22, Last: 22}},
|
||||
{IP: "fd7a:115c:a1e0::6", Ports: tailcfg.PortRange{First: 22, Last: 22}},
|
||||
{IP: "100.64.0.0/12", Ports: tailcfg.PortRangeAny},
|
||||
}, got[0].DstPorts)
|
||||
|
||||
// An exit node forwards to every destination, so it keeps them whole.
|
||||
node.Hostinfo = &tailcfg.Hostinfo{RoutableIPs: tsaddr.ExitRoutes()}
|
||||
node.ApprovedRoutes = tsaddr.ExitRoutes()
|
||||
|
||||
got = policyutil.ReduceFilterRules(node.View(), rules, nodeAddrs)
|
||||
require.Equal(t, rules[0].DstPorts, got[0].DstPorts)
|
||||
}
|
||||
@@ -139,6 +139,20 @@ func buildUserNodeIndex(
|
||||
return idx
|
||||
}
|
||||
|
||||
// nodeAddrSet is the addresses of every node.
|
||||
func nodeAddrSet(nodes views.Slice[types.NodeView]) *netipx.IPSet {
|
||||
var b netipx.IPSetBuilder
|
||||
|
||||
for _, n := range nodes.All() {
|
||||
n.AppendToIPSet(&b)
|
||||
}
|
||||
|
||||
// The builder only fails on invalid addresses, which nodes never hold.
|
||||
set, _ := b.IPSet()
|
||||
|
||||
return set
|
||||
}
|
||||
|
||||
// compileNodeAttrs returns the per-node CapMap derived from policy
|
||||
// nodeAttrs plus the tailnet-wide [Policy.RandomizeClientPort] flag.
|
||||
//
|
||||
|
||||
@@ -1497,7 +1497,7 @@ func TestTagUserMutualExclusivity(t *testing.T) {
|
||||
|
||||
compiled := pol.compileFilterRulesForNode(users, userNode, nodes.ViewSlice())
|
||||
|
||||
userRules := policyutil.ReduceFilterRules(userNode, compiled)
|
||||
userRules := policyutil.ReduceFilterRules(userNode, compiled, nodeAddrSet(nodes.ViewSlice()))
|
||||
|
||||
for _, rule := range userRules {
|
||||
for _, dst := range rule.DstPorts {
|
||||
@@ -1519,7 +1519,7 @@ func TestTagUserMutualExclusivity(t *testing.T) {
|
||||
|
||||
compiled = pol.compileFilterRulesForNode(users, dbNode, nodes.ViewSlice())
|
||||
|
||||
dbRules := policyutil.ReduceFilterRules(dbNode, compiled)
|
||||
dbRules := policyutil.ReduceFilterRules(dbNode, compiled, nodeAddrSet(nodes.ViewSlice()))
|
||||
|
||||
foundServerSrc := false
|
||||
|
||||
@@ -1590,7 +1590,7 @@ func TestUserToTagCrossIdentityGrant(t *testing.T) {
|
||||
|
||||
compiled := pol.compileFilterRulesForNode(users, taggedNode, nodes.ViewSlice())
|
||||
|
||||
rules := policyutil.ReduceFilterRules(taggedNode, compiled)
|
||||
rules := policyutil.ReduceFilterRules(taggedNode, compiled, nodeAddrSet(nodes.ViewSlice()))
|
||||
|
||||
// user1's IP should appear as a source that can reach tag:server.
|
||||
foundUser1Src := false
|
||||
|
||||
@@ -42,6 +42,9 @@ type PolicyManager struct {
|
||||
nodes views.Slice[types.NodeView]
|
||||
// nodesByID indexes nodes; see [PolicyManager.cacheableLocked].
|
||||
nodesByID map[types.NodeID]types.NodeView
|
||||
// nodeAddrs is every node's addresses, for
|
||||
// [policyutil.ReduceFilterRules].
|
||||
nodeAddrs *netipx.IPSet
|
||||
|
||||
// resolvedPol is pol with its tags indexed and its groups resolved
|
||||
// against users and nodes, see [Policy.withNodesByTag] and
|
||||
@@ -313,6 +316,7 @@ func (pm *PolicyManager) updateLocked() (bool, error) {
|
||||
pm.resolvedPol = pol
|
||||
pm.compiledGrants = grants
|
||||
pm.userNodeIdx = buildUserNodeIndex(pm.nodes)
|
||||
pm.nodeAddrs = nodeAddrSet(pm.nodes)
|
||||
pm.needsPerNodeFilter = hasPerNodeGrants(pm.compiledGrants)
|
||||
pm.viaTargetTags = collectViaTargetTags(pm.compiledGrants)
|
||||
pm.relayTargetIPs = relayTargetIPs
|
||||
@@ -973,7 +977,7 @@ func (pm *PolicyManager) filterForNodeLocked(
|
||||
)
|
||||
}
|
||||
|
||||
reduced := policyutil.ReduceFilterRules(node, unreduced)
|
||||
reduced := policyutil.ReduceFilterRules(node, unreduced, pm.nodeAddrs)
|
||||
if pm.cacheableLocked(node) {
|
||||
pm.filterRulesMap.Store(node.ID(), reduced)
|
||||
}
|
||||
|
||||
@@ -372,6 +372,7 @@ func testACLSuccess(
|
||||
gotRules := policyutil.ReduceFilterRules(
|
||||
node.View(),
|
||||
pol.compileFilterRulesForNode(users, node.View(), nodes.ViewSlice()),
|
||||
nodeAddrSet(nodes.ViewSlice()),
|
||||
)
|
||||
|
||||
if diff := cmp.Diff(wantRules, gotRules, opts...); diff != "" {
|
||||
|
||||
@@ -281,6 +281,7 @@ func testGrantSuccess(
|
||||
gotRules := policyutil.ReduceFilterRules(
|
||||
node.View(),
|
||||
pol.compileFilterRulesForNode(users, node.View(), nodes.ViewSlice()),
|
||||
nodeAddrSet(nodes.ViewSlice()),
|
||||
)
|
||||
|
||||
pmRules, err := pm.FilterForNode(node.View())
|
||||
|
||||
@@ -366,6 +366,7 @@ func TestRoutesCompat(t *testing.T) {
|
||||
gotRules := policyutil.ReduceFilterRules(
|
||||
node.View(),
|
||||
compiledRules,
|
||||
nodeAddrSet(nodes.ViewSlice()),
|
||||
)
|
||||
|
||||
wantRules := capture.PacketFilterRules
|
||||
|
||||
@@ -140,15 +140,6 @@ var mapStreamSkipReasons = map[string]string{
|
||||
"policy-generation/G4/thor-1/peer-owners": sameUserPeers,
|
||||
"policy-generation/G4/thor-2/peer-owners": sameUserPeers,
|
||||
|
||||
"policy-generation/G2/freya-1/filter": filterDstMerged,
|
||||
"policy-generation/G2/thor-2/filter": filterDstMerged,
|
||||
"policy-generation/G3/freya-1/filter": filterDstMerged,
|
||||
"policy-generation/G3/thor-2/filter": filterDstMerged,
|
||||
"policy-generation/G4/freya-1/filter": filterDstMerged,
|
||||
"policy-generation/G4/thor-2/filter": filterDstMerged,
|
||||
"policy-generation/G5/freya-1/filter": filterDstMerged,
|
||||
"policy-generation/G5/thor-2/filter": filterDstMerged,
|
||||
|
||||
"policy-generation/G4/rt/enabled": withdrawnRouteEnabled,
|
||||
"policy-generation/G5/rt/enabled": withdrawnRouteEnabled,
|
||||
"policy-generation/G7/rt/enabled": withdrawnRouteEnabled,
|
||||
|
||||
@@ -107,9 +107,6 @@ var routesMapCompatTests = []struct {
|
||||
var viaCompatSkipReasons = map[string]string{
|
||||
"tagged-self": "TAGGED_SELF_PROFILE: SaaS gives a tagged node a profile of its own, headscale the shared tagged-devices one",
|
||||
|
||||
"routes-f2-subnet-as-acl-source/ivysaur/packet-filter": filterDstMerged,
|
||||
"routes-f2-subnet-as-acl-source/venusaur/packet-filter": filterDstMerged,
|
||||
|
||||
// SaaS sends a router every approved route in its own AllowedIPs and
|
||||
// every route it is primary for in PrimaryRoutes.
|
||||
"grant-v11/blastoise/self-routes": selfRoutesReduced,
|
||||
@@ -258,10 +255,6 @@ var viaCompatSkipReasons = map[string]string{
|
||||
|
||||
const exitRoutesOnOverlap = "EXIT_ROUTES_ON_OVERLAP: headscale offers a peer's exit routes to a viewer whose rules reach only nodes or subnets; SaaS leaves them out"
|
||||
|
||||
// Adjacent member addresses merge into one prefix, and the reduced filter
|
||||
// keeps all of it; SaaS lists only the node's own address.
|
||||
const filterDstMerged = "FILTER_DST_MERGED: own filter names a neighbour's address"
|
||||
|
||||
const sameUserPeers = "SAME_USER_PEERS: SaaS lists a user's own devices as each other's peers with no rule linking them; headscale peers only what the policy links"
|
||||
|
||||
const selfRoutesReduced = "SELF_ROUTES_REDUCED: headscale sends a router only the primary routes its policy lets it reach"
|
||||
|
||||
Reference in new issue
Block a user