mirror of
				https://github.com/juanfont/headscale.git
				synced 2025-10-28 10:51:44 +01:00 
			
		
		
		
	
		
			
				
	
	
		
			386 lines
		
	
	
		
			10 KiB
		
	
	
	
		
			Go
		
	
	
	
	
	
			
		
		
	
	
			386 lines
		
	
	
		
			10 KiB
		
	
	
	
		
			Go
		
	
	
	
	
	
| package v2
 | |
| 
 | |
| import (
 | |
| 	"encoding/json"
 | |
| 	"fmt"
 | |
| 	"net/netip"
 | |
| 	"slices"
 | |
| 	"strings"
 | |
| 	"sync"
 | |
| 
 | |
| 	"github.com/juanfont/headscale/hscontrol/policy/matcher"
 | |
| 	"github.com/juanfont/headscale/hscontrol/types"
 | |
| 	"github.com/rs/zerolog/log"
 | |
| 	"go4.org/netipx"
 | |
| 	"tailscale.com/net/tsaddr"
 | |
| 	"tailscale.com/tailcfg"
 | |
| 	"tailscale.com/types/views"
 | |
| 	"tailscale.com/util/deephash"
 | |
| )
 | |
| 
 | |
| type PolicyManager struct {
 | |
| 	mu    sync.Mutex
 | |
| 	pol   *Policy
 | |
| 	users []types.User
 | |
| 	nodes views.Slice[types.NodeView]
 | |
| 
 | |
| 	filterHash deephash.Sum
 | |
| 	filter     []tailcfg.FilterRule
 | |
| 	matchers   []matcher.Match
 | |
| 
 | |
| 	tagOwnerMapHash deephash.Sum
 | |
| 	tagOwnerMap     map[Tag]*netipx.IPSet
 | |
| 
 | |
| 	exitSetHash        deephash.Sum
 | |
| 	exitSet            *netipx.IPSet
 | |
| 	autoApproveMapHash deephash.Sum
 | |
| 	autoApproveMap     map[netip.Prefix]*netipx.IPSet
 | |
| 
 | |
| 	// Lazy map of SSH policies
 | |
| 	sshPolicyMap map[types.NodeID]*tailcfg.SSHPolicy
 | |
| }
 | |
| 
 | |
| // NewPolicyManager creates a new PolicyManager from a policy file and a list of users and nodes.
 | |
| // It returns an error if the policy file is invalid.
 | |
| // The policy manager will update the filter rules based on the users and nodes.
 | |
| func NewPolicyManager(b []byte, users []types.User, nodes views.Slice[types.NodeView]) (*PolicyManager, error) {
 | |
| 	policy, err := unmarshalPolicy(b)
 | |
| 	if err != nil {
 | |
| 		return nil, fmt.Errorf("parsing policy: %w", err)
 | |
| 	}
 | |
| 
 | |
| 	pm := PolicyManager{
 | |
| 		pol:          policy,
 | |
| 		users:        users,
 | |
| 		nodes:        nodes,
 | |
| 		sshPolicyMap: make(map[types.NodeID]*tailcfg.SSHPolicy, nodes.Len()),
 | |
| 	}
 | |
| 
 | |
| 	_, err = pm.updateLocked()
 | |
| 	if err != nil {
 | |
| 		return nil, err
 | |
| 	}
 | |
| 
 | |
| 	return &pm, nil
 | |
| }
 | |
| 
 | |
| // updateLocked updates the filter rules based on the current policy and nodes.
 | |
| // It must be called with the lock held.
 | |
