1
0
mirror of https://github.com/juanfont/headscale.git synced 2025-08-10 13:46:46 +02:00

policy: reduce routes based on policy

Fixes #2365

Signed-off-by: Kristoffer Dalby <kristoffer@tailscale.com>
This commit is contained in:
Kristoffer Dalby 2025-05-03 10:11:30 +02:00
parent 388bf5c7b9
commit 0d17cdd8cb
No known key found for this signature in database
9 changed files with 269 additions and 10 deletions

View File

@ -546,7 +546,7 @@ func appendPeerChanges(
// If there are filter rules present, see if there are any nodes that cannot
// access each-other at all and remove them from the peers.
if len(filter) > 0 {
changed = policy.FilterNodesByACL(node, changed, matchers)
changed = policy.ReduceNodes(node, changed, matchers)
}
profiles := generateUserProfiles(node, changed)

View File

@ -348,6 +348,11 @@ func Test_fullMapResponse(t *testing.T) {
"src": ["100.64.0.2"],
"dst": ["user1@:*"],
},
{
"action": "accept",
"src": ["100.64.0.1"],
"dst": ["192.168.0.0/24:*"],
},
],
}
`),
@ -380,6 +385,10 @@ func Test_fullMapResponse(t *testing.T) {
{IP: "100.64.0.1/32", Ports: tailcfg.PortRangeAny},
},
},
{
SrcIPs: []string{"100.64.0.1/32"},
DstPorts: []tailcfg.NetPortRange{{IP: "192.168.0.0/24", Ports: tailcfg.PortRangeAny}},
},
},
},
SSHPolicy: nil,

View File

@ -81,7 +81,9 @@ func tailNode(
}
tags = lo.Uniq(append(tags, node.ForcedTags...))
allowed := append(node.Prefixes(), primary.PrimaryRoutes(node.ID)...)
_, matchers := polMan.Filter()
routes := policy.ReduceRoutes(node, primary.PrimaryRoutes(node.ID), matchers)
allowed := append(node.Prefixes(), routes...)
allowed = append(allowed, node.ExitRoutes()...)
tsaddr.SortPrefixes(allowed)

View File

@ -269,10 +269,13 @@ func TestNodeExpiry(t *testing.T) {
GivenName: "test",
Expiry: tt.exp,
}
polMan, err := policy.NewPolicyManager(nil, nil, nil)
require.NoError(t, err)
tn, err := tailNode(
node,
0,
nil, // TODO(kradalby): removed in merge but error?
polMan,
nil,
&types.Config{},
)

View File

@ -1,9 +1,10 @@
package policy
import (
"github.com/juanfont/headscale/hscontrol/policy/matcher"
"net/netip"
"github.com/juanfont/headscale/hscontrol/policy/matcher"
policyv1 "github.com/juanfont/headscale/hscontrol/policy/v1"
policyv2 "github.com/juanfont/headscale/hscontrol/policy/v2"
"github.com/juanfont/headscale/hscontrol/types"
@ -33,7 +34,7 @@ type PolicyManager interface {
}
// NewPolicyManager returns a new policy manager, the version is determined by
// the environment flag "HEADSCALE_EXPERIMENTAL_POLICY_V2".
// the environment flag "HEADSCALE_POLICY_V1".
func NewPolicyManager(pol []byte, users []types.User, nodes types.Nodes) (PolicyManager, error) {
var polMan PolicyManager
var err error

View File

@ -1,10 +1,11 @@
package policy
import (
"github.com/juanfont/headscale/hscontrol/policy/matcher"
"net/netip"
"slices"
"github.com/juanfont/headscale/hscontrol/policy/matcher"
"github.com/juanfont/headscale/hscontrol/types"
"github.com/juanfont/headscale/hscontrol/util"
"github.com/samber/lo"
@ -12,8 +13,8 @@ import (
"tailscale.com/tailcfg"
)
// FilterNodesByACL returns the list of peers authorized to be accessed from a given node.
func FilterNodesByACL(
// ReduceNodes returns the list of peers authorized to be accessed from a given node.
func ReduceNodes(
node *types.Node,
nodes types.Nodes,
matchers []matcher.Match,
@ -33,6 +34,23 @@ func FilterNodesByACL(
return result
}
// ReduceRoutes returns a reduced list of routes for a given node that it can access.
func ReduceRoutes(
node *types.Node,
routes []netip.Prefix,
matchers []matcher.Match,
) []netip.Prefix {
var result []netip.Prefix
for _, route := range routes {
if node.CanAccessRoute(matchers, route) {
result = append(result, route)
}
}
return result
}
// ReduceFilterRules takes a node and a set of rules and removes all rules and destinations
// that are not relevant to that particular node.
func ReduceFilterRules(node *types.Node, rules []tailcfg.FilterRule) []tailcfg.FilterRule {

View File

@ -784,7 +784,7 @@ func TestReduceFilterRules(t *testing.T) {
}
}
func TestFilterNodesByACL(t *testing.T) {
func TestReduceNodes(t *testing.T) {
type args struct {
nodes types.Nodes
rules []tailcfg.FilterRule
@ -1530,7 +1530,7 @@ func TestFilterNodesByACL(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
matchers := matcher.MatchesFromFilterRules(tt.args.rules)
got := FilterNodesByACL(
got := ReduceNodes(
tt.args.node,
tt.args.nodes,
matchers,
@ -1946,3 +1946,197 @@ func TestSSHPolicyRules(t *testing.T) {
}
}
}
func TestReduceRoutes(t *testing.T) {
type args struct {
node *types.Node
routes []netip.Prefix
rules []tailcfg.FilterRule
}
tests := []struct {
name string
args args
want []netip.Prefix
}{
{
name: "node can access all routes",
args: args{
node: &types.Node{
ID: 1,
IPv4: ap("100.64.0.1"),
User: types.User{Name: "user1"},
},
routes: []netip.Prefix{
netip.MustParsePrefix("10.0.0.0/24"),
netip.MustParsePrefix("192.168.1.0/24"),
netip.MustParsePrefix("172.16.0.0/16"),
},
rules: []tailcfg.FilterRule{
{
SrcIPs: []string{"100.64.0.1"},
DstPorts: []tailcfg.NetPortRange{
{IP: "*"},
},
},
},
},
want: []netip.Prefix{
netip.MustParsePrefix("10.0.0.0/24"),
netip.MustParsePrefix("192.168.1.0/24"),
netip.MustParsePrefix("172.16.0.0/16"),
},
},
{
name: "node can access specific route",
args: args{
node: &types.Node{
ID: 1,
IPv4: ap("100.64.0.1"),
User: types.User{Name: "user1"},
},
routes: []netip.Prefix{
netip.MustParsePrefix("10.0.0.0/24"),
netip.MustParsePrefix("192.168.1.0/24"),
netip.MustParsePrefix("172.16.0.0/16"),
},
rules: []tailcfg.FilterRule{
{
SrcIPs: []string{"100.64.0.1"},
DstPorts: []tailcfg.NetPortRange{
{IP: "10.0.0.0/24"},
},
},
},
},
want: []netip.Prefix{
netip.MustParsePrefix("10.0.0.0/24"),
},
},
{
name: "node can access multiple specific routes",
args: args{
node: &types.Node{
ID: 1,
IPv4: ap("100.64.0.1"),
User: types.User{Name: "user1"},
},
routes: []netip.Prefix{
netip.MustParsePrefix("10.0.0.0/24"),
netip.MustParsePrefix("192.168.1.0/24"),
netip.MustParsePrefix("172.16.0.0/16"),
},
rules: []tailcfg.FilterRule{
{
SrcIPs: []string{"100.64.0.1"},
DstPorts: []tailcfg.NetPortRange{
{IP: "10.0.0.0/24"},
{IP: "192.168.1.0/24"},
},
},
},
},
want: []netip.Prefix{
netip.MustParsePrefix("10.0.0.0/24"),
netip.MustParsePrefix("192.168.1.0/24"),
},
},
{
name: "node can access overlapping routes",
args: args{
node: &types.Node{
ID: 1,
IPv4: ap("100.64.0.1"),
User: types.User{Name: "user1"},
},
routes: []netip.Prefix{
netip.MustParsePrefix("10.0.0.0/24"),
netip.MustParsePrefix("10.0.0.0/16"), // Overlaps with the first one
netip.MustParsePrefix("192.168.1.0/24"),
},
rules: []tailcfg.FilterRule{
{
SrcIPs: []string{"100.64.0.1"},
DstPorts: []tailcfg.NetPortRange{
{IP: "10.0.0.0/16"},
},
},
},
},
want: []netip.Prefix{
netip.MustParsePrefix("10.0.0.0/24"),
netip.MustParsePrefix("10.0.0.0/16"),
},
},
{
name: "node with no matching rules",
args: args{
node: &types.Node{
ID: 1,
IPv4: ap("100.64.0.1"),
User: types.User{Name: "user1"},
},
routes: []netip.Prefix{
netip.MustParsePrefix("10.0.0.0/24"),
netip.MustParsePrefix("192.168.1.0/24"),
netip.MustParsePrefix("172.16.0.0/16"),
},
rules: []tailcfg.FilterRule{
{
SrcIPs: []string{"100.64.0.2"}, // Different source IP
DstPorts: []tailcfg.NetPortRange{
{IP: "*"},
},
},
},
},
want: nil,
},
{
name: "node with both IPv4 and IPv6",
args: args{
node: &types.Node{
ID: 1,
IPv4: ap("100.64.0.1"),
IPv6: ap("fd7a:115c:a1e0::1"),
User: types.User{Name: "user1"},
},
routes: []netip.Prefix{
netip.MustParsePrefix("10.0.0.0/24"),
netip.MustParsePrefix("2001:db8::/64"),
netip.MustParsePrefix("192.168.1.0/24"),
},
rules: []tailcfg.FilterRule{
{
SrcIPs: []string{"fd7a:115c:a1e0::1"}, // IPv6 source
DstPorts: []tailcfg.NetPortRange{
{IP: "2001:db8::/64"}, // IPv6 destination
},
},
{
SrcIPs: []string{"100.64.0.1"}, // IPv4 source
DstPorts: []tailcfg.NetPortRange{
{IP: "10.0.0.0/24"}, // IPv4 destination
},
},
},
},
want: []netip.Prefix{
netip.MustParsePrefix("10.0.0.0/24"),
netip.MustParsePrefix("2001:db8::/64"),
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
matchers := matcher.MatchesFromFilterRules(tt.args.rules)
got := ReduceRoutes(
tt.args.node,
tt.args.routes,
matchers,
)
if diff := cmp.Diff(tt.want, got, util.Comparers...); diff != "" {
t.Errorf("ReduceRoutes() unexpected result (-want +got):\n%s", diff)
}
})
}
}

View File

@ -152,6 +152,10 @@ func (pm *PolicyManager) SetPolicy(polB []byte) (bool, error) {
// Filter returns the current filter rules for the entire tailnet and the associated matchers.
func (pm *PolicyManager) Filter() ([]tailcfg.FilterRule, []matcher.Match) {
if pm == nil {
return nil, nil
}
pm.mu.Lock()
defer pm.mu.Unlock()
return pm.filter, pm.matchers
@ -159,6 +163,10 @@ func (pm *PolicyManager) Filter() ([]tailcfg.FilterRule, []matcher.Match) {
// SetUsers updates the users in the policy manager and updates the filter rules.
func (pm *PolicyManager) SetUsers(users []types.User) (bool, error) {
if pm == nil {
return false, nil
}
pm.mu.Lock()
defer pm.mu.Unlock()
pm.users = users
@ -167,6 +175,10 @@ func (pm *PolicyManager) SetUsers(users []types.User) (bool, error) {
// SetNodes updates the nodes in the policy manager and updates the filter rules.
func (pm *PolicyManager) SetNodes(nodes types.Nodes) (bool, error) {
if pm == nil {
return false, nil
}
pm.mu.Lock()
defer pm.mu.Unlock()
pm.nodes = nodes
@ -238,6 +250,10 @@ func (pm *PolicyManager) Version() int {
}
func (pm *PolicyManager) DebugString() string {
if pm == nil {
return "PolicyManager is not setup"
}
var sb strings.Builder
fmt.Fprintf(&sb, "PolicyManager (v%d):\n\n", pm.Version())

View File

@ -291,6 +291,22 @@ func (node *Node) CanAccess(matchers []matcher.Match, node2 *Node) bool {
return false
}
func (node *Node) CanAccessRoute(matchers []matcher.Match, route netip.Prefix) bool {
src := node.IPs()
for _, matcher := range matchers {
if !matcher.SrcsContainsIPs(src...) {
continue
}
if matcher.DestsOverlapsPrefixes(route) {
return true
}
}
return false
}
func (nodes Nodes) FilterByIP(ip netip.Addr) Nodes {
var found Nodes