| func (pm *PolicyManager) updateLocked() (bool, error) {
 | |
| 	// Clear the SSH policy map to ensure it's recalculated with the new policy.
 | |
| 	// TODO(kradalby): This could potentially be optimized by only clearing the
 | |
| 	// policies for nodes that have changed. Particularly if the only difference is
 | |
| 	// that nodes has been added or removed.
 | |
| 	clear(pm.sshPolicyMap)
 | |
| 
 | |
| 	filter, err := pm.pol.compileFilterRules(pm.users, pm.nodes)
 | |
| 	if err != nil {
 | |
| 		return false, fmt.Errorf("compiling filter rules: %w", err)
 | |
| 	}
 | |
| 
 | |
| 	filterHash := deephash.Hash(&filter)
 | |
| 	filterChanged := filterHash != pm.filterHash
 | |
| 	if filterChanged {
 | |
| 		log.Debug().
 | |
| 			Str("filter.hash.old", pm.filterHash.String()[:8]).
 | |
| 			Str("filter.hash.new", filterHash.String()[:8]).
 | |
| 			Int("filter.rules", len(pm.filter)).
 | |
| 			Int("filter.rules.new", len(filter)).
 | |
| 			Msg("Policy filter hash changed")
 | |
| 	}
 | |
| 	pm.filter = filter
 | |
| 	pm.filterHash = filterHash
 | |
| 	if filterChanged {
 | |
| 		pm.matchers = matcher.MatchesFromFilterRules(pm.filter)
 | |
| 	}
 | |
| 
 | |
| 	// Order matters, tags might be used in autoapprovers, so we need to ensure
 | |
| 	// that the map for tag owners is resolved before resolving autoapprovers.
 | |
| 	// TODO(kradalby): Order might not matter after #2417
 | |
| 	tagMap, err := resolveTagOwners(pm.pol, pm.users, pm.nodes)
 | |
| 	if err != nil {
 | |
| 		return false, fmt.Errorf("resolving tag owners map: %w", err)
 | |
| 	}
 | |
| 
 | |
| 	tagOwnerMapHash := deephash.Hash(&tagMap)
 | |
| 	tagOwnerChanged := tagOwnerMapHash != pm.tagOwnerMapHash
 | |
| 	if tagOwnerChanged {
 | |
| 		log.Debug().
 | |
| 			Str("tagOwner.hash.old", pm.tagOwnerMapHash.String()[:8]).
 | |
| 			Str("tagOwner.hash.new", tagOwnerMapHash.String()[:8]).
 | |
| 			Int("tagOwners.old", len(pm.tagOwnerMap)).
 | |
| 			Int("tagOwners.new", len(tagMap)).
 | |
| 			Msg("Tag owner hash changed")
 | |
| 	}
 | |
| 	pm.tagOwnerMap = tagMap
 | |
| 	pm.tagOwnerMapHash = tagOwnerMapHash
 | |
| 
 | |
| 	autoMap, exitSet, err := resolveAutoApprovers(pm.pol, pm.users, pm.nodes)
 | |
| 	if err != nil {
 | |
| 		return false, fmt.Errorf("resolving auto approvers map: %w", err)
 | |
| 	}
 | |
| 
 | |
| 	autoApproveMapHash := deephash.Hash(&autoMap)
 | |
| 	autoApproveChanged := autoApproveMapHash != pm.autoApproveMapHash
 | |
| 	if autoApproveChanged {
 | |
| 		log.Debug().
 | |
| 			Str("autoApprove.hash.old", pm.autoApproveMapHash.String()[:8]).
 | |
| 			Str("autoApprove.hash.new", autoApproveMapHash.String()[:8]).
 | |
| 			Int("autoApprovers.old", len(pm.autoApproveMap)).
 | |
| 			Int("autoApprovers.new", len(autoMap)).
 | |
| 			Msg("Auto-approvers hash changed")
 | |
| 	}
 | |
| 	pm.autoApproveMap = autoMap
 | |
| 	pm.autoApproveMapHash = autoApproveMapHash
 | |
| 
 | |
| 	exitSetHash := deephash.Hash(&exitSet)
 | |
| 	exitSetChanged := exitSetHash != pm.exitSetHash
 | |
| 	if exitSetChanged {
 | |
| 		log.Debug().
 | |
| 			Str("exitSet.hash.old", pm.exitSetHash.String()[:8]).
 | |
| 			Str("exitSet.hash.new", exitSetHash.String()[:8]).
 | |
| 			Msg("Exit node set hash changed")
 | |
| 	}
 | |
| 	pm.exitSet = exitSet
 | |
| 	pm.exitSetHash = exitSetHash
 | |
| 
 | |
| 	// If neither of the calculated values changed, no need to update nodes
 | |
| 	if !filterChanged && !tagOwnerChanged && !autoApproveChanged && !exitSetChanged {
 | |
| 		log.Trace().
 | |
| 			Msg("Policy evaluation detected no changes - all hashes match")
 | |
| 		return false, nil
 | |
| 	}
 | |
| 
 | |
| 	log.Debug().
 | |
| 		Bool("filter.changed", filterChanged).
 | |
| 		Bool("tagOwners.changed", tagOwnerChanged).
 | |
| 		Bool("autoApprovers.changed", autoApproveChanged).
 | |
| 		Bool("exitNodes.changed", exitSetChanged).
 | |
| 		Msg("Policy changes require node updates")
 | |
| 
 | |
| 	return true, nil
 | |
| }
 | |
| 
 | |
| func (pm *PolicyManager) SSHPolicy(node types.NodeView) (*tailcfg.SSHPolicy, error) {
 | |
| 	pm.mu.Lock()
 | |
| 	defer pm.mu.Unlock()
 | |
| 
 | |
| 	if sshPol, ok := pm.sshPolicyMap[node.ID()]; ok {
 | |
| 		return sshPol, nil
 | |
| 	}
 | |
| 
 | |
| 	sshPol, err := pm.pol.compileSSHPolicy(pm.users, node, pm.nodes)
 | |
| 	if err != nil {
 | |
| 		return nil, fmt.Errorf("compiling SSH policy: %w", err)
 | |
| 	}
 | |
| 	pm.sshPolicyMap[node.ID()] = sshPol
 | |
| 
 | |
| 	return sshPol, nil
 | |
| }
 | |
| 
 | |
| func (pm *PolicyManager) SetPolicy(polB []byte) (bool, error) {
 | |
| 	if len(polB) == 0 {
 | |
| 		return false, nil
 | |
| 	}
 | |
| 
 | |
| 	pol, err := unmarshalPolicy(polB)
 | |
| 	if err != nil {
 | |
| 		return false, fmt.Errorf("parsing policy: %w", err)
 | |
| 	}
 | |
| 
 | |
| 	pm.mu.Lock()
 | |
| 	defer pm.mu.Unlock()
 | |
| 
 | |
| 	// Log policy metadata for debugging
 | |
| 	log.Debug().
 | |
| 		Int("policy.bytes", len(polB)).
 | |
| 		Int("acls.count", len(pol.ACLs)).
 | |
| 		Int("groups.count", len(pol.Groups)).
 | |
| 		Int("hosts.count", len(pol.Hosts)).
 | |
| 		Int("tagOwners.count", len(pol.TagOwners)).
 | |
| 		Int("autoApprovers.routes.count", len(pol.AutoApprovers.Routes)).
 | |
| 		Msg("Policy parsed successfully")
 | |
| 
 | |
| 	pm.pol = pol
 | |
| 
 | |
| 	return pm.updateLocked()
 | |
| }
 | |
| 
 | |
| // 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
 | |
| }
 | |
| 
 | |
| // 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
 | |
| 
 | |
| 	return pm.updateLocked()
 | |
| }
 | |
| 
 | |
| // SetNodes updates the nodes in the policy manager and updates the filter rules.
 | |
| func (pm *PolicyManager) SetNodes(nodes views.Slice[types.NodeView]) (bool, error) {
 | |
| 	if pm == nil {
 | |
| 		return false, nil
 | |
| 	}
 | |
| 
 | |
| 	pm.mu.Lock()
 | |
| 	defer pm.mu.Unlock()
 | |
| 	pm.nodes = nodes
 | |
| 
 | |
| 	return pm.updateLocked()
 | |
| }
 | |
| 
 | |
| func (pm *PolicyManager) NodeCanHaveTag(node types.NodeView, tag string) bool {
 | |
| 	if pm == nil {
 | |
| 		return false
 | |
| 	}
 | |
| 
 | |
| 	pm.mu.Lock()
 | |
| 	defer pm.mu.Unlock()
 | |
| 
 | |
| 	if ips, ok := pm.tagOwnerMap[Tag(tag)]; ok {
 | |
| 		if slices.ContainsFunc(node.IPs(), ips.Contains) {
 | |
| 			return true
 | |
| 		}
 | |
| 	}
 | |
| 
 | |
| 	return false
 | |
| }
 | |
| 
 | |
| func (pm *PolicyManager) NodeCanApproveRoute(node types.NodeView, route netip.Prefix) bool {
 | |
| 	if pm == nil {
 | |
| 		return false
 | |
| 	}
 | |
| 
 | |
| 	// If the route to-be-approved is an exit route, then we need to check
 | |
| 	// if the node is in allowed to approve it. This is treated differently
 | |
| 	// than the auto-approvers, as the auto-approvers are not allowed to
 | |
| 	// approve the whole /0 range.
 | |
| 	// However, an auto approver might be /0, meaning that they can approve
 | |
| 	// all routes available, just not exit nodes.
 | |
| 	if tsaddr.IsExitRoute(route) {
 | |
| 		if pm.exitSet == nil {
 | |
| 			return false
 | |
| 		}
 | |
| 		if slices.ContainsFunc(node.IPs(), pm.exitSet.Contains) {
 | |
| 			return true
 | |
| 		}
 | |
| 
 | |
| 		return false
 | |
| 	}
 | |
| 
 | |
| 	pm.mu.Lock()
 | |
| 	defer pm.mu.Unlock()
 | |
| 
 | |
| 	// The fast path is that a node requests to approve a prefix
 | |
| 	// where there is an exact entry, e.g. 10.0.0.0/8, then
 | |
| 	// check and return quickly
 | |
| 	if approvers, ok := pm.autoApproveMap[route]; ok {
 | |
| 		canApprove := slices.ContainsFunc(node.IPs(), approvers.Contains)
 | |
| 		if canApprove {
 | |
| 			return true
 | |
| 		}
 | |
| 	}
 | |
| 
 | |
| 	// The slow path is that the node tries to approve
 | |
| 	// 10.0.10.0/24, which is a part of 10.0.0.0/8, then we
 | |
| 	// cannot just lookup in the prefix map and have to check
 | |
| 	// if there is a "parent" prefix available.
 | |
| 	for prefix, approveAddrs := range pm.autoApproveMap {
 | |
| 		// Check if prefix is larger (so containing) and then overlaps
 | |
| 		// the route to see if the node can approve a subset of an autoapprover
 | |
| 		if prefix.Bits() <= route.Bits() && prefix.Overlaps(route) {
 | |
| 			canApprove := slices.ContainsFunc(node.IPs(), approveAddrs.Contains)
 | |
| 			if canApprove {
 | |
| 				return true
 | |
| 			}
 | |
| 		}
 | |
| 	}
 | |
| 
 | |
| 	return false
 | |
| }
 | |
| 
 | |
| func (pm *PolicyManager) Version() int {
 | |
| 	return 2
 | |
| }
 | |
| 
 | |
| 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())
 | |
| 
 | |
| 	sb.WriteString("\n\n")
 | |
| 
 | |
| 	if pm.pol != nil {
 | |
| 		pol, err := json.MarshalIndent(pm.pol, "", "  ")
 | |
| 		if err == nil {
 | |
| 			sb.WriteString("Policy:\n")
 | |
| 			sb.Write(pol)
 | |
| 			sb.WriteString("\n\n")
 | |
| 		}
 | |
| 	}
 | |
| 
 | |
| 	fmt.Fprintf(&sb, "AutoApprover (%d):\n", len(pm.autoApproveMap))
 | |
| 	for prefix, approveAddrs := range pm.autoApproveMap {
 | |
| 		fmt.Fprintf(&sb, "\t%s:\n", prefix)
 | |
| 		for _, iprange := range approveAddrs.Ranges() {
 | |
| 			fmt.Fprintf(&sb, "\t\t%s\n", iprange)
 | |
| 		}
 | |
| 	}
 | |
| 
 | |
| 	sb.WriteString("\n\n")
 | |
| 
 | |
| 	fmt.Fprintf(&sb, "TagOwner (%d):\n", len(pm.tagOwnerMap))
 | |
| 	for prefix, tagOwners := range pm.tagOwnerMap {
 | |
| 		fmt.Fprintf(&sb, "\t%s:\n", prefix)
 | |
| 		for _, iprange := range tagOwners.Ranges() {
 | |
| 			fmt.Fprintf(&sb, "\t\t%s\n", iprange)
 | |
| 		}
 | |
| 	}
 | |
| 
 | |
| 	sb.WriteString("\n\n")
 | |
| 	if pm.filter != nil {
 | |
| 		filter, err := json.MarshalIndent(pm.filter, "", "  ")
 | |
| 		if err == nil {
 | |
| 			sb.WriteString("Compiled filter:\n")
 | |
| 			sb.Write(filter)
 | |
| 			sb.WriteString("\n\n")
 | |
| 		}
 | |
| 	}
 | |
| 
 | |
| 	sb.WriteString("\n\n")
 | |
| 	sb.WriteString("Matchers:\n")
 | |
| 	sb.WriteString("an internal structure used to filter nodes and routes\n")
 | |
| 	for _, match := range pm.matchers {
 | |
| 		sb.WriteString(match.DebugString())
 | |
| 		sb.WriteString("\n")
 | |
| 	}
 | |
| 
 | |
| 	sb.WriteString("\n\n")
 | |
| 	sb.WriteString("Nodes:\n")
 | |
| 	for _, node := range pm.nodes.All() {
 | |
| 		sb.WriteString(node.String())
 | |
| 		sb.WriteString("\n")
 | |
| 	}
 | |
| 
 | |
| 	return sb.String()
 | |
| }
 |