2025-03-10 16:20:29 +01:00
|
|
|
package v2
|
|
|
|
|
|
|
|
|
|
import (
|
tags: process tags on registration, simplify policy (#2931)
This PR investigates, adds tests and aims to correctly implement Tailscale's model for how Tags should be accepted, assigned and used to identify nodes in the Tailscale access and ownership model.
When evaluating in Headscale's policy, Tags are now only checked against a nodes "tags" list, which defines the source of truth for all tags for a given node. This simplifies the code for dealing with tags greatly, and should help us have less access bugs related to nodes belonging to tags or users.
A node can either be owned by a user, or a tag.
Next, to ensure the tags list on the node is correctly implemented, we first add tests for every registration scenario and combination of user, pre auth key and pre auth key with tags with the same registration expectation as observed by trying them all with the Tailscale control server. This should ensure that we implement the correct behaviour and that it does not change or break over time.
Lastly, the missing parts of the auth has been added, or changed in the cases where it was wrong. This has in large parts allowed us to delete and simplify a lot of code.
Now, tags can only be changed when a node authenticates or if set via the CLI/API. Tags can only be fully overwritten/replaced and any use of either auth or CLI will replace the current set if different.
A user owned device can be converted to a tagged device, but it cannot be changed back. A tagged device can never remove the last tag either, it has to have a minimum of one.
2025-12-08 18:51:07 +01:00
|
|
|
"cmp"
|
2025-03-10 16:20:29 +01:00
|
|
|
"encoding/json"
|
tags: process tags on registration, simplify policy (#2931)
This PR investigates, adds tests and aims to correctly implement Tailscale's model for how Tags should be accepted, assigned and used to identify nodes in the Tailscale access and ownership model.
When evaluating in Headscale's policy, Tags are now only checked against a nodes "tags" list, which defines the source of truth for all tags for a given node. This simplifies the code for dealing with tags greatly, and should help us have less access bugs related to nodes belonging to tags or users.
A node can either be owned by a user, or a tag.
Next, to ensure the tags list on the node is correctly implemented, we first add tests for every registration scenario and combination of user, pre auth key and pre auth key with tags with the same registration expectation as observed by trying them all with the Tailscale control server. This should ensure that we implement the correct behaviour and that it does not change or break over time.
Lastly, the missing parts of the auth has been added, or changed in the cases where it was wrong. This has in large parts allowed us to delete and simplify a lot of code.
Now, tags can only be changed when a node authenticates or if set via the CLI/API. Tags can only be fully overwritten/replaced and any use of either auth or CLI will replace the current set if different.
A user owned device can be converted to a tagged device, but it cannot be changed back. A tagged device can never remove the last tag either, it has to have a minimum of one.
2025-12-08 18:51:07 +01:00
|
|
|
"errors"
|
2025-03-10 16:20:29 +01:00
|
|
|
"fmt"
|
policy/v2: parse, validate, and compile nodeAttrs
ACL policies now accept a top-level nodeAttrs block. Each entry hands
a list of tailcfg node capabilities to every node matching target.
Accepted target forms are the same as acls.src and grants.src: users,
groups, tags, hosts, prefixes, autogroup:member, autogroup:tagged,
and *. autogroup:self, autogroup:internet, and autogroup:danger-all
are rejected at validate time because none describes a stable
identity set a node-level attribute can attach to.
NodeAttrGrant carries Targets, Attrs, and IPPool. IPPool is parsed
but rejected at validate time -- the allocator that consumes it is
not yet implemented. nodeAttrUnsupportedCaps lists caps SaaS accepts
that headscale cannot act on (funnel today) and rejects them with a
tracking-issue link in the error.
compileNodeAttrs resolves each entry's targets, then maps every
targeted node to a tailcfg.NodeCapMap of the entry's attrs. Per-node
IPs are cached once per call so the inner attr loop is O(grants)
instead of O(grants * nodes) IP allocations.
PolicyManager grows NodeCapMap (per-node), NodeCapMaps (snapshot for
batched callers), and NodesWithChangedCapMap (drain buffer for the
self-broadcast diff). refreshNodeAttrsLocked appends to the drain
rather than overwriting so a SetUsers/SetNodes between SetPolicy and
the drain cannot lose the policy-reload diff.
2026-05-11 14:46:38 +00:00
|
|
|
"maps"
|
2025-03-10 16:20:29 +01:00
|
|
|
"net/netip"
|
2025-07-10 23:38:55 +02:00
|
|
|
"slices"
|
2025-03-10 16:20:29 +01:00
|
|
|
"strings"
|
|
|
|
|
"sync"
|
2026-02-24 18:52:17 +00:00
|
|
|
"time"
|
2025-03-10 16:20:29 +01:00
|
|
|
|
2025-05-01 07:06:30 +02:00
|
|
|
"github.com/juanfont/headscale/hscontrol/policy/matcher"
|
2025-10-23 17:57:41 +02:00
|
|
|
"github.com/juanfont/headscale/hscontrol/policy/policyutil"
|
2025-03-10 16:20:29 +01:00
|
|
|
"github.com/juanfont/headscale/hscontrol/types"
|
2025-09-05 16:32:46 +02:00
|
|
|
"github.com/rs/zerolog/log"
|
2025-03-10 16:20:29 +01:00
|
|
|
"go4.org/netipx"
|
|
|
|
|
"tailscale.com/net/tsaddr"
|
|
|
|
|
"tailscale.com/tailcfg"
|
2025-07-05 23:31:13 +02:00
|
|
|
"tailscale.com/types/views"
|
2025-07-10 23:38:55 +02:00
|
|
|
"tailscale.com/util/deephash"
|
2026-04-28 16:09:42 +00:00
|
|
|
"tailscale.com/util/multierr"
|
2025-03-10 16:20:29 +01:00
|
|
|
)
|
|
|
|
|
|
2026-05-18 18:35:23 +00:00
|
|
|
// ErrInvalidTagOwner is returned when a tag owner is not an [Alias] type.
|
tags: process tags on registration, simplify policy (#2931)
This PR investigates, adds tests and aims to correctly implement Tailscale's model for how Tags should be accepted, assigned and used to identify nodes in the Tailscale access and ownership model.
When evaluating in Headscale's policy, Tags are now only checked against a nodes "tags" list, which defines the source of truth for all tags for a given node. This simplifies the code for dealing with tags greatly, and should help us have less access bugs related to nodes belonging to tags or users.
A node can either be owned by a user, or a tag.
Next, to ensure the tags list on the node is correctly implemented, we first add tests for every registration scenario and combination of user, pre auth key and pre auth key with tags with the same registration expectation as observed by trying them all with the Tailscale control server. This should ensure that we implement the correct behaviour and that it does not change or break over time.
Lastly, the missing parts of the auth has been added, or changed in the cases where it was wrong. This has in large parts allowed us to delete and simplify a lot of code.
Now, tags can only be changed when a node authenticates or if set via the CLI/API. Tags can only be fully overwritten/replaced and any use of either auth or CLI will replace the current set if different.
A user owned device can be converted to a tagged device, but it cannot be changed back. A tagged device can never remove the last tag either, it has to have a minimum of one.
2025-12-08 18:51:07 +01:00
|
|
|
var ErrInvalidTagOwner = errors.New("tag owner is not an Alias")
|
|
|
|
|
|
2025-03-10 16:20:29 +01:00
|
|
|
type PolicyManager struct {
|
|
|
|
|
mu sync.Mutex
|
|
|
|
|
pol *Policy
|
|
|
|
|
users []types.User
|
2025-07-05 23:31:13 +02:00
|
|
|
nodes views.Slice[types.NodeView]
|
2025-03-10 16:20:29 +01:00
|
|
|
|
|
|
|
|
filterHash deephash.Sum
|
|
|
|
|
filter []tailcfg.FilterRule
|
2025-05-01 07:06:30 +02:00
|
|
|
matchers []matcher.Match
|
2025-03-10 16:20:29 +01:00
|
|
|
|
|
|
|
|
tagOwnerMapHash deephash.Sum
|
|
|
|
|
tagOwnerMap map[Tag]*netipx.IPSet
|
|
|
|
|
|
2025-05-10 00:20:04 +03:00
|
|
|
exitSetHash deephash.Sum
|
|
|
|
|
exitSet *netipx.IPSet
|
2025-03-10 16:20:29 +01:00
|
|
|
autoApproveMapHash deephash.Sum
|
|
|
|
|
autoApproveMap map[netip.Prefix]*netipx.IPSet
|
|
|
|
|
|
2026-06-03 10:25:20 +00:00
|
|
|
// relayTargetIPs holds the IPs of nodes that are destinations of a
|
|
|
|
|
// tailscale.com/cap/relay grant; viaTargetTags holds the tags used as
|
|
|
|
|
// via targets. A node matching either, or that is a subnet router,
|
|
|
|
|
// forces peers to recompute their netmap when its online state changes
|
|
|
|
|
// (see [PolicyManager.NodeNeedsPeerRecompute]). Recomputed from the
|
|
|
|
|
// compiled grants on every policy/user/node change.
|
|
|
|
|
relayTargetIPs *netipx.IPSet
|
|
|
|
|
viaTargetTags map[Tag]struct{}
|
|
|
|
|
|
2025-03-10 16:20:29 +01:00
|
|
|
// Lazy map of SSH policies
|
|
|
|
|
sshPolicyMap map[types.NodeID]*tailcfg.SSHPolicy
|
2025-10-16 12:59:52 +02:00
|
|
|
|
2026-04-15 08:27:28 +00:00
|
|
|
// compiledGrants are the grants with sources pre-resolved.
|
|
|
|
|
// The single source of truth for filter compilation. Both
|
|
|
|
|
// global and per-node filter rules are derived from these.
|
|
|
|
|
compiledGrants []compiledGrant
|
|
|
|
|
userNodeIdx userNodeIndex
|
|
|
|
|
|
2025-10-23 17:57:41 +02:00
|
|
|
// Lazy map of per-node filter rules (reduced, for packet filters)
|
2026-04-15 08:27:28 +00:00
|
|
|
filterRulesMap map[types.NodeID][]tailcfg.FilterRule
|
|
|
|
|
|
|
|
|
|
// Lazy map of per-node matchers derived from UNREDUCED filter
|
|
|
|
|
// rules. Only populated on the slow path when needsPerNodeFilter
|
|
|
|
|
// is true; the fast path returns pm.matchers directly.
|
|
|
|
|
matchersForNodeMap map[types.NodeID][]matcher.Match
|
2026-03-26 06:03:28 +00:00
|
|
|
|
2026-04-15 08:27:28 +00:00
|
|
|
// needsPerNodeFilter is true when any compiled grant requires
|
|
|
|
|
// per-node work (autogroup:self or via grants).
|
2026-03-26 06:03:28 +00:00
|
|
|
needsPerNodeFilter bool
|
policy/v2: parse, validate, and compile nodeAttrs
ACL policies now accept a top-level nodeAttrs block. Each entry hands
a list of tailcfg node capabilities to every node matching target.
Accepted target forms are the same as acls.src and grants.src: users,
groups, tags, hosts, prefixes, autogroup:member, autogroup:tagged,
and *. autogroup:self, autogroup:internet, and autogroup:danger-all
are rejected at validate time because none describes a stable
identity set a node-level attribute can attach to.
NodeAttrGrant carries Targets, Attrs, and IPPool. IPPool is parsed
but rejected at validate time -- the allocator that consumes it is
not yet implemented. nodeAttrUnsupportedCaps lists caps SaaS accepts
that headscale cannot act on (funnel today) and rejects them with a
tracking-issue link in the error.
compileNodeAttrs resolves each entry's targets, then maps every
targeted node to a tailcfg.NodeCapMap of the entry's attrs. Per-node
IPs are cached once per call so the inner attr loop is O(grants)
instead of O(grants * nodes) IP allocations.
PolicyManager grows NodeCapMap (per-node), NodeCapMaps (snapshot for
batched callers), and NodesWithChangedCapMap (drain buffer for the
self-broadcast diff). refreshNodeAttrsLocked appends to the drain
rather than overwriting so a SetUsers/SetNodes between SetPolicy and
the drain cannot lose the policy-reload diff.
2026-05-11 14:46:38 +00:00
|
|
|
|
|
|
|
|
// nodeAttrsMap is the per-node CapMap compiled from policy.NodeAttrs.
|
|
|
|
|
// nodeAttrsHashes shadow it for change detection between updateLocked
|
|
|
|
|
// runs. nodeAttrsChanged accumulates the union of all per-call diffs
|
|
|
|
|
// since the last drain — refresh APPENDS, never overwrites, so a
|
|
|
|
|
// concurrent SetUsers/SetNodes between SetPolicy and the drain
|
|
|
|
|
// cannot silently lose the policy-reload diff.
|
|
|
|
|
nodeAttrsMap map[types.NodeID]tailcfg.NodeCapMap
|
|
|
|
|
nodeAttrsHashes map[types.NodeID]deephash.Sum
|
|
|
|
|
nodeAttrsChanged []types.NodeID
|
2025-03-10 16:20:29 +01:00
|
|
|
}
|
|
|
|
|
|
2025-11-30 15:54:16 +01:00
|
|
|
// filterAndPolicy combines the compiled filter rules with policy content for hashing.
|
|
|
|
|
// This ensures filterHash changes when policy changes, even for autogroup:self where
|
|
|
|
|
// the compiled filter is always empty.
|
|
|
|
|
type filterAndPolicy struct {
|
|
|
|
|
Filter []tailcfg.FilterRule
|
|
|
|
|
Policy *Policy
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-28 16:09:42 +00:00
|
|
|
// validateUserReferences surfaces ambiguous user@ tokens at policy load so
|
2026-05-18 18:35:23 +00:00
|
|
|
// duplicate DB rows fail loudly instead of silently dropping rules.
|
|
|
|
|
// Missing-user tokens stay tolerant. Empty users → no-op for
|
2026-04-28 16:09:42 +00:00
|
|
|
// syntax-only checks.
|
|
|
|
|
func validateUserReferences(pol *Policy, users types.Users) error {
|
|
|
|
|
if pol == nil || len(users) == 0 {
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var errs []error
|
|
|
|
|
|
|
|
|
|
check := func(u *Username) {
|
|
|
|
|
if u == nil {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
_, err := u.resolveUser(users)
|
|
|
|
|
if err != nil && errors.Is(err, ErrMultipleUsersFound) {
|
|
|
|
|
errs = append(errs, err)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
checkAlias := func(a Alias) {
|
|
|
|
|
if u, ok := a.(*Username); ok {
|
|
|
|
|
check(u)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
checkOwner := func(o Owner) {
|
|
|
|
|
if u, ok := o.(*Username); ok {
|
|
|
|
|
check(u)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
checkAutoApprover := func(aa AutoApprover) {
|
|
|
|
|
if u, ok := aa.(*Username); ok {
|
|
|
|
|
check(u)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for _, usernames := range pol.Groups {
|
|
|
|
|
for i := range usernames {
|
|
|
|
|
check(&usernames[i])
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for _, owners := range pol.TagOwners {
|
|
|
|
|
for _, o := range owners {
|
|
|
|
|
checkOwner(o)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for _, approvers := range pol.AutoApprovers.Routes {
|
|
|
|
|
for _, aa := range approvers {
|
|
|
|
|
checkAutoApprover(aa)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for _, aa := range pol.AutoApprovers.ExitNode {
|
|
|
|
|
checkAutoApprover(aa)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for _, acl := range pol.ACLs {
|
|
|
|
|
for _, src := range acl.Sources {
|
|
|
|
|
checkAlias(src)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for _, dst := range acl.Destinations {
|
|
|
|
|
checkAlias(dst.Alias)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for _, ssh := range pol.SSHs {
|
|
|
|
|
for _, src := range ssh.Sources {
|
|
|
|
|
checkAlias(src)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for _, dst := range ssh.Destinations {
|
|
|
|
|
checkAlias(dst)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return multierr.New(errs...)
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-18 18:35:23 +00:00
|
|
|
// NewPolicyManager creates a new [PolicyManager] from a policy file and a list of users and nodes.
|
2025-03-10 16:20:29 +01:00
|
|
|
// It returns an error if the policy file is invalid.
|
|
|
|
|
// The policy manager will update the filter rules based on the users and nodes.
|
2025-07-05 23:31:13 +02:00
|
|
|
func NewPolicyManager(b []byte, users []types.User, nodes views.Slice[types.NodeView]) (*PolicyManager, error) {
|
2025-05-01 15:30:52 +03:00
|
|
|
policy, err := unmarshalPolicy(b)
|
2025-03-10 16:20:29 +01:00
|
|
|
if err != nil {
|
|
|
|
|
return nil, fmt.Errorf("parsing policy: %w", err)
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-28 16:09:42 +00:00
|
|
|
err = validateUserReferences(policy, users)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, fmt.Errorf("validating policy user references: %w", err)
|
|
|
|
|
}
|
|
|
|
|
|
2025-03-10 16:20:29 +01:00
|
|
|
pm := PolicyManager{
|
2026-04-15 08:27:28 +00:00
|
|
|
pol: policy,
|
|
|
|
|
users: users,
|
|
|
|
|
nodes: nodes,
|
|
|
|
|
sshPolicyMap: make(map[types.NodeID]*tailcfg.SSHPolicy, nodes.Len()),
|
|
|
|
|
filterRulesMap: make(map[types.NodeID][]tailcfg.FilterRule, nodes.Len()),
|
|
|
|
|
matchersForNodeMap: make(map[types.NodeID][]matcher.Match, nodes.Len()),
|
2025-03-10 16:20:29 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
_, err = pm.updateLocked()
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
|
policy/v2: evaluate the tests block on user-initiated writes
v2 silently dropped policy.tests, so a policy that contradicted its
own assertions still applied. Resolve src/dst via the existing Alias
machinery, walk the compiled global filter rules (acls and grants
both contribute), and run on every user-write boundary: SetPolicy,
the file watcher, and `headscale policy check`. A failing test
rejects the write before it mutates live state.
Boot-time reload skips evaluation; an already-stored policy that
references a deleted user shouldn't lock the server out.
`headscale policy check` is a thin frontend for the new CheckPolicy
gRPC method. The server-side handler builds a fresh PolicyManager
from the request bytes and the state's live users/nodes, runs
SetPolicy on the sandbox so the tests block executes, and returns
the result through gRPC status. No persistence, no policy_mode
coupling. --bypass-grpc-and-access-database-directly opens the DB
directly when the server is not running.
cmd/headscale/cli/root.go no longer special-cases `policy check` in
init() (the early return from PR #2580 broke --config registration
and viper priming for --bypass).
integration/cli_policy_test.go covers policy_mode={file,database} x
fixture={acl-only, acl+passing-tests, acl+failing-tests} x
bypass={false,true} = 12 rows.
Updates #1803
Co-authored-by: Janis Jansons <janhouse@gmail.com>
2026-04-29 14:27:12 +00:00
|
|
|
// Boot path: log a warning if the stored policy's tests would
|
|
|
|
|
// fail against the current users and nodes, but keep the server
|
|
|
|
|
// running. A stale stored policy (e.g. referencing a user that
|
|
|
|
|
// was deleted while the server was offline) should not block
|
|
|
|
|
// boot; the operator finds out via logs and re-runs the write
|
|
|
|
|
// boundary when they are ready.
|
|
|
|
|
if testErr := pm.RunTests(); testErr != nil { //nolint:noinlineerr // boot path: warn-and-continue, not return
|
|
|
|
|
log.Warn().Err(testErr).Msg("policy tests failed at boot; server starting anyway, fix the policy and reload")
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-13 14:17:04 +00:00
|
|
|
if testErr := pm.RunSSHTests(); testErr != nil { //nolint:noinlineerr // boot path: warn-and-continue, not return
|
|
|
|
|
log.Warn().Err(testErr).Msg("policy sshTests failed at boot; server starting anyway, fix the policy and reload")
|
|
|
|
|
}
|
|
|
|
|
|
2025-03-10 16:20:29 +01:00
|
|
|
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) {
|
2026-04-15 08:27:28 +00:00
|
|
|
// Compile all grants once. Both global and per-node filter
|
|
|
|
|
// rules are derived from these compiled grants.
|
|
|
|
|
pm.compiledGrants = pm.pol.compileGrants(pm.users, pm.nodes)
|
|
|
|
|
pm.userNodeIdx = buildUserNodeIndex(pm.nodes)
|
|
|
|
|
pm.needsPerNodeFilter = hasPerNodeGrants(pm.compiledGrants)
|
2026-06-03 10:25:20 +00:00
|
|
|
pm.viaTargetTags = collectViaTargetTags(pm.compiledGrants)
|
|
|
|
|
|
|
|
|
|
relayTargetIPs, err := collectRelayTargetIPs(pm.compiledGrants)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return false, fmt.Errorf("collecting relay target IPs: %w", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pm.relayTargetIPs = relayTargetIPs
|
2025-10-16 12:59:52 +02:00
|
|
|
|
|
|
|
|
var filter []tailcfg.FilterRule
|
2026-04-28 09:21:18 +00:00
|
|
|
if pm.pol == nil || (pm.pol.ACLs == nil && pm.pol.Grants == nil) {
|
2026-04-15 08:27:28 +00:00
|
|
|
filter = tailcfg.FilterAllowAll
|
|
|
|
|
} else {
|
|
|
|
|
filter = globalFilterRules(pm.compiledGrants)
|
2025-03-10 16:20:29 +01:00
|
|
|
}
|
|
|
|
|
|
2025-11-30 15:54:16 +01:00
|
|
|
// Hash both the compiled filter AND the policy content together.
|
|
|
|
|
// This ensures filterHash changes when policy changes, even for autogroup:self
|
|
|
|
|
// where the compiled filter is always empty. This eliminates the need for
|
|
|
|
|
// a separate policyHash field.
|
|
|
|
|
filterHash := deephash.Hash(&filterAndPolicy{
|
|
|
|
|
Filter: filter,
|
|
|
|
|
Policy: pm.pol,
|
|
|
|
|
})
|
2026-02-06 21:45:32 +01:00
|
|
|
|
2025-05-01 07:06:30 +02:00
|
|
|
filterChanged := filterHash != pm.filterHash
|
2025-09-05 16:32:46 +02:00
|
|
|
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")
|
|
|
|
|
}
|
2026-02-06 21:45:32 +01:00
|
|
|
|
2025-03-10 16:20:29 +01:00
|
|
|
pm.filter = filter
|
2026-02-06 21:45:32 +01:00
|
|
|
|
2025-03-10 16:20:29 +01:00
|
|
|
pm.filterHash = filterHash
|
2025-05-01 07:06:30 +02:00
|
|
|
if filterChanged {
|
|
|
|
|
pm.matchers = matcher.MatchesFromFilterRules(pm.filter)
|
|
|
|
|
}
|
2025-03-10 16:20:29 +01:00
|
|
|
|
|
|
|
|
// 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)
|
2026-02-06 21:45:32 +01:00
|
|
|
|
2025-03-10 16:20:29 +01:00
|
|
|
tagOwnerChanged := tagOwnerMapHash != pm.tagOwnerMapHash
|
2025-09-05 16:32:46 +02:00
|
|
|
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")
|
|
|
|
|
}
|
2026-02-06 21:45:32 +01:00
|
|
|
|
2025-03-10 16:20:29 +01:00
|
|
|
pm.tagOwnerMap = tagMap
|
|
|
|
|
pm.tagOwnerMapHash = tagOwnerMapHash
|
|
|
|
|
|
2025-05-10 00:20:04 +03:00
|
|
|
autoMap, exitSet, err := resolveAutoApprovers(pm.pol, pm.users, pm.nodes)
|
2025-03-10 16:20:29 +01:00
|
|
|
if err != nil {
|
|
|
|
|
return false, fmt.Errorf("resolving auto approvers map: %w", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
autoApproveMapHash := deephash.Hash(&autoMap)
|
2026-02-06 21:45:32 +01:00
|
|
|
|
2025-03-10 16:20:29 +01:00
|
|
|
autoApproveChanged := autoApproveMapHash != pm.autoApproveMapHash
|
2025-09-05 16:32:46 +02:00
|
|
|
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")
|
|
|
|
|
}
|
2026-02-06 21:45:32 +01:00
|
|
|
|
2025-03-10 16:20:29 +01:00
|
|
|
pm.autoApproveMap = autoMap
|
|
|
|
|
pm.autoApproveMapHash = autoApproveMapHash
|
|
|
|
|
|
2025-09-05 16:32:46 +02:00
|
|
|
exitSetHash := deephash.Hash(&exitSet)
|
2026-02-06 21:45:32 +01:00
|
|
|
|
2025-05-10 00:20:04 +03:00
|
|
|
exitSetChanged := exitSetHash != pm.exitSetHash
|
2025-09-05 16:32:46 +02:00
|
|
|
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")
|
|
|
|
|
}
|
2026-02-06 21:45:32 +01:00
|
|
|
|
2025-05-10 00:20:04 +03:00
|
|
|
pm.exitSet = exitSet
|
|
|
|
|
pm.exitSetHash = exitSetHash
|
|
|
|
|
|
policy/v2: parse, validate, and compile nodeAttrs
ACL policies now accept a top-level nodeAttrs block. Each entry hands
a list of tailcfg node capabilities to every node matching target.
Accepted target forms are the same as acls.src and grants.src: users,
groups, tags, hosts, prefixes, autogroup:member, autogroup:tagged,
and *. autogroup:self, autogroup:internet, and autogroup:danger-all
are rejected at validate time because none describes a stable
identity set a node-level attribute can attach to.
NodeAttrGrant carries Targets, Attrs, and IPPool. IPPool is parsed
but rejected at validate time -- the allocator that consumes it is
not yet implemented. nodeAttrUnsupportedCaps lists caps SaaS accepts
that headscale cannot act on (funnel today) and rejects them with a
tracking-issue link in the error.
compileNodeAttrs resolves each entry's targets, then maps every
targeted node to a tailcfg.NodeCapMap of the entry's attrs. Per-node
IPs are cached once per call so the inner attr loop is O(grants)
instead of O(grants * nodes) IP allocations.
PolicyManager grows NodeCapMap (per-node), NodeCapMaps (snapshot for
batched callers), and NodesWithChangedCapMap (drain buffer for the
self-broadcast diff). refreshNodeAttrsLocked appends to the drain
rather than overwriting so a SetUsers/SetNodes between SetPolicy and
the drain cannot lose the policy-reload diff.
2026-05-11 14:46:38 +00:00
|
|
|
// Recompile per-node nodeAttrs CapMap and append the diff to
|
|
|
|
|
// pm.nodeAttrsChanged. The drain (NodesWithChangedCapMap) returns
|
|
|
|
|
// the accumulated union of every change since the last drain;
|
|
|
|
|
// SetUsers/SetNodes appending between SetPolicy and the drain
|
|
|
|
|
// cannot lose the policy-reload diff.
|
|
|
|
|
err = pm.refreshNodeAttrsLocked()
|
|
|
|
|
if err != nil {
|
|
|
|
|
return false, err
|
|
|
|
|
}
|
|
|
|
|
|
2025-11-30 15:54:16 +01:00
|
|
|
// Determine if we need to send updates to nodes
|
|
|
|
|
// filterChanged now includes policy content changes (via combined hash),
|
|
|
|
|
// so it will detect changes even for autogroup:self where compiled filter is empty
|
|
|
|
|
needsUpdate := filterChanged || tagOwnerChanged || autoApproveChanged || exitSetChanged
|
|
|
|
|
|
|
|
|
|
// Only clear caches if we're actually going to send updates
|
|
|
|
|
// This prevents clearing caches when nothing changed, which would leave nodes
|
|
|
|
|
// with stale filters until they reconnect. This is critical for autogroup:self
|
|
|
|
|
// where even reloading the same policy would clear caches but not send updates.
|
|
|
|
|
if needsUpdate {
|
|
|
|
|
// 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)
|
|
|
|
|
clear(pm.filterRulesMap)
|
2026-04-15 08:27:28 +00:00
|
|
|
clear(pm.matchersForNodeMap)
|
2025-11-30 15:54:16 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// If nothing changed, no need to update nodes
|
|
|
|
|
if !needsUpdate {
|
2025-09-05 16:32:46 +02:00
|
|
|
log.Trace().
|
|
|
|
|
Msg("Policy evaluation detected no changes - all hashes match")
|
2026-02-06 21:45:32 +01:00
|
|
|
|
2025-03-10 16:20:29 +01:00
|
|
|
return false, nil
|
|
|
|
|
}
|
|
|
|
|
|
2025-09-05 16:32:46 +02:00
|
|
|
log.Debug().
|
|
|
|
|
Bool("filter.changed", filterChanged).
|
|
|
|
|
Bool("tagOwners.changed", tagOwnerChanged).
|
|
|
|
|
Bool("autoApprovers.changed", autoApproveChanged).
|
|
|
|
|
Bool("exitNodes.changed", exitSetChanged).
|
|
|
|
|
Msg("Policy changes require node updates")
|
|
|
|
|
|
2025-03-10 16:20:29 +01:00
|
|
|
return true, nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-03 10:25:20 +00:00
|
|
|
// NodeNeedsPeerRecompute reports whether peers must recompute their netmap
|
|
|
|
|
// when node's online state changes. A plain node only needs the lightweight
|
|
|
|
|
// online/offline peer patch; these roles change what peers compute when the
|
|
|
|
|
// node goes up or down, so they require a full recompute:
|
|
|
|
|
// - subnet router: primary-route failover changes peers' AllowedIPs
|
|
|
|
|
// - relay target (tailscale.com/cap/relay): peers must drop a stale
|
|
|
|
|
// PeerRelay allocation
|
|
|
|
|
// - via target: peers steer traffic through this node
|
|
|
|
|
//
|
|
|
|
|
// The check is keyed on the node itself, so an ordinary node in a tailnet
|
|
|
|
|
// that uses relay or via for other nodes is correctly classified as not
|
|
|
|
|
// needing a recompute.
|
|
|
|
|
func (pm *PolicyManager) NodeNeedsPeerRecompute(node types.NodeView) bool {
|
|
|
|
|
if !node.Valid() {
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Subnet-router status is intrinsic to the node, so it needs no policy
|
|
|
|
|
// state and is checked without the lock.
|
|
|
|
|
if node.IsSubnetRouter() {
|
|
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pm.mu.Lock()
|
|
|
|
|
defer pm.mu.Unlock()
|
|
|
|
|
|
|
|
|
|
if pm.relayTargetIPs != nil && node.InIPSet(pm.relayTargetIPs) {
|
|
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for tag := range pm.viaTargetTags {
|
|
|
|
|
if node.HasTag(string(tag)) {
|
|
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-18 18:35:23 +00:00
|
|
|
// SSHPolicy returns the [tailcfg.SSHPolicy] for node, compiling and
|
2026-04-15 08:27:28 +00:00
|
|
|
// caching on first access. Rules use SessionDuration = 0 (no
|
|
|
|
|
// auto-approval) and emit check URLs of the form
|
|
|
|
|
// /machine/ssh/action/{src}/to/{dst}?local_user={local_user} per the
|
|
|
|
|
// SaaS wire format. Cache is invalidated on policy reload.
|
2026-02-24 18:50:18 +00:00
|
|
|
func (pm *PolicyManager) SSHPolicy(baseURL string, node types.NodeView) (*tailcfg.SSHPolicy, error) {
|
2025-03-10 16:20:29 +01:00
|
|
|
pm.mu.Lock()
|
|
|
|
|
defer pm.mu.Unlock()
|
|
|
|
|
|
2025-07-05 23:31:13 +02:00
|
|
|
if sshPol, ok := pm.sshPolicyMap[node.ID()]; ok {
|
2025-03-10 16:20:29 +01:00
|
|
|
return sshPol, nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-24 18:50:18 +00:00
|
|
|
sshPol, err := pm.pol.compileSSHPolicy(baseURL, pm.users, node, pm.nodes)
|
2025-03-10 16:20:29 +01:00
|
|
|
if err != nil {
|
|
|
|
|
return nil, fmt.Errorf("compiling SSH policy: %w", err)
|
|
|
|
|
}
|
2026-02-06 21:45:32 +01:00
|
|
|
|
2025-07-05 23:31:13 +02:00
|
|
|
pm.sshPolicyMap[node.ID()] = sshPol
|
2025-03-10 16:20:29 +01:00
|
|
|
|
|
|
|
|
return sshPol, nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-24 18:52:17 +00:00
|
|
|
// SSHCheckParams resolves the SSH check period for a source-destination
|
|
|
|
|
// node pair by looking up the current policy. This avoids trusting URL
|
2026-04-15 08:27:28 +00:00
|
|
|
// parameters that a client could tamper with. First-match wins across
|
|
|
|
|
// the policy's SSH rules.
|
|
|
|
|
//
|
|
|
|
|
// Returns (duration, true) when a matching rule is found and
|
|
|
|
|
// (0, false) when none is. A (0, true) return means the matched rule
|
|
|
|
|
// uses a zero check period (re-check every session).
|
2026-02-24 18:52:17 +00:00
|
|
|
func (pm *PolicyManager) SSHCheckParams(
|
|
|
|
|
srcNodeID, dstNodeID types.NodeID,
|
|
|
|
|
) (time.Duration, bool) {
|
|
|
|
|
pm.mu.Lock()
|
|
|
|
|
defer pm.mu.Unlock()
|
|
|
|
|
|
|
|
|
|
if pm.pol == nil || len(pm.pol.SSHs) == 0 {
|
|
|
|
|
return 0, false
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Find the source and destination node views.
|
|
|
|
|
var srcNode, dstNode types.NodeView
|
|
|
|
|
|
|
|
|
|
for _, n := range pm.nodes.All() {
|
|
|
|
|
nid := n.ID()
|
|
|
|
|
if nid == srcNodeID {
|
|
|
|
|
srcNode = n
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if nid == dstNodeID {
|
|
|
|
|
dstNode = n
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if srcNode.Valid() && dstNode.Valid() {
|
|
|
|
|
break
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if !srcNode.Valid() || !dstNode.Valid() {
|
|
|
|
|
return 0, false
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Iterate SSH rules to find the first matching check rule.
|
|
|
|
|
for _, rule := range pm.pol.SSHs {
|
|
|
|
|
if rule.Action != SSHActionCheck {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Resolve sources and check if src node matches.
|
|
|
|
|
srcIPs, err := rule.Sources.Resolve(pm.pol, pm.users, pm.nodes)
|
|
|
|
|
if err != nil || srcIPs == nil {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if !slices.ContainsFunc(srcNode.IPs(), srcIPs.Contains) {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Check if dst node matches any destination.
|
|
|
|
|
for _, dst := range rule.Destinations {
|
|
|
|
|
if ag, isAG := dst.(*AutoGroup); isAG && ag.Is(AutoGroupSelf) {
|
|
|
|
|
if !srcNode.IsTagged() && !dstNode.IsTagged() &&
|
|
|
|
|
srcNode.User().ID() == dstNode.User().ID() {
|
|
|
|
|
return checkPeriodFromRule(rule), true
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
dstIPs, err := dst.Resolve(pm.pol, pm.users, pm.nodes)
|
|
|
|
|
if err != nil || dstIPs == nil {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if slices.ContainsFunc(dstNode.IPs(), dstIPs.Contains) {
|
|
|
|
|
return checkPeriodFromRule(rule), true
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return 0, false
|
|
|
|
|
}
|
|
|
|
|
|
2025-03-10 16:20:29 +01:00
|
|
|
func (pm *PolicyManager) SetPolicy(polB []byte) (bool, error) {
|
|
|
|
|
if len(polB) == 0 {
|
|
|
|
|
return false, nil
|
|
|
|
|
}
|
|
|
|
|
|
2025-05-01 15:30:52 +03:00
|
|
|
pol, err := unmarshalPolicy(polB)
|
2025-03-10 16:20:29 +01:00
|
|
|
if err != nil {
|
|
|
|
|
return false, fmt.Errorf("parsing policy: %w", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pm.mu.Lock()
|
|
|
|
|
defer pm.mu.Unlock()
|
|
|
|
|
|
2026-04-28 16:09:42 +00:00
|
|
|
err = validateUserReferences(pol, pm.users)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return false, fmt.Errorf("validating policy user references: %w", err)
|
|
|
|
|
}
|
|
|
|
|
|
policy/v2: evaluate the tests block on user-initiated writes
v2 silently dropped policy.tests, so a policy that contradicted its
own assertions still applied. Resolve src/dst via the existing Alias
machinery, walk the compiled global filter rules (acls and grants
both contribute), and run on every user-write boundary: SetPolicy,
the file watcher, and `headscale policy check`. A failing test
rejects the write before it mutates live state.
Boot-time reload skips evaluation; an already-stored policy that
references a deleted user shouldn't lock the server out.
`headscale policy check` is a thin frontend for the new CheckPolicy
gRPC method. The server-side handler builds a fresh PolicyManager
from the request bytes and the state's live users/nodes, runs
SetPolicy on the sandbox so the tests block executes, and returns
the result through gRPC status. No persistence, no policy_mode
coupling. --bypass-grpc-and-access-database-directly opens the DB
directly when the server is not running.
cmd/headscale/cli/root.go no longer special-cases `policy check` in
init() (the early return from PR #2580 broke --config registration
and viper priming for --bypass).
integration/cli_policy_test.go covers policy_mode={file,database} x
fixture={acl-only, acl+passing-tests, acl+failing-tests} x
bypass={false,true} = 12 rows.
Updates #1803
Co-authored-by: Janis Jansons <janhouse@gmail.com>
2026-04-29 14:27:12 +00:00
|
|
|
// SetPolicy is the user-write boundary. Tests evaluate against a
|
|
|
|
|
// sandbox compiled from the new policy + current users/nodes; if
|
|
|
|
|
// they fail, return without mutating the live PolicyManager so the
|
|
|
|
|
// failed write does not knock the running config offline.
|
2026-05-13 14:17:04 +00:00
|
|
|
//
|
|
|
|
|
// Aggregate ACL and SSH test failures via multierr so operators
|
|
|
|
|
// see both classes in a single response instead of having to
|
|
|
|
|
// fix-and-retry to discover the second one.
|
|
|
|
|
testErr := multierr.New(
|
|
|
|
|
evaluateTests(pol, pm.users, pm.nodes),
|
|
|
|
|
evaluateSSHTests(pol, pm.users, pm.nodes),
|
|
|
|
|
)
|
|
|
|
|
if testErr != nil {
|
|
|
|
|
return false, testErr
|
policy/v2: evaluate the tests block on user-initiated writes
v2 silently dropped policy.tests, so a policy that contradicted its
own assertions still applied. Resolve src/dst via the existing Alias
machinery, walk the compiled global filter rules (acls and grants
both contribute), and run on every user-write boundary: SetPolicy,
the file watcher, and `headscale policy check`. A failing test
rejects the write before it mutates live state.
Boot-time reload skips evaluation; an already-stored policy that
references a deleted user shouldn't lock the server out.
`headscale policy check` is a thin frontend for the new CheckPolicy
gRPC method. The server-side handler builds a fresh PolicyManager
from the request bytes and the state's live users/nodes, runs
SetPolicy on the sandbox so the tests block executes, and returns
the result through gRPC status. No persistence, no policy_mode
coupling. --bypass-grpc-and-access-database-directly opens the DB
directly when the server is not running.
cmd/headscale/cli/root.go no longer special-cases `policy check` in
init() (the early return from PR #2580 broke --config registration
and viper priming for --bypass).
integration/cli_policy_test.go covers policy_mode={file,database} x
fixture={acl-only, acl+passing-tests, acl+failing-tests} x
bypass={false,true} = 12 rows.
Updates #1803
Co-authored-by: Janis Jansons <janhouse@gmail.com>
2026-04-29 14:27:12 +00:00
|
|
|
}
|
|
|
|
|
|
2025-09-05 16:32:46 +02:00
|
|
|
// 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)).
|
policy/v2: parse, validate, and compile nodeAttrs
ACL policies now accept a top-level nodeAttrs block. Each entry hands
a list of tailcfg node capabilities to every node matching target.
Accepted target forms are the same as acls.src and grants.src: users,
groups, tags, hosts, prefixes, autogroup:member, autogroup:tagged,
and *. autogroup:self, autogroup:internet, and autogroup:danger-all
are rejected at validate time because none describes a stable
identity set a node-level attribute can attach to.
NodeAttrGrant carries Targets, Attrs, and IPPool. IPPool is parsed
but rejected at validate time -- the allocator that consumes it is
not yet implemented. nodeAttrUnsupportedCaps lists caps SaaS accepts
that headscale cannot act on (funnel today) and rejects them with a
tracking-issue link in the error.
compileNodeAttrs resolves each entry's targets, then maps every
targeted node to a tailcfg.NodeCapMap of the entry's attrs. Per-node
IPs are cached once per call so the inner attr loop is O(grants)
instead of O(grants * nodes) IP allocations.
PolicyManager grows NodeCapMap (per-node), NodeCapMaps (snapshot for
batched callers), and NodesWithChangedCapMap (drain buffer for the
self-broadcast diff). refreshNodeAttrsLocked appends to the drain
rather than overwriting so a SetUsers/SetNodes between SetPolicy and
the drain cannot lose the policy-reload diff.
2026-05-11 14:46:38 +00:00
|
|
|
Int("nodeAttrs.count", len(pol.NodeAttrs)).
|
2025-09-05 16:32:46 +02:00
|
|
|
Int("autoApprovers.routes.count", len(pol.AutoApprovers.Routes)).
|
policy/v2: evaluate the tests block on user-initiated writes
v2 silently dropped policy.tests, so a policy that contradicted its
own assertions still applied. Resolve src/dst via the existing Alias
machinery, walk the compiled global filter rules (acls and grants
both contribute), and run on every user-write boundary: SetPolicy,
the file watcher, and `headscale policy check`. A failing test
rejects the write before it mutates live state.
Boot-time reload skips evaluation; an already-stored policy that
references a deleted user shouldn't lock the server out.
`headscale policy check` is a thin frontend for the new CheckPolicy
gRPC method. The server-side handler builds a fresh PolicyManager
from the request bytes and the state's live users/nodes, runs
SetPolicy on the sandbox so the tests block executes, and returns
the result through gRPC status. No persistence, no policy_mode
coupling. --bypass-grpc-and-access-database-directly opens the DB
directly when the server is not running.
cmd/headscale/cli/root.go no longer special-cases `policy check` in
init() (the early return from PR #2580 broke --config registration
and viper priming for --bypass).
integration/cli_policy_test.go covers policy_mode={file,database} x
fixture={acl-only, acl+passing-tests, acl+failing-tests} x
bypass={false,true} = 12 rows.
Updates #1803
Co-authored-by: Janis Jansons <janhouse@gmail.com>
2026-04-29 14:27:12 +00:00
|
|
|
Int("tests.count", len(pol.Tests)).
|
2025-09-05 16:32:46 +02:00
|
|
|
Msg("Policy parsed successfully")
|
|
|
|
|
|
2025-03-10 16:20:29 +01:00
|
|
|
pm.pol = pol
|
|
|
|
|
|
|
|
|
|
return pm.updateLocked()
|
|
|
|
|
}
|
|
|
|
|
|
2025-05-01 07:06:30 +02:00
|
|
|
// Filter returns the current filter rules for the entire tailnet and the associated matchers.
|
|
|
|
|
func (pm *PolicyManager) Filter() ([]tailcfg.FilterRule, []matcher.Match) {
|
2025-05-04 22:52:47 +03:00
|
|
|
if pm == nil {
|
|
|
|
|
return nil, nil
|
|
|
|
|
}
|
|
|
|
|
|
2025-03-10 16:20:29 +01:00
|
|
|
pm.mu.Lock()
|
|
|
|
|
defer pm.mu.Unlock()
|
2025-07-10 23:38:55 +02:00
|
|
|
|
2025-05-01 07:06:30 +02:00
|
|
|
return pm.filter, pm.matchers
|
2025-03-10 16:20:29 +01:00
|
|
|
}
|
|
|
|
|
|
2025-10-23 17:57:41 +02:00
|
|
|
// BuildPeerMap constructs peer relationship maps for the given nodes.
|
|
|
|
|
// For global filters, it uses the global filter matchers for all nodes.
|
|
|
|
|
// For autogroup:self policies (empty global filter), it builds per-node
|
|
|
|
|
// peer maps using each node's specific filter rules.
|
2026-05-18 18:35:23 +00:00
|
|
|
//
|
|
|
|
|
// Compared to [policy.ReduceNodes], which builds the list per node, we end
|
|
|
|
|
// up with doing the full work for every node O(n^2), while this will reduce
|
|
|
|
|
// the list as we see relationships while building the map, making it
|
|
|
|
|
// O(n^2/2) in the end, but with less work per node.
|
2025-10-23 17:57:41 +02:00
|
|
|
func (pm *PolicyManager) BuildPeerMap(nodes views.Slice[types.NodeView]) map[types.NodeID][]types.NodeView {
|
2025-10-16 12:59:52 +02:00
|
|
|
if pm == nil {
|
2025-10-23 17:57:41 +02:00
|
|
|
return nil
|
2025-10-16 12:59:52 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pm.mu.Lock()
|
|
|
|
|
defer pm.mu.Unlock()
|
|
|
|
|
|
2026-03-28 12:48:19 +00:00
|
|
|
// If we have a global filter, use it for all nodes (normal case).
|
|
|
|
|
// Via grants require the per-node path because the global filter
|
|
|
|
|
// skips via grants (compileFilterRules: if len(grant.Via) > 0 { continue }).
|
|
|
|
|
if !pm.needsPerNodeFilter {
|
2025-10-23 17:57:41 +02:00
|
|
|
ret := make(map[types.NodeID][]types.NodeView, nodes.Len())
|
|
|
|
|
|
|
|
|
|
// Build the map of all peers according to the matchers.
|
|
|
|
|
for i := range nodes.Len() {
|
|
|
|
|
for j := i + 1; j < nodes.Len(); j++ {
|
|
|
|
|
if nodes.At(i).ID() == nodes.At(j).ID() {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if nodes.At(i).CanAccess(pm.matchers, nodes.At(j)) || nodes.At(j).CanAccess(pm.matchers, nodes.At(i)) {
|
|
|
|
|
ret[nodes.At(i).ID()] = append(ret[nodes.At(i).ID()], nodes.At(j))
|
|
|
|
|
ret[nodes.At(j).ID()] = append(ret[nodes.At(j).ID()], nodes.At(i))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return ret
|
2025-10-16 12:59:52 +02:00
|
|
|
}
|
|
|
|
|
|
2026-03-28 12:48:19 +00:00
|
|
|
// For autogroup:self or via grants, build per-node peer relationships
|
2025-10-23 17:57:41 +02:00
|
|
|
ret := make(map[types.NodeID][]types.NodeView, nodes.Len())
|
|
|
|
|
|
|
|
|
|
// Pre-compute per-node matchers using unreduced compiled rules
|
|
|
|
|
// We need unreduced rules to determine peer relationships correctly.
|
|
|
|
|
// Reduced rules only show destinations where the node is the target,
|
|
|
|
|
// but peer relationships require the full bidirectional access rules.
|
|
|
|
|
nodeMatchers := make(map[types.NodeID][]matcher.Match, nodes.Len())
|
|
|
|
|
for _, node := range nodes.All() {
|
2026-04-15 08:27:28 +00:00
|
|
|
unreduced := pm.filterRulesForNodeLocked(node)
|
|
|
|
|
nodeMatchers[node.ID()] = matcher.MatchesFromFilterRules(unreduced)
|
2025-10-23 17:57:41 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Check each node pair for peer relationships.
|
|
|
|
|
// Start j at i+1 to avoid checking the same pair twice and creating duplicates.
|
2026-01-20 16:49:36 +00:00
|
|
|
// We use symmetric visibility: if EITHER node can access the other, BOTH see
|
|
|
|
|
// each other. This matches the global filter path behavior and ensures that
|
|
|
|
|
// one-way access rules (e.g., admin -> tagged server) still allow both nodes
|
|
|
|
|
// to see each other as peers, which is required for network connectivity.
|
2025-10-23 17:57:41 +02:00
|
|
|
for i := range nodes.Len() {
|
|
|
|
|
nodeI := nodes.At(i)
|
|
|
|
|
matchersI, hasFilterI := nodeMatchers[nodeI.ID()]
|
|
|
|
|
|
|
|
|
|
for j := i + 1; j < nodes.Len(); j++ {
|
|
|
|
|
nodeJ := nodes.At(j)
|
|
|
|
|
matchersJ, hasFilterJ := nodeMatchers[nodeJ.ID()]
|
|
|
|
|
|
2026-03-28 12:48:19 +00:00
|
|
|
// Check all access directions for symmetric peer visibility.
|
|
|
|
|
// For via grants, filter rules exist on the via-designated node
|
|
|
|
|
// (e.g., router-a) with sources being the client (group-a).
|
|
|
|
|
// We need to check BOTH:
|
|
|
|
|
// 1. nodeI.CanAccess(matchersI, nodeJ) — can nodeI reach nodeJ?
|
|
|
|
|
// 2. nodeJ.CanAccess(matchersI, nodeI) — can nodeJ reach nodeI
|
|
|
|
|
// using nodeI's matchers? (reverse direction: the matchers
|
|
|
|
|
// on the via node accept traffic FROM the source)
|
|
|
|
|
// Same for matchersJ in both directions.
|
2026-01-20 16:49:36 +00:00
|
|
|
canIAccessJ := hasFilterI && nodeI.CanAccess(matchersI, nodeJ)
|
|
|
|
|
canJAccessI := hasFilterJ && nodeJ.CanAccess(matchersJ, nodeI)
|
2026-03-28 12:48:19 +00:00
|
|
|
canJReachI := hasFilterI && nodeJ.CanAccess(matchersI, nodeI)
|
|
|
|
|
canIReachJ := hasFilterJ && nodeI.CanAccess(matchersJ, nodeJ)
|
2025-10-23 17:57:41 +02:00
|
|
|
|
2026-03-28 12:48:19 +00:00
|
|
|
if canIAccessJ || canJAccessI || canJReachI || canIReachJ {
|
2026-01-20 16:49:36 +00:00
|
|
|
ret[nodeI.ID()] = append(ret[nodeI.ID()], nodeJ)
|
2025-10-23 17:57:41 +02:00
|
|
|
ret[nodeJ.ID()] = append(ret[nodeJ.ID()], nodeI)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return ret
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-15 08:27:28 +00:00
|
|
|
// filterRulesForNodeLocked returns the unreduced compiled filter rules
|
|
|
|
|
// for a node, combining pre-compiled global rules with per-node self
|
|
|
|
|
// and via rules from the stored compiled grants.
|
|
|
|
|
func (pm *PolicyManager) filterRulesForNodeLocked(
|
|
|
|
|
node types.NodeView,
|
|
|
|
|
) []tailcfg.FilterRule {
|
|
|
|
|
return filterRulesForNode(
|
|
|
|
|
pm.compiledGrants, node, pm.userNodeIdx,
|
|
|
|
|
)
|
2025-10-16 12:59:52 +02:00
|
|
|
}
|
|
|
|
|
|
2026-04-15 08:27:28 +00:00
|
|
|
// filterForNodeLocked returns the filter rules for a specific node,
|
|
|
|
|
// already reduced to only include rules relevant to that node.
|
|
|
|
|
//
|
|
|
|
|
// Fast path (!needsPerNodeFilter): reduces global filter per-node.
|
|
|
|
|
// Slow path (needsPerNodeFilter): combines global + self + via rules
|
|
|
|
|
// from the stored compiled grants, then reduces.
|
|
|
|
|
//
|
|
|
|
|
// Both paths derive from the same compiledGrants, ensuring there is
|
|
|
|
|
// no divergence between global and per-node filter output.
|
|
|
|
|
//
|
|
|
|
|
// Lock-free version for internal use when the lock is already held.
|
|
|
|
|
func (pm *PolicyManager) filterForNodeLocked(
|
|
|
|
|
node types.NodeView,
|
|
|
|
|
) []tailcfg.FilterRule {
|
2025-10-23 17:57:41 +02:00
|
|
|
if pm == nil {
|
2026-04-15 08:27:28 +00:00
|
|
|
return nil
|
2025-10-23 17:57:41 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if rules, ok := pm.filterRulesMap[node.ID()]; ok {
|
2026-04-15 08:27:28 +00:00
|
|
|
return rules
|
2025-10-23 17:57:41 +02:00
|
|
|
}
|
|
|
|
|
|
2026-04-15 08:27:28 +00:00
|
|
|
var unreduced []tailcfg.FilterRule
|
|
|
|
|
if !pm.needsPerNodeFilter {
|
|
|
|
|
unreduced = pm.filter
|
|
|
|
|
} else {
|
|
|
|
|
unreduced = pm.filterRulesForNodeLocked(node)
|
2025-10-23 17:57:41 +02:00
|
|
|
}
|
|
|
|
|
|
2026-04-15 08:27:28 +00:00
|
|
|
reduced := policyutil.ReduceFilterRules(node, unreduced)
|
|
|
|
|
pm.filterRulesMap[node.ID()] = reduced
|
2025-10-23 17:57:41 +02:00
|
|
|
|
2026-04-15 08:27:28 +00:00
|
|
|
return reduced
|
2025-10-23 17:57:41 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// FilterForNode returns the filter rules for a specific node, already reduced
|
|
|
|
|
// to only include rules relevant to that node.
|
|
|
|
|
// If the policy uses autogroup:self, this returns node-specific compiled rules.
|
|
|
|
|
// Otherwise, it returns the global filter reduced for this node.
|
2026-04-15 08:27:28 +00:00
|
|
|
//
|
2026-05-18 18:35:23 +00:00
|
|
|
// Cache is invalidated by [PolicyManager.updateLocked] on policy reload,
|
|
|
|
|
// node-set change, or tag-state change.
|
2025-10-23 17:57:41 +02:00
|
|
|
func (pm *PolicyManager) FilterForNode(node types.NodeView) ([]tailcfg.FilterRule, error) {
|
|
|
|
|
if pm == nil {
|
|
|
|
|
return nil, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pm.mu.Lock()
|
|
|
|
|
defer pm.mu.Unlock()
|
|
|
|
|
|
2026-04-15 08:27:28 +00:00
|
|
|
return pm.filterForNodeLocked(node), nil
|
2025-10-23 17:57:41 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// MatchersForNode returns the matchers for peer relationship determination for a specific node.
|
|
|
|
|
// These are UNREDUCED matchers - they include all rules where the node could be either source or destination.
|
2026-05-18 18:35:23 +00:00
|
|
|
// This is different from [PolicyManager.FilterForNode] which returns REDUCED rules for packet filtering.
|
2025-10-23 17:57:41 +02:00
|
|
|
//
|
|
|
|
|
// For global policies: returns the global matchers (same for all nodes)
|
2026-02-06 21:45:32 +01:00
|
|
|
// For autogroup:self: returns node-specific matchers from unreduced compiled rules.
|
2026-04-15 08:27:28 +00:00
|
|
|
//
|
|
|
|
|
// Per-node results are cached and invalidated on policy/node updates
|
2026-05-18 18:35:23 +00:00
|
|
|
// so [PolicyManager.BuildPeerMap]'s O(N²) slow path avoids recomputing
|
|
|
|
|
// matchers for every pair.
|
2025-10-23 17:57:41 +02:00
|
|
|
func (pm *PolicyManager) MatchersForNode(node types.NodeView) ([]matcher.Match, error) {
|
|
|
|
|
if pm == nil {
|
|
|
|
|
return nil, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pm.mu.Lock()
|
|
|
|
|
defer pm.mu.Unlock()
|
|
|
|
|
|
2026-03-28 12:48:19 +00:00
|
|
|
// For global policies, return the shared global matchers.
|
|
|
|
|
// Via grants require per-node matchers because the global matchers
|
|
|
|
|
// are empty for via-grant-only policies.
|
|
|
|
|
if !pm.needsPerNodeFilter {
|
2025-10-23 17:57:41 +02:00
|
|
|
return pm.matchers, nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-15 08:27:28 +00:00
|
|
|
if cached, ok := pm.matchersForNodeMap[node.ID()]; ok {
|
|
|
|
|
return cached, nil
|
2025-10-23 17:57:41 +02:00
|
|
|
}
|
|
|
|
|
|
2026-04-15 08:27:28 +00:00
|
|
|
// For autogroup:self or via grants, derive matchers from
|
|
|
|
|
// the stored compiled grants for this specific node.
|
|
|
|
|
unreduced := pm.filterRulesForNodeLocked(node)
|
|
|
|
|
matchers := matcher.MatchesFromFilterRules(unreduced)
|
|
|
|
|
pm.matchersForNodeMap[node.ID()] = matchers
|
|
|
|
|
|
|
|
|
|
return matchers, nil
|
2025-10-23 17:57:41 +02:00
|
|
|
}
|
|
|
|
|
|
2025-03-10 16:20:29 +01:00
|
|
|
// SetUsers updates the users in the policy manager and updates the filter rules.
|
|
|
|
|
func (pm *PolicyManager) SetUsers(users []types.User) (bool, error) {
|
2025-05-04 22:52:47 +03:00
|
|
|
if pm == nil {
|
|
|
|
|
return false, nil
|
|
|
|
|
}
|
|
|
|
|
|
2025-03-10 16:20:29 +01:00
|
|
|
pm.mu.Lock()
|
|
|
|
|
defer pm.mu.Unlock()
|
2026-02-06 21:45:32 +01:00
|
|
|
|
2025-03-10 16:20:29 +01:00
|
|
|
pm.users = users
|
2025-07-10 23:38:55 +02:00
|
|
|
|
2025-09-17 14:23:21 +02:00
|
|
|
// Clear SSH policy map when users change to force SSH policy recomputation
|
|
|
|
|
// This ensures that if SSH policy compilation previously failed due to missing users,
|
|
|
|
|
// it will be retried with the new user list
|
|
|
|
|
clear(pm.sshPolicyMap)
|
|
|
|
|
|
|
|
|
|
changed, err := pm.updateLocked()
|
|
|
|
|
if err != nil {
|
|
|
|
|
return false, err
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// If SSH policies exist, force a policy change when users are updated
|
|
|
|
|
// This ensures nodes get updated SSH policies even if other policy hashes didn't change
|
|
|
|
|
if pm.pol != nil && pm.pol.SSHs != nil && len(pm.pol.SSHs) > 0 {
|
|
|
|
|
return true, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return changed, nil
|
2025-03-10 16:20:29 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// SetNodes updates the nodes in the policy manager and updates the filter rules.
|
2025-07-05 23:31:13 +02:00
|
|
|
func (pm *PolicyManager) SetNodes(nodes views.Slice[types.NodeView]) (bool, error) {
|
2025-05-04 22:52:47 +03:00
|
|
|
if pm == nil {
|
|
|
|
|
return false, nil
|
|
|
|
|
}
|
|
|
|
|
|
2025-03-10 16:20:29 +01:00
|
|
|
pm.mu.Lock()
|
|
|
|
|
defer pm.mu.Unlock()
|
2025-10-16 12:59:52 +02:00
|
|
|
|
2025-12-15 14:33:36 +00:00
|
|
|
policyChanged := pm.nodesHavePolicyAffectingChanges(nodes)
|
2025-10-23 17:57:41 +02:00
|
|
|
|
|
|
|
|
// Invalidate cache entries for nodes that changed.
|
|
|
|
|
// For autogroup:self: invalidate all nodes belonging to affected users (peer changes).
|
|
|
|
|
// For global policies: invalidate only nodes whose properties changed (IPs, routes).
|
|
|
|
|
pm.invalidateNodeCache(nodes)
|
2025-10-16 12:59:52 +02:00
|
|
|
|
2025-03-10 16:20:29 +01:00
|
|
|
pm.nodes = nodes
|
2025-07-10 23:38:55 +02:00
|
|
|
|
2025-12-15 14:33:36 +00:00
|
|
|
// When policy-affecting node properties change, we must recompile filters because:
|
2025-10-23 17:57:41 +02:00
|
|
|
// 1. User/group aliases (like "user1@") resolve to node IPs
|
2025-12-15 14:33:36 +00:00
|
|
|
// 2. Tag aliases (like "tag:server") match nodes based on their tags
|
|
|
|
|
// 3. Filter compilation needs nodes to generate rules
|
2025-10-23 17:57:41 +02:00
|
|
|
//
|
|
|
|
|
// For autogroup:self: return true when nodes change even if the global filter
|
|
|
|
|
// hash didn't change. The global filter is empty for autogroup:self (each node
|
|
|
|
|
// has its own filter), so the hash never changes. But peer relationships DO
|
|
|
|
|
// change when nodes are added/removed, so we must signal this to trigger updates.
|
|
|
|
|
// For global policies: the filter must be recompiled to include the new nodes.
|
2025-12-15 14:33:36 +00:00
|
|
|
if policyChanged {
|
2025-10-23 17:57:41 +02:00
|
|
|
// Recompile filter with the new node list
|
2025-11-30 15:54:16 +01:00
|
|
|
needsUpdate, err := pm.updateLocked()
|
2025-10-23 17:57:41 +02:00
|
|
|
if err != nil {
|
|
|
|
|
return false, err
|
|
|
|
|
}
|
2025-11-30 15:54:16 +01:00
|
|
|
|
|
|
|
|
if !needsUpdate {
|
|
|
|
|
// This ensures fresh filter rules are generated for all nodes
|
|
|
|
|
clear(pm.sshPolicyMap)
|
|
|
|
|
clear(pm.filterRulesMap)
|
2026-04-15 08:27:28 +00:00
|
|
|
clear(pm.matchersForNodeMap)
|
2025-11-30 15:54:16 +01:00
|
|
|
}
|
2025-10-23 17:57:41 +02:00
|
|
|
// Always return true when nodes changed, even if filter hash didn't change
|
|
|
|
|
// (can happen with autogroup:self or when nodes are added but don't affect rules)
|
|
|
|
|
return true, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return false, nil
|
2025-03-10 16:20:29 +01:00
|
|
|
}
|
|
|
|
|
|
2025-12-15 14:33:36 +00:00
|
|
|
func (pm *PolicyManager) nodesHavePolicyAffectingChanges(newNodes views.Slice[types.NodeView]) bool {
|
|
|
|
|
if pm.nodes.Len() != newNodes.Len() {
|
|
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
oldNodes := make(map[types.NodeID]types.NodeView, pm.nodes.Len())
|
|
|
|
|
for _, node := range pm.nodes.All() {
|
|
|
|
|
oldNodes[node.ID()] = node
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for _, newNode := range newNodes.All() {
|
|
|
|
|
oldNode, exists := oldNodes[newNode.ID()]
|
|
|
|
|
if !exists {
|
|
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if newNode.HasPolicyChange(oldNode) {
|
|
|
|
|
return true
|
|
|
|
|
}
|
2026-03-26 06:03:28 +00:00
|
|
|
|
|
|
|
|
// Via grants and autogroup:self compile filter rules per-node
|
|
|
|
|
// that depend on the node's route state (SubnetRoutes, ExitRoutes).
|
|
|
|
|
// Route changes are policy-affecting in this context because they
|
|
|
|
|
// alter which filter rules get generated for the via-designated node.
|
|
|
|
|
if pm.needsPerNodeFilter && newNode.HasNetworkChanges(oldNode) {
|
|
|
|
|
return true
|
|
|
|
|
}
|
2025-12-15 14:33:36 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
|
tags: process tags on registration, simplify policy (#2931)
This PR investigates, adds tests and aims to correctly implement Tailscale's model for how Tags should be accepted, assigned and used to identify nodes in the Tailscale access and ownership model.
When evaluating in Headscale's policy, Tags are now only checked against a nodes "tags" list, which defines the source of truth for all tags for a given node. This simplifies the code for dealing with tags greatly, and should help us have less access bugs related to nodes belonging to tags or users.
A node can either be owned by a user, or a tag.
Next, to ensure the tags list on the node is correctly implemented, we first add tests for every registration scenario and combination of user, pre auth key and pre auth key with tags with the same registration expectation as observed by trying them all with the Tailscale control server. This should ensure that we implement the correct behaviour and that it does not change or break over time.
Lastly, the missing parts of the auth has been added, or changed in the cases where it was wrong. This has in large parts allowed us to delete and simplify a lot of code.
Now, tags can only be changed when a node authenticates or if set via the CLI/API. Tags can only be fully overwritten/replaced and any use of either auth or CLI will replace the current set if different.
A user owned device can be converted to a tagged device, but it cannot be changed back. A tagged device can never remove the last tag either, it has to have a minimum of one.
2025-12-08 18:51:07 +01:00
|
|
|
// NodeCanHaveTag checks if a node can have the specified tag during client-initiated
|
|
|
|
|
// registration or reauth flows (e.g., tailscale up --advertise-tags).
|
|
|
|
|
//
|
2026-05-18 18:35:23 +00:00
|
|
|
// This function is NOT used by the admin API's [state.State.SetNodeTags] - admins can
|
|
|
|
|
// set any existing tag on any node by calling [state.State.SetNodeTags] directly,
|
|
|
|
|
// which bypasses this authorization check.
|
2025-07-05 23:31:13 +02:00
|
|
|
func (pm *PolicyManager) NodeCanHaveTag(node types.NodeView, tag string) bool {
|
tags: process tags on registration, simplify policy (#2931)
This PR investigates, adds tests and aims to correctly implement Tailscale's model for how Tags should be accepted, assigned and used to identify nodes in the Tailscale access and ownership model.
When evaluating in Headscale's policy, Tags are now only checked against a nodes "tags" list, which defines the source of truth for all tags for a given node. This simplifies the code for dealing with tags greatly, and should help us have less access bugs related to nodes belonging to tags or users.
A node can either be owned by a user, or a tag.
Next, to ensure the tags list on the node is correctly implemented, we first add tests for every registration scenario and combination of user, pre auth key and pre auth key with tags with the same registration expectation as observed by trying them all with the Tailscale control server. This should ensure that we implement the correct behaviour and that it does not change or break over time.
Lastly, the missing parts of the auth has been added, or changed in the cases where it was wrong. This has in large parts allowed us to delete and simplify a lot of code.
Now, tags can only be changed when a node authenticates or if set via the CLI/API. Tags can only be fully overwritten/replaced and any use of either auth or CLI will replace the current set if different.
A user owned device can be converted to a tagged device, but it cannot be changed back. A tagged device can never remove the last tag either, it has to have a minimum of one.
2025-12-08 18:51:07 +01:00
|
|
|
if pm == nil || pm.pol == nil {
|
2025-03-10 16:20:29 +01:00
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pm.mu.Lock()
|
|
|
|
|
defer pm.mu.Unlock()
|
|
|
|
|
|
tags: process tags on registration, simplify policy (#2931)
This PR investigates, adds tests and aims to correctly implement Tailscale's model for how Tags should be accepted, assigned and used to identify nodes in the Tailscale access and ownership model.
When evaluating in Headscale's policy, Tags are now only checked against a nodes "tags" list, which defines the source of truth for all tags for a given node. This simplifies the code for dealing with tags greatly, and should help us have less access bugs related to nodes belonging to tags or users.
A node can either be owned by a user, or a tag.
Next, to ensure the tags list on the node is correctly implemented, we first add tests for every registration scenario and combination of user, pre auth key and pre auth key with tags with the same registration expectation as observed by trying them all with the Tailscale control server. This should ensure that we implement the correct behaviour and that it does not change or break over time.
Lastly, the missing parts of the auth has been added, or changed in the cases where it was wrong. This has in large parts allowed us to delete and simplify a lot of code.
Now, tags can only be changed when a node authenticates or if set via the CLI/API. Tags can only be fully overwritten/replaced and any use of either auth or CLI will replace the current set if different.
A user owned device can be converted to a tagged device, but it cannot be changed back. A tagged device can never remove the last tag either, it has to have a minimum of one.
2025-12-08 18:51:07 +01:00
|
|
|
// Check if tag exists in policy
|
|
|
|
|
owners, exists := pm.pol.TagOwners[Tag(tag)]
|
|
|
|
|
if !exists {
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Check if node's owner can assign this tag via the pre-resolved tagOwnerMap.
|
|
|
|
|
// The tagOwnerMap contains IP sets built from resolving TagOwners entries
|
|
|
|
|
// (usernames/groups) to their nodes' IPs, so checking if the node's IP
|
|
|
|
|
// is in the set answers "does this node's owner own this tag?"
|
2025-03-10 16:20:29 +01:00
|
|
|
if ips, ok := pm.tagOwnerMap[Tag(tag)]; ok {
|
2025-04-30 08:54:04 +03:00
|
|
|
if slices.ContainsFunc(node.IPs(), ips.Contains) {
|
|
|
|
|
return true
|
2025-03-10 16:20:29 +01:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
tags: process tags on registration, simplify policy (#2931)
This PR investigates, adds tests and aims to correctly implement Tailscale's model for how Tags should be accepted, assigned and used to identify nodes in the Tailscale access and ownership model.
When evaluating in Headscale's policy, Tags are now only checked against a nodes "tags" list, which defines the source of truth for all tags for a given node. This simplifies the code for dealing with tags greatly, and should help us have less access bugs related to nodes belonging to tags or users.
A node can either be owned by a user, or a tag.
Next, to ensure the tags list on the node is correctly implemented, we first add tests for every registration scenario and combination of user, pre auth key and pre auth key with tags with the same registration expectation as observed by trying them all with the Tailscale control server. This should ensure that we implement the correct behaviour and that it does not change or break over time.
Lastly, the missing parts of the auth has been added, or changed in the cases where it was wrong. This has in large parts allowed us to delete and simplify a lot of code.
Now, tags can only be changed when a node authenticates or if set via the CLI/API. Tags can only be fully overwritten/replaced and any use of either auth or CLI will replace the current set if different.
A user owned device can be converted to a tagged device, but it cannot be changed back. A tagged device can never remove the last tag either, it has to have a minimum of one.
2025-12-08 18:51:07 +01:00
|
|
|
// For new nodes being registered, their IP may not yet be in the tagOwnerMap.
|
|
|
|
|
// Fall back to checking the node's user directly against the TagOwners.
|
|
|
|
|
// This handles the case where a user registers a new node with --advertise-tags.
|
|
|
|
|
if node.User().Valid() {
|
|
|
|
|
for _, owner := range owners {
|
|
|
|
|
if pm.userMatchesOwner(node.User(), owner) {
|
|
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-03-10 16:20:29 +01:00
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
|
tags: process tags on registration, simplify policy (#2931)
This PR investigates, adds tests and aims to correctly implement Tailscale's model for how Tags should be accepted, assigned and used to identify nodes in the Tailscale access and ownership model.
When evaluating in Headscale's policy, Tags are now only checked against a nodes "tags" list, which defines the source of truth for all tags for a given node. This simplifies the code for dealing with tags greatly, and should help us have less access bugs related to nodes belonging to tags or users.
A node can either be owned by a user, or a tag.
Next, to ensure the tags list on the node is correctly implemented, we first add tests for every registration scenario and combination of user, pre auth key and pre auth key with tags with the same registration expectation as observed by trying them all with the Tailscale control server. This should ensure that we implement the correct behaviour and that it does not change or break over time.
Lastly, the missing parts of the auth has been added, or changed in the cases where it was wrong. This has in large parts allowed us to delete and simplify a lot of code.
Now, tags can only be changed when a node authenticates or if set via the CLI/API. Tags can only be fully overwritten/replaced and any use of either auth or CLI will replace the current set if different.
A user owned device can be converted to a tagged device, but it cannot be changed back. A tagged device can never remove the last tag either, it has to have a minimum of one.
2025-12-08 18:51:07 +01:00
|
|
|
// userMatchesOwner checks if a user matches a tag owner entry.
|
2026-05-18 18:35:23 +00:00
|
|
|
// This is used as a fallback when the node's IP is not in the [PolicyManager.tagOwnerMap].
|
tags: process tags on registration, simplify policy (#2931)
This PR investigates, adds tests and aims to correctly implement Tailscale's model for how Tags should be accepted, assigned and used to identify nodes in the Tailscale access and ownership model.
When evaluating in Headscale's policy, Tags are now only checked against a nodes "tags" list, which defines the source of truth for all tags for a given node. This simplifies the code for dealing with tags greatly, and should help us have less access bugs related to nodes belonging to tags or users.
A node can either be owned by a user, or a tag.
Next, to ensure the tags list on the node is correctly implemented, we first add tests for every registration scenario and combination of user, pre auth key and pre auth key with tags with the same registration expectation as observed by trying them all with the Tailscale control server. This should ensure that we implement the correct behaviour and that it does not change or break over time.
Lastly, the missing parts of the auth has been added, or changed in the cases where it was wrong. This has in large parts allowed us to delete and simplify a lot of code.
Now, tags can only be changed when a node authenticates or if set via the CLI/API. Tags can only be fully overwritten/replaced and any use of either auth or CLI will replace the current set if different.
A user owned device can be converted to a tagged device, but it cannot be changed back. A tagged device can never remove the last tag either, it has to have a minimum of one.
2025-12-08 18:51:07 +01:00
|
|
|
func (pm *PolicyManager) userMatchesOwner(user types.UserView, owner Owner) bool {
|
|
|
|
|
switch o := owner.(type) {
|
|
|
|
|
case *Username:
|
|
|
|
|
if o == nil {
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
// Resolve the username to find the user it refers to
|
|
|
|
|
resolvedUser, err := o.resolveUser(pm.users)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return user.ID() == resolvedUser.ID
|
|
|
|
|
|
|
|
|
|
case *Group:
|
|
|
|
|
if o == nil || pm.pol == nil {
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
// Resolve the group to get usernames
|
|
|
|
|
usernames, ok := pm.pol.Groups[*o]
|
|
|
|
|
if !ok {
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
// Check if the user matches any username in the group
|
|
|
|
|
for _, uname := range usernames {
|
|
|
|
|
resolvedUser, err := uname.resolveUser(pm.users)
|
|
|
|
|
if err != nil {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if user.ID() == resolvedUser.ID {
|
|
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return false
|
|
|
|
|
|
|
|
|
|
default:
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// TagExists reports whether the given tag is defined in the policy.
|
|
|
|
|
func (pm *PolicyManager) TagExists(tag string) bool {
|
|
|
|
|
if pm == nil || pm.pol == nil {
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pm.mu.Lock()
|
|
|
|
|
defer pm.mu.Unlock()
|
|
|
|
|
|
|
|
|
|
_, exists := pm.pol.TagOwners[Tag(tag)]
|
|
|
|
|
|
|
|
|
|
return exists
|
|
|
|
|
}
|
|
|
|
|
|
2025-07-05 23:31:13 +02:00
|
|
|
func (pm *PolicyManager) NodeCanApproveRoute(node types.NodeView, route netip.Prefix) bool {
|
2025-03-10 16:20:29 +01:00
|
|
|
if pm == nil {
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-15 08:27:28 +00:00
|
|
|
pm.mu.Lock()
|
|
|
|
|
defer pm.mu.Unlock()
|
|
|
|
|
|
2025-05-10 00:20:04 +03:00
|
|
|
// 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
|
|
|
|
|
}
|
2026-02-06 21:45:32 +01:00
|
|
|
|
2026-04-15 08:27:28 +00:00
|
|
|
return slices.ContainsFunc(node.IPs(), pm.exitSet.Contains)
|
2025-05-10 00:20:04 +03:00
|
|
|
}
|
|
|
|
|
|
2025-03-10 16:20:29 +01:00
|
|
|
// 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
|
2025-07-05 23:30:47 +02:00
|
|
|
if approvers, ok := pm.autoApproveMap[route]; ok {
|
|
|
|
|
canApprove := slices.ContainsFunc(node.IPs(), approvers.Contains)
|
|
|
|
|
if canApprove {
|
2025-04-30 08:54:04 +03:00
|
|
|
return true
|
2025-03-10 16:20:29 +01:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 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) {
|
2025-07-05 23:30:47 +02:00
|
|
|
canApprove := slices.ContainsFunc(node.IPs(), approveAddrs.Contains)
|
|
|
|
|
if canApprove {
|
2025-04-30 08:54:04 +03:00
|
|
|
return true
|
2025-03-10 16:20:29 +01:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
|
policy/v2,state,mapper: implement per-viewer via route steering
Via grants steer routes to specific nodes per viewer. Until now,
all clients saw the same routes for each peer because route
assembly was viewer-independent. This implements per-viewer route
visibility so that via-designated peers serve routes only to
matching viewers, while non-designated peers have those routes
withdrawn.
Add ViaRouteResult type (Include/Exclude prefix lists) and
ViaRoutesForPeer to the PolicyManager interface. The v2
implementation iterates via grants, resolves sources against the
viewer, matches destinations against the peer's advertised routes
(both subnet and exit), and categorizes prefixes by whether the
peer has the via tag.
Add RoutesForPeer to State which composes global primary election,
via Include/Exclude filtering, exit routes, and ACL reduction.
When no via grants exist, it falls back to existing behavior.
Update the mapper to call RoutesForPeer per-peer instead of using
a single route function for all peers. The route function now
returns all routes (subnet + exit), and TailNode filters exit
routes out of the PrimaryRoutes field for HA tracking.
Updates #2180
2026-03-22 20:43:28 +00:00
|
|
|
// ViaRoutesForPeer computes via grant effects for a viewer-peer pair.
|
|
|
|
|
// For each via grant where the viewer matches the source, it checks whether the
|
|
|
|
|
// peer advertises any of the grant's destination prefixes. If the peer has the
|
2026-05-18 18:35:23 +00:00
|
|
|
// via tag, those prefixes go into [types.ViaRouteResult.Include]; otherwise
|
|
|
|
|
// into [types.ViaRouteResult.Exclude].
|
|
|
|
|
//
|
|
|
|
|
// Performance note: this holds [PolicyManager.mu] for its full duration. Hot
|
|
|
|
|
// callers should memoise by (policy-hash, viewer-id) rather than invoking
|
|
|
|
|
// this per-pair.
|
2026-04-15 08:27:28 +00:00
|
|
|
//
|
2026-05-18 18:35:23 +00:00
|
|
|
//nolint:gocyclo // three-pass via-grant resolution (match, primary election, regular-overlap)
|
policy/v2,state,mapper: implement per-viewer via route steering
Via grants steer routes to specific nodes per viewer. Until now,
all clients saw the same routes for each peer because route
assembly was viewer-independent. This implements per-viewer route
visibility so that via-designated peers serve routes only to
matching viewers, while non-designated peers have those routes
withdrawn.
Add ViaRouteResult type (Include/Exclude prefix lists) and
ViaRoutesForPeer to the PolicyManager interface. The v2
implementation iterates via grants, resolves sources against the
viewer, matches destinations against the peer's advertised routes
(both subnet and exit), and categorizes prefixes by whether the
peer has the via tag.
Add RoutesForPeer to State which composes global primary election,
via Include/Exclude filtering, exit routes, and ACL reduction.
When no via grants exist, it falls back to existing behavior.
Update the mapper to call RoutesForPeer per-peer instead of using
a single route function for all peers. The route function now
returns all routes (subnet + exit), and TailNode filters exit
routes out of the PrimaryRoutes field for HA tracking.
Updates #2180
2026-03-22 20:43:28 +00:00
|
|
|
func (pm *PolicyManager) ViaRoutesForPeer(viewer, peer types.NodeView) types.ViaRouteResult {
|
|
|
|
|
var result types.ViaRouteResult
|
|
|
|
|
|
|
|
|
|
if pm == nil || pm.pol == nil {
|
|
|
|
|
return result
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pm.mu.Lock()
|
|
|
|
|
defer pm.mu.Unlock()
|
|
|
|
|
|
|
|
|
|
// Self-steering doesn't apply.
|
|
|
|
|
if viewer.ID() == peer.ID() {
|
|
|
|
|
return result
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
grants := pm.pol.Grants
|
|
|
|
|
for _, acl := range pm.pol.ACLs {
|
|
|
|
|
grants = append(grants, aclToGrants(acl)...)
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-18 09:42:28 +00:00
|
|
|
// Resolve each grant's sources against the viewer once, and each
|
|
|
|
|
// grant's destinations into a flat prefix list. The three passes
|
|
|
|
|
// below reuse both results instead of re-resolving per pass.
|
2026-04-15 08:27:28 +00:00
|
|
|
viewerIPs := viewer.IPs()
|
|
|
|
|
viewerMatchesGrant := make([]bool, len(grants))
|
2026-05-18 09:42:28 +00:00
|
|
|
resolvedDstPrefixes := make([][]netip.Prefix, len(grants))
|
|
|
|
|
grantHasAutoGroupInternet := make([]bool, len(grants))
|
policy/v2,state,mapper: implement per-viewer via route steering
Via grants steer routes to specific nodes per viewer. Until now,
all clients saw the same routes for each peer because route
assembly was viewer-independent. This implements per-viewer route
visibility so that via-designated peers serve routes only to
matching viewers, while non-designated peers have those routes
withdrawn.
Add ViaRouteResult type (Include/Exclude prefix lists) and
ViaRoutesForPeer to the PolicyManager interface. The v2
implementation iterates via grants, resolves sources against the
viewer, matches destinations against the peer's advertised routes
(both subnet and exit), and categorizes prefixes by whether the
peer has the via tag.
Add RoutesForPeer to State which composes global primary election,
via Include/Exclude filtering, exit routes, and ACL reduction.
When no via grants exist, it falls back to existing behavior.
Update the mapper to call RoutesForPeer per-peer instead of using
a single route function for all peers. The route function now
returns all routes (subnet + exit), and TailNode filters exit
routes out of the PrimaryRoutes field for HA tracking.
Updates #2180
2026-03-22 20:43:28 +00:00
|
|
|
|
2026-04-15 08:27:28 +00:00
|
|
|
for i, grant := range grants {
|
policy/v2,state,mapper: implement per-viewer via route steering
Via grants steer routes to specific nodes per viewer. Until now,
all clients saw the same routes for each peer because route
assembly was viewer-independent. This implements per-viewer route
visibility so that via-designated peers serve routes only to
matching viewers, while non-designated peers have those routes
withdrawn.
Add ViaRouteResult type (Include/Exclude prefix lists) and
ViaRoutesForPeer to the PolicyManager interface. The v2
implementation iterates via grants, resolves sources against the
viewer, matches destinations against the peer's advertised routes
(both subnet and exit), and categorizes prefixes by whether the
peer has the via tag.
Add RoutesForPeer to State which composes global primary election,
via Include/Exclude filtering, exit routes, and ACL reduction.
When no via grants exist, it falls back to existing behavior.
Update the mapper to call RoutesForPeer per-peer instead of using
a single route function for all peers. The route function now
returns all routes (subnet + exit), and TailNode filters exit
routes out of the PrimaryRoutes field for HA tracking.
Updates #2180
2026-03-22 20:43:28 +00:00
|
|
|
for _, src := range grant.Sources {
|
|
|
|
|
ips, err := src.Resolve(pm.pol, pm.users, pm.nodes)
|
|
|
|
|
if err != nil {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-15 08:27:28 +00:00
|
|
|
if ips != nil && slices.ContainsFunc(viewerIPs, ips.Contains) {
|
|
|
|
|
viewerMatchesGrant[i] = true
|
policy/v2,state,mapper: implement per-viewer via route steering
Via grants steer routes to specific nodes per viewer. Until now,
all clients saw the same routes for each peer because route
assembly was viewer-independent. This implements per-viewer route
visibility so that via-designated peers serve routes only to
matching viewers, while non-designated peers have those routes
withdrawn.
Add ViaRouteResult type (Include/Exclude prefix lists) and
ViaRoutesForPeer to the PolicyManager interface. The v2
implementation iterates via grants, resolves sources against the
viewer, matches destinations against the peer's advertised routes
(both subnet and exit), and categorizes prefixes by whether the
peer has the via tag.
Add RoutesForPeer to State which composes global primary election,
via Include/Exclude filtering, exit routes, and ACL reduction.
When no via grants exist, it falls back to existing behavior.
Update the mapper to call RoutesForPeer per-peer instead of using
a single route function for all peers. The route function now
returns all routes (subnet + exit), and TailNode filters exit
routes out of the PrimaryRoutes field for HA tracking.
Updates #2180
2026-03-22 20:43:28 +00:00
|
|
|
|
|
|
|
|
break
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-05-18 09:42:28 +00:00
|
|
|
|
|
|
|
|
resolvedDstPrefixes[i], grantHasAutoGroupInternet[i] = resolveViaDestinations(
|
|
|
|
|
pm.pol, pm.users, pm.nodes, grant.Destinations,
|
|
|
|
|
)
|
2026-04-15 08:27:28 +00:00
|
|
|
}
|
policy/v2,state,mapper: implement per-viewer via route steering
Via grants steer routes to specific nodes per viewer. Until now,
all clients saw the same routes for each peer because route
assembly was viewer-independent. This implements per-viewer route
visibility so that via-designated peers serve routes only to
matching viewers, while non-designated peers have those routes
withdrawn.
Add ViaRouteResult type (Include/Exclude prefix lists) and
ViaRoutesForPeer to the PolicyManager interface. The v2
implementation iterates via grants, resolves sources against the
viewer, matches destinations against the peer's advertised routes
(both subnet and exit), and categorizes prefixes by whether the
peer has the via tag.
Add RoutesForPeer to State which composes global primary election,
via Include/Exclude filtering, exit routes, and ACL reduction.
When no via grants exist, it falls back to existing behavior.
Update the mapper to call RoutesForPeer per-peer instead of using
a single route function for all peers. The route function now
returns all routes (subnet + exit), and TailNode filters exit
routes out of the PrimaryRoutes field for HA tracking.
Updates #2180
2026-03-22 20:43:28 +00:00
|
|
|
|
2026-04-15 08:27:28 +00:00
|
|
|
for i, grant := range grants {
|
|
|
|
|
if len(grant.Via) == 0 {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if !viewerMatchesGrant[i] {
|
policy/v2,state,mapper: implement per-viewer via route steering
Via grants steer routes to specific nodes per viewer. Until now,
all clients saw the same routes for each peer because route
assembly was viewer-independent. This implements per-viewer route
visibility so that via-designated peers serve routes only to
matching viewers, while non-designated peers have those routes
withdrawn.
Add ViaRouteResult type (Include/Exclude prefix lists) and
ViaRoutesForPeer to the PolicyManager interface. The v2
implementation iterates via grants, resolves sources against the
viewer, matches destinations against the peer's advertised routes
(both subnet and exit), and categorizes prefixes by whether the
peer has the via tag.
Add RoutesForPeer to State which composes global primary election,
via Include/Exclude filtering, exit routes, and ACL reduction.
When no via grants exist, it falls back to existing behavior.
Update the mapper to call RoutesForPeer per-peer instead of using
a single route function for all peers. The route function now
returns all routes (subnet + exit), and TailNode filters exit
routes out of the PrimaryRoutes field for HA tracking.
Updates #2180
2026-03-22 20:43:28 +00:00
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-18 18:35:23 +00:00
|
|
|
// Filter rules and [tailcfg.Node.AllowedIPs] are different layers.
|
|
|
|
|
// The filter rule carries the dst (the authorisation surface).
|
|
|
|
|
// [tailcfg.Node.AllowedIPs] carries the advertised route (the
|
|
|
|
|
// routing fact the viewer needs to pick this peer). This loop
|
|
|
|
|
// builds the AllowedIPs side, so it emits routes — not dst
|
|
|
|
|
// prefixes.
|
policy/v2,state,mapper: implement per-viewer via route steering
Via grants steer routes to specific nodes per viewer. Until now,
all clients saw the same routes for each peer because route
assembly was viewer-independent. This implements per-viewer route
visibility so that via-designated peers serve routes only to
matching viewers, while non-designated peers have those routes
withdrawn.
Add ViaRouteResult type (Include/Exclude prefix lists) and
ViaRoutesForPeer to the PolicyManager interface. The v2
implementation iterates via grants, resolves sources against the
viewer, matches destinations against the peer's advertised routes
(both subnet and exit), and categorizes prefixes by whether the
peer has the via tag.
Add RoutesForPeer to State which composes global primary election,
via Include/Exclude filtering, exit routes, and ACL reduction.
When no via grants exist, it falls back to existing behavior.
Update the mapper to call RoutesForPeer per-peer instead of using
a single route function for all peers. The route function now
returns all routes (subnet + exit), and TailNode filters exit
routes out of the PrimaryRoutes field for HA tracking.
Updates #2180
2026-03-22 20:43:28 +00:00
|
|
|
peerSubnetRoutes := peer.SubnetRoutes()
|
|
|
|
|
|
|
|
|
|
var matchedPrefixes []netip.Prefix
|
|
|
|
|
|
2026-05-18 09:42:28 +00:00
|
|
|
for _, dstPrefix := range resolvedDstPrefixes[i] {
|
|
|
|
|
for _, route := range peerSubnetRoutes {
|
|
|
|
|
if dstPrefix.Overlaps(route) {
|
|
|
|
|
matchedPrefixes = append(matchedPrefixes, route)
|
policy/v2: surface autogroup:internet via grants on exit nodes
A grant of the form `{src: alice, dst: autogroup:internet, via:
tag:exit1}` was loading without error but stripping every exit node
from alice's view: `tailscale exit-node list` returned "no exit nodes
found".
Two sites skipped autogroup:internet at the compile / steering layer:
compileViaForNode's *AutoGroup arm produced no FilterRule for the
via-tagged exit node, and ViaRoutesForPeer's *AutoGroup arm produced
no Include/Exclude. With pm.needsPerNodeFilter true, the exit node's
matchers were empty, BuildPeerMap could not link source to exit, and
RoutesForPeer's ReduceRoutes stripped 0.0.0.0/0 and ::/0 from
AllowedIPs.
The skip belongs at the wire-format layer (ReduceFilterRules), not at
the compile layer that also feeds internal matchers. Lift
autogroup:internet handling into both *AutoGroup arms with the same
shape used for *Prefix destinations: emit a TheInternet rule on
via-tagged exit advertisers; surface peer.ExitRoutes() in Include
when the peer carries the via tag, Exclude otherwise.
ReduceFilterRules continues to keep the rule on exit-route
advertisers' wire output and strip it elsewhere, preserving SaaS
PacketFilter encoding.
Also drop compileViaForNode's early len(SubnetRoutes)==0 return:
SubnetRoutes excludes exit routes, so the early return pre-empted the
autogroup:internet branch on nodes that only advertise exit routes.
Existing tests pinning the buggy behaviour (TestViaRoutesForPeer
subtests, TestCompileViaGrant case) flipped to the new contract.
Fixes #3233
2026-04-30 11:40:29 +00:00
|
|
|
}
|
policy/v2,state,mapper: implement per-viewer via route steering
Via grants steer routes to specific nodes per viewer. Until now,
all clients saw the same routes for each peer because route
assembly was viewer-independent. This implements per-viewer route
visibility so that via-designated peers serve routes only to
matching viewers, while non-designated peers have those routes
withdrawn.
Add ViaRouteResult type (Include/Exclude prefix lists) and
ViaRoutesForPeer to the PolicyManager interface. The v2
implementation iterates via grants, resolves sources against the
viewer, matches destinations against the peer's advertised routes
(both subnet and exit), and categorizes prefixes by whether the
peer has the via tag.
Add RoutesForPeer to State which composes global primary election,
via Include/Exclude filtering, exit routes, and ACL reduction.
When no via grants exist, it falls back to existing behavior.
Update the mapper to call RoutesForPeer per-peer instead of using
a single route function for all peers. The route function now
returns all routes (subnet + exit), and TailNode filters exit
routes out of the PrimaryRoutes field for HA tracking.
Updates #2180
2026-03-22 20:43:28 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-18 09:42:28 +00:00
|
|
|
// Per-viewer steering for autogroup:internet: a peer advertising
|
|
|
|
|
// approved exit routes is the via-tagged node's analogue of
|
|
|
|
|
// "advertises the destination". The downstream Include/Exclude
|
|
|
|
|
// split below restricts the viewer to exit nodes carrying the
|
|
|
|
|
// via tag.
|
|
|
|
|
if grantHasAutoGroupInternet[i] && peer.IsExitNode() {
|
|
|
|
|
matchedPrefixes = append(matchedPrefixes, peer.ExitRoutes()...)
|
|
|
|
|
}
|
|
|
|
|
|
policy/v2,state,mapper: implement per-viewer via route steering
Via grants steer routes to specific nodes per viewer. Until now,
all clients saw the same routes for each peer because route
assembly was viewer-independent. This implements per-viewer route
visibility so that via-designated peers serve routes only to
matching viewers, while non-designated peers have those routes
withdrawn.
Add ViaRouteResult type (Include/Exclude prefix lists) and
ViaRoutesForPeer to the PolicyManager interface. The v2
implementation iterates via grants, resolves sources against the
viewer, matches destinations against the peer's advertised routes
(both subnet and exit), and categorizes prefixes by whether the
peer has the via tag.
Add RoutesForPeer to State which composes global primary election,
via Include/Exclude filtering, exit routes, and ACL reduction.
When no via grants exist, it falls back to existing behavior.
Update the mapper to call RoutesForPeer per-peer instead of using
a single route function for all peers. The route function now
returns all routes (subnet + exit), and TailNode filters exit
routes out of the PrimaryRoutes field for HA tracking.
Updates #2180
2026-03-22 20:43:28 +00:00
|
|
|
if len(matchedPrefixes) == 0 {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Check if peer has any of the via tags.
|
|
|
|
|
peerHasVia := false
|
|
|
|
|
|
|
|
|
|
for _, viaTag := range grant.Via {
|
|
|
|
|
if peer.HasTag(string(viaTag)) {
|
|
|
|
|
peerHasVia = true
|
|
|
|
|
|
|
|
|
|
break
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if peerHasVia {
|
|
|
|
|
result.Include = append(result.Include, matchedPrefixes...)
|
|
|
|
|
} else {
|
|
|
|
|
result.Exclude = append(result.Exclude, matchedPrefixes...)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-15 08:27:28 +00:00
|
|
|
// Detect prefixes that should fall back to HA primary election
|
|
|
|
|
// rather than per-viewer via steering. Two conditions trigger this:
|
|
|
|
|
//
|
|
|
|
|
// 1. Multi-router via: a via grant's tag matches multiple peers
|
|
|
|
|
// advertising the same prefix.
|
|
|
|
|
// 2. Regular grant overlap: a non-via grant also covers the same
|
|
|
|
|
// prefix for this viewer.
|
|
|
|
|
//
|
|
|
|
|
// When neither condition is met, per-viewer via steering applies.
|
|
|
|
|
if len(result.Include) > 0 || len(result.Exclude) > 0 {
|
|
|
|
|
// Multi-router via election: when a via grant's tag matches
|
|
|
|
|
// multiple peers advertising the same prefix, only the
|
|
|
|
|
// lowest-ID peer (the via-group primary) keeps the prefix in
|
|
|
|
|
// Include. The others move to Exclude. This mirrors HA
|
|
|
|
|
// primary election scoped to the via tag group.
|
|
|
|
|
//
|
2026-05-18 18:35:23 +00:00
|
|
|
// Unlike the global [tailcfg.Node.PrimaryRoutes] election
|
|
|
|
|
// (routes/primary.go), which picks one primary across ALL
|
|
|
|
|
// advertisers of a prefix, this election is scoped to the via tag.
|
|
|
|
|
// Two via grants with different tags (e.g., tag:ha-a vs tag:ha-b)
|
|
|
|
|
// each elect their own winner independently.
|
2026-04-15 08:27:28 +00:00
|
|
|
//
|
|
|
|
|
// Only process via grants where the viewer matches the source,
|
|
|
|
|
// otherwise grants for other viewer groups would incorrectly
|
|
|
|
|
// demote the peer.
|
|
|
|
|
for i, grant := range grants {
|
|
|
|
|
if len(grant.Via) == 0 {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if !viewerMatchesGrant[i] {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-18 09:42:28 +00:00
|
|
|
// Elect per matched route, not per dst — a peer can only
|
|
|
|
|
// be primary for a prefix it actually advertises, and one
|
|
|
|
|
// dst may cover multiple distinct routes.
|
|
|
|
|
for _, dstPrefix := range resolvedDstPrefixes[i] {
|
|
|
|
|
for _, included := range slices.Clone(result.Include) {
|
|
|
|
|
if !dstPrefix.Overlaps(included) {
|
|
|
|
|
continue
|
|
|
|
|
}
|
2026-04-15 08:27:28 +00:00
|
|
|
|
2026-05-18 09:42:28 +00:00
|
|
|
var viaPrimaryID types.NodeID
|
2026-04-15 08:27:28 +00:00
|
|
|
|
2026-05-18 09:42:28 +00:00
|
|
|
for _, viaTag := range grant.Via {
|
|
|
|
|
for _, node := range pm.nodes.All() {
|
|
|
|
|
if node.HasTag(string(viaTag)) &&
|
|
|
|
|
slices.Contains(node.SubnetRoutes(), included) {
|
|
|
|
|
if viaPrimaryID == 0 || node.ID() < viaPrimaryID {
|
|
|
|
|
viaPrimaryID = node.ID()
|
|
|
|
|
}
|
2026-04-15 08:27:28 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-18 09:42:28 +00:00
|
|
|
if viaPrimaryID != 0 && peer.ID() != viaPrimaryID {
|
|
|
|
|
result.Include = slices.DeleteFunc(result.Include, func(p netip.Prefix) bool {
|
|
|
|
|
return p == included
|
|
|
|
|
})
|
|
|
|
|
if !slices.Contains(result.Exclude, included) {
|
|
|
|
|
result.Exclude = append(result.Exclude, included)
|
|
|
|
|
}
|
2026-04-15 08:27:28 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Check for regular (non-via) grants covering the same prefix.
|
|
|
|
|
// When a regular grant also covers a prefix that a via grant
|
|
|
|
|
// included, defer to global HA primary election (UsePrimary).
|
|
|
|
|
// When a regular grant covers a prefix that a via grant excluded
|
2026-05-18 18:35:23 +00:00
|
|
|
// (peer lacks via tag), remove the exclusion so
|
|
|
|
|
// [state.State.RoutesForPeer] can apply normal
|
|
|
|
|
// [policy.ReduceRoutes] + primary logic.
|
2026-04-15 08:27:28 +00:00
|
|
|
for i, grant := range grants {
|
|
|
|
|
if len(grant.Via) > 0 {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if !viewerMatchesGrant[i] {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-18 09:42:28 +00:00
|
|
|
// A non-via grant covering routes that a via grant included
|
|
|
|
|
// defers to global HA primary election. Match by overlap so
|
|
|
|
|
// a broader or narrower regular dst still catches the
|
|
|
|
|
// routes the via grant added to Include.
|
|
|
|
|
for _, dstPrefix := range resolvedDstPrefixes[i] {
|
|
|
|
|
for _, p := range result.Include {
|
|
|
|
|
if dstPrefix.Overlaps(p) &&
|
|
|
|
|
!slices.Contains(result.UsePrimary, p) {
|
|
|
|
|
result.UsePrimary = append(result.UsePrimary, p)
|
2026-04-15 08:27:28 +00:00
|
|
|
}
|
|
|
|
|
}
|
2026-05-18 09:42:28 +00:00
|
|
|
|
|
|
|
|
result.Exclude = slices.DeleteFunc(result.Exclude, dstPrefix.Overlaps)
|
2026-04-15 08:27:28 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
policy/v2,state,mapper: implement per-viewer via route steering
Via grants steer routes to specific nodes per viewer. Until now,
all clients saw the same routes for each peer because route
assembly was viewer-independent. This implements per-viewer route
visibility so that via-designated peers serve routes only to
matching viewers, while non-designated peers have those routes
withdrawn.
Add ViaRouteResult type (Include/Exclude prefix lists) and
ViaRoutesForPeer to the PolicyManager interface. The v2
implementation iterates via grants, resolves sources against the
viewer, matches destinations against the peer's advertised routes
(both subnet and exit), and categorizes prefixes by whether the
peer has the via tag.
Add RoutesForPeer to State which composes global primary election,
via Include/Exclude filtering, exit routes, and ACL reduction.
When no via grants exist, it falls back to existing behavior.
Update the mapper to call RoutesForPeer per-peer instead of using
a single route function for all peers. The route function now
returns all routes (subnet + exit), and TailNode filters exit
routes out of the PrimaryRoutes field for HA tracking.
Updates #2180
2026-03-22 20:43:28 +00:00
|
|
|
return result
|
|
|
|
|
}
|
|
|
|
|
|
2025-03-10 16:20:29 +01:00
|
|
|
func (pm *PolicyManager) Version() int {
|
|
|
|
|
return 2
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (pm *PolicyManager) DebugString() string {
|
2025-05-04 22:52:47 +03:00
|
|
|
if pm == nil {
|
|
|
|
|
return "PolicyManager is not setup"
|
|
|
|
|
}
|
|
|
|
|
|
2025-03-10 16:20:29 +01:00
|
|
|
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))
|
2026-02-06 21:45:32 +01:00
|
|
|
|
2025-03-10 16:20:29 +01:00
|
|
|
for prefix, approveAddrs := range pm.autoApproveMap {
|
|
|
|
|
fmt.Fprintf(&sb, "\t%s:\n", prefix)
|
2026-02-06 21:45:32 +01:00
|
|
|
|
2025-03-10 16:20:29 +01:00
|
|
|
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))
|
2026-02-06 21:45:32 +01:00
|
|
|
|
2025-03-10 16:20:29 +01:00
|
|
|
for prefix, tagOwners := range pm.tagOwnerMap {
|
|
|
|
|
fmt.Fprintf(&sb, "\t%s:\n", prefix)
|
2026-02-06 21:45:32 +01:00
|
|
|
|
2025-03-10 16:20:29 +01:00
|
|
|
for _, iprange := range tagOwners.Ranges() {
|
|
|
|
|
fmt.Fprintf(&sb, "\t\t%s\n", iprange)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
sb.WriteString("\n\n")
|
2026-02-06 21:45:32 +01:00
|
|
|
|
2025-03-10 16:20:29 +01:00
|
|
|
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")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-05-04 22:52:47 +03:00
|
|
|
sb.WriteString("\n\n")
|
|
|
|
|
sb.WriteString("Matchers:\n")
|
|
|
|
|
sb.WriteString("an internal structure used to filter nodes and routes\n")
|
2026-02-06 21:45:32 +01:00
|
|
|
|
2025-05-04 22:52:47 +03:00
|
|
|
for _, match := range pm.matchers {
|
|
|
|
|
sb.WriteString(match.DebugString())
|
|
|
|
|
sb.WriteString("\n")
|
|
|
|
|
}
|
|
|
|
|
|
2025-04-30 08:54:04 +03:00
|
|
|
sb.WriteString("\n\n")
|
2025-07-05 23:31:13 +02:00
|
|
|
sb.WriteString("Nodes:\n")
|
2026-02-06 21:45:32 +01:00
|
|
|
|
2025-07-05 23:31:13 +02:00
|
|
|
for _, node := range pm.nodes.All() {
|
|
|
|
|
sb.WriteString(node.String())
|
|
|
|
|
sb.WriteString("\n")
|
|
|
|
|
}
|
2025-04-30 08:54:04 +03:00
|
|
|
|
2025-03-10 16:20:29 +01:00
|
|
|
return sb.String()
|
|
|
|
|
}
|
2025-10-16 12:59:52 +02:00
|
|
|
|
|
|
|
|
// invalidateAutogroupSelfCache intelligently clears only the cache entries that need to be
|
|
|
|
|
// invalidated when using autogroup:self policies. This is much more efficient than clearing
|
|
|
|
|
// the entire cache.
|
|
|
|
|
func (pm *PolicyManager) invalidateAutogroupSelfCache(oldNodes, newNodes views.Slice[types.NodeView]) {
|
|
|
|
|
// Build maps for efficient lookup
|
|
|
|
|
oldNodeMap := make(map[types.NodeID]types.NodeView)
|
|
|
|
|
for _, node := range oldNodes.All() {
|
|
|
|
|
oldNodeMap[node.ID()] = node
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
newNodeMap := make(map[types.NodeID]types.NodeView)
|
|
|
|
|
for _, node := range newNodes.All() {
|
|
|
|
|
newNodeMap[node.ID()] = node
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-02 14:32:22 +00:00
|
|
|
// Track which users are affected by changes.
|
|
|
|
|
// Tagged nodes don't participate in autogroup:self (identity is tag-based),
|
|
|
|
|
// so we skip them when collecting affected users, except when tag status changes
|
|
|
|
|
// (which affects the user's device set).
|
2026-05-29 10:06:02 +00:00
|
|
|
//
|
|
|
|
|
// Ownership is keyed on TypedUserID (the UserID field), not the User
|
|
|
|
|
// association view: the NodeStore holds nodes by value with User as a
|
|
|
|
|
// *User pointer, and not every write path hydrates that association. A
|
|
|
|
|
// non-tagged node always has UserID set, so it is the reliable owner key.
|
|
|
|
|
affectedUsers := make(map[types.UserID]struct{})
|
2025-10-16 12:59:52 +02:00
|
|
|
|
2026-02-02 14:32:22 +00:00
|
|
|
// Check for removed nodes (only non-tagged nodes affect autogroup:self)
|
2025-10-16 12:59:52 +02:00
|
|
|
for nodeID, oldNode := range oldNodeMap {
|
|
|
|
|
if _, exists := newNodeMap[nodeID]; !exists {
|
2026-02-02 14:32:22 +00:00
|
|
|
if !oldNode.IsTagged() {
|
2026-05-29 10:06:02 +00:00
|
|
|
affectedUsers[oldNode.TypedUserID()] = struct{}{}
|
2026-02-02 14:32:22 +00:00
|
|
|
}
|
2025-10-16 12:59:52 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-02 14:32:22 +00:00
|
|
|
// Check for added nodes (only non-tagged nodes affect autogroup:self)
|
2025-10-16 12:59:52 +02:00
|
|
|
for nodeID, newNode := range newNodeMap {
|
|
|
|
|
if _, exists := oldNodeMap[nodeID]; !exists {
|
2026-02-02 14:32:22 +00:00
|
|
|
if !newNode.IsTagged() {
|
2026-05-29 10:06:02 +00:00
|
|
|
affectedUsers[newNode.TypedUserID()] = struct{}{}
|
2026-02-02 14:32:22 +00:00
|
|
|
}
|
2025-10-16 12:59:52 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Check for modified nodes (user changes, tag changes, IP changes)
|
|
|
|
|
for nodeID, newNode := range newNodeMap {
|
|
|
|
|
if oldNode, exists := oldNodeMap[nodeID]; exists {
|
2026-02-02 14:32:22 +00:00
|
|
|
// Check if tag status changed — this affects the user's autogroup:self device set.
|
|
|
|
|
// Use the non-tagged version to get the user ID safely.
|
|
|
|
|
if oldNode.IsTagged() != newNode.IsTagged() {
|
|
|
|
|
if !oldNode.IsTagged() {
|
|
|
|
|
// Was untagged, now tagged: user lost a device
|
2026-05-29 10:06:02 +00:00
|
|
|
affectedUsers[oldNode.TypedUserID()] = struct{}{}
|
2026-02-02 14:32:22 +00:00
|
|
|
} else {
|
|
|
|
|
// Was tagged, now untagged: user gained a device
|
2026-05-29 10:06:02 +00:00
|
|
|
affectedUsers[newNode.TypedUserID()] = struct{}{}
|
2026-02-02 14:32:22 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
continue
|
2025-10-16 12:59:52 +02:00
|
|
|
}
|
|
|
|
|
|
2026-02-02 14:32:22 +00:00
|
|
|
// Skip tagged nodes for remaining checks — they don't participate in autogroup:self
|
|
|
|
|
if newNode.IsTagged() {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Check if user changed (both versions are non-tagged here)
|
2026-05-29 10:06:02 +00:00
|
|
|
if oldNode.TypedUserID() != newNode.TypedUserID() {
|
|
|
|
|
affectedUsers[oldNode.TypedUserID()] = struct{}{}
|
|
|
|
|
affectedUsers[newNode.TypedUserID()] = struct{}{}
|
2025-10-16 12:59:52 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Check if IPs changed (simple check - could be more sophisticated)
|
|
|
|
|
oldIPs := oldNode.IPs()
|
2026-02-06 21:45:32 +01:00
|
|
|
|
2025-10-16 12:59:52 +02:00
|
|
|
newIPs := newNode.IPs()
|
|
|
|
|
if len(oldIPs) != len(newIPs) {
|
2026-05-29 10:06:02 +00:00
|
|
|
affectedUsers[newNode.TypedUserID()] = struct{}{}
|
2025-10-16 12:59:52 +02:00
|
|
|
} else {
|
|
|
|
|
// Check if any IPs are different
|
|
|
|
|
for i, oldIP := range oldIPs {
|
|
|
|
|
if i >= len(newIPs) || oldIP != newIPs[i] {
|
2026-05-29 10:06:02 +00:00
|
|
|
affectedUsers[newNode.TypedUserID()] = struct{}{}
|
2025-10-16 12:59:52 +02:00
|
|
|
break
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-02 14:32:22 +00:00
|
|
|
// Clear cache entries for affected users only.
|
2025-10-16 12:59:52 +02:00
|
|
|
// For autogroup:self, we need to clear all nodes belonging to affected users
|
2026-02-02 14:32:22 +00:00
|
|
|
// because autogroup:self rules depend on the entire user's device set.
|
2025-10-16 12:59:52 +02:00
|
|
|
for nodeID := range pm.filterRulesMap {
|
|
|
|
|
// Find the user for this cached node
|
2026-05-29 10:06:02 +00:00
|
|
|
var nodeUserID types.UserID
|
2026-02-06 21:45:32 +01:00
|
|
|
|
2025-10-16 12:59:52 +02:00
|
|
|
found := false
|
|
|
|
|
|
|
|
|
|
// Check in new nodes first
|
|
|
|
|
for _, node := range newNodes.All() {
|
|
|
|
|
if node.ID() == nodeID {
|
2026-02-02 14:32:22 +00:00
|
|
|
// Tagged nodes don't participate in autogroup:self,
|
|
|
|
|
// so their cache doesn't need user-based invalidation.
|
|
|
|
|
if node.IsTagged() {
|
|
|
|
|
found = true
|
|
|
|
|
break
|
|
|
|
|
}
|
2026-02-06 21:45:32 +01:00
|
|
|
|
2026-05-29 10:06:02 +00:00
|
|
|
nodeUserID = node.TypedUserID()
|
2025-10-16 12:59:52 +02:00
|
|
|
found = true
|
2026-02-06 21:45:32 +01:00
|
|
|
|
2025-10-16 12:59:52 +02:00
|
|
|
break
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// If not found in new nodes, check old nodes
|
|
|
|
|
if !found {
|
|
|
|
|
for _, node := range oldNodes.All() {
|
|
|
|
|
if node.ID() == nodeID {
|
2026-02-02 14:32:22 +00:00
|
|
|
if node.IsTagged() {
|
|
|
|
|
found = true
|
|
|
|
|
break
|
|
|
|
|
}
|
2026-02-06 21:45:32 +01:00
|
|
|
|
2026-05-29 10:06:02 +00:00
|
|
|
nodeUserID = node.TypedUserID()
|
2025-10-16 12:59:52 +02:00
|
|
|
found = true
|
2026-02-06 21:45:32 +01:00
|
|
|
|
2025-10-16 12:59:52 +02:00
|
|
|
break
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// If we found the user and they're affected, clear this cache entry
|
|
|
|
|
if found {
|
|
|
|
|
if _, affected := affectedUsers[nodeUserID]; affected {
|
|
|
|
|
delete(pm.filterRulesMap, nodeID)
|
2026-04-15 08:27:28 +00:00
|
|
|
delete(pm.matchersForNodeMap, nodeID)
|
2025-10-16 12:59:52 +02:00
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
// Node not found in either old or new list, clear it
|
|
|
|
|
delete(pm.filterRulesMap, nodeID)
|
2026-04-15 08:27:28 +00:00
|
|
|
delete(pm.matchersForNodeMap, nodeID)
|
2025-10-16 12:59:52 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if len(affectedUsers) > 0 {
|
|
|
|
|
log.Debug().
|
|
|
|
|
Int("affected_users", len(affectedUsers)).
|
|
|
|
|
Int("remaining_cache_entries", len(pm.filterRulesMap)).
|
|
|
|
|
Msg("Selectively cleared autogroup:self cache for affected users")
|
|
|
|
|
}
|
|
|
|
|
}
|
2025-10-23 17:57:41 +02:00
|
|
|
|
|
|
|
|
// invalidateNodeCache invalidates cache entries based on what changed.
|
|
|
|
|
func (pm *PolicyManager) invalidateNodeCache(newNodes views.Slice[types.NodeView]) {
|
2026-04-15 08:27:28 +00:00
|
|
|
if pm.needsPerNodeFilter {
|
|
|
|
|
// For autogroup:self or via grants, a node's filter depends
|
|
|
|
|
// on its peers. When any node changes, invalidate affected
|
|
|
|
|
// users' caches.
|
2025-10-23 17:57:41 +02:00
|
|
|
pm.invalidateAutogroupSelfCache(pm.nodes, newNodes)
|
|
|
|
|
} else {
|
2026-04-15 08:27:28 +00:00
|
|
|
// For global policies, a node's filter depends only on its
|
|
|
|
|
// own properties. Only invalidate changed nodes.
|
2025-10-23 17:57:41 +02:00
|
|
|
pm.invalidateGlobalPolicyCache(newNodes)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// invalidateGlobalPolicyCache invalidates only nodes whose properties affecting
|
2026-05-18 18:35:23 +00:00
|
|
|
// [policyutil.ReduceFilterRules] changed. For global policies, each node's filter is independent.
|
2025-10-23 17:57:41 +02:00
|
|
|
func (pm *PolicyManager) invalidateGlobalPolicyCache(newNodes views.Slice[types.NodeView]) {
|
|
|
|
|
oldNodeMap := make(map[types.NodeID]types.NodeView)
|
|
|
|
|
for _, node := range pm.nodes.All() {
|
|
|
|
|
oldNodeMap[node.ID()] = node
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
newNodeMap := make(map[types.NodeID]types.NodeView)
|
|
|
|
|
for _, node := range newNodes.All() {
|
|
|
|
|
newNodeMap[node.ID()] = node
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Invalidate nodes whose properties changed
|
|
|
|
|
for nodeID, newNode := range newNodeMap {
|
|
|
|
|
oldNode, existed := oldNodeMap[nodeID]
|
|
|
|
|
if !existed {
|
|
|
|
|
// New node - no cache entry yet, will be lazily calculated
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if newNode.HasNetworkChanges(oldNode) {
|
|
|
|
|
delete(pm.filterRulesMap, nodeID)
|
2026-04-15 08:27:28 +00:00
|
|
|
delete(pm.matchersForNodeMap, nodeID)
|
2025-10-23 17:57:41 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Remove deleted nodes from cache
|
|
|
|
|
for nodeID := range pm.filterRulesMap {
|
|
|
|
|
if _, exists := newNodeMap[nodeID]; !exists {
|
|
|
|
|
delete(pm.filterRulesMap, nodeID)
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-04-15 08:27:28 +00:00
|
|
|
|
|
|
|
|
for nodeID := range pm.matchersForNodeMap {
|
|
|
|
|
if _, exists := newNodeMap[nodeID]; !exists {
|
|
|
|
|
delete(pm.matchersForNodeMap, nodeID)
|
|
|
|
|
}
|
|
|
|
|
}
|
2025-10-23 17:57:41 +02:00
|
|
|
}
|
tags: process tags on registration, simplify policy (#2931)
This PR investigates, adds tests and aims to correctly implement Tailscale's model for how Tags should be accepted, assigned and used to identify nodes in the Tailscale access and ownership model.
When evaluating in Headscale's policy, Tags are now only checked against a nodes "tags" list, which defines the source of truth for all tags for a given node. This simplifies the code for dealing with tags greatly, and should help us have less access bugs related to nodes belonging to tags or users.
A node can either be owned by a user, or a tag.
Next, to ensure the tags list on the node is correctly implemented, we first add tests for every registration scenario and combination of user, pre auth key and pre auth key with tags with the same registration expectation as observed by trying them all with the Tailscale control server. This should ensure that we implement the correct behaviour and that it does not change or break over time.
Lastly, the missing parts of the auth has been added, or changed in the cases where it was wrong. This has in large parts allowed us to delete and simplify a lot of code.
Now, tags can only be changed when a node authenticates or if set via the CLI/API. Tags can only be fully overwritten/replaced and any use of either auth or CLI will replace the current set if different.
A user owned device can be converted to a tagged device, but it cannot be changed back. A tagged device can never remove the last tag either, it has to have a minimum of one.
2025-12-08 18:51:07 +01:00
|
|
|
|
2026-05-13 14:09:17 +00:00
|
|
|
// flattenTags resolves nested tag-owner references. Cycles
|
|
|
|
|
// (tag:a -> tag:b -> tag:a, or tag:a -> tag:a) drop the cycle-causing
|
|
|
|
|
// edge and contribute no addresses; non-cycle owners on the cycled tags
|
|
|
|
|
// still resolve. Undefined-tag references remain a hard error.
|
tags: process tags on registration, simplify policy (#2931)
This PR investigates, adds tests and aims to correctly implement Tailscale's model for how Tags should be accepted, assigned and used to identify nodes in the Tailscale access and ownership model.
When evaluating in Headscale's policy, Tags are now only checked against a nodes "tags" list, which defines the source of truth for all tags for a given node. This simplifies the code for dealing with tags greatly, and should help us have less access bugs related to nodes belonging to tags or users.
A node can either be owned by a user, or a tag.
Next, to ensure the tags list on the node is correctly implemented, we first add tests for every registration scenario and combination of user, pre auth key and pre auth key with tags with the same registration expectation as observed by trying them all with the Tailscale control server. This should ensure that we implement the correct behaviour and that it does not change or break over time.
Lastly, the missing parts of the auth has been added, or changed in the cases where it was wrong. This has in large parts allowed us to delete and simplify a lot of code.
Now, tags can only be changed when a node authenticates or if set via the CLI/API. Tags can only be fully overwritten/replaced and any use of either auth or CLI will replace the current set if different.
A user owned device can be converted to a tagged device, but it cannot be changed back. A tagged device can never remove the last tag either, it has to have a minimum of one.
2025-12-08 18:51:07 +01:00
|
|
|
func flattenTags(tagOwners TagOwners, tag Tag, visiting map[Tag]bool, chain []Tag) (Owners, error) {
|
|
|
|
|
if visiting[tag] {
|
2026-05-13 14:09:17 +00:00
|
|
|
return nil, nil
|
tags: process tags on registration, simplify policy (#2931)
This PR investigates, adds tests and aims to correctly implement Tailscale's model for how Tags should be accepted, assigned and used to identify nodes in the Tailscale access and ownership model.
When evaluating in Headscale's policy, Tags are now only checked against a nodes "tags" list, which defines the source of truth for all tags for a given node. This simplifies the code for dealing with tags greatly, and should help us have less access bugs related to nodes belonging to tags or users.
A node can either be owned by a user, or a tag.
Next, to ensure the tags list on the node is correctly implemented, we first add tests for every registration scenario and combination of user, pre auth key and pre auth key with tags with the same registration expectation as observed by trying them all with the Tailscale control server. This should ensure that we implement the correct behaviour and that it does not change or break over time.
Lastly, the missing parts of the auth has been added, or changed in the cases where it was wrong. This has in large parts allowed us to delete and simplify a lot of code.
Now, tags can only be changed when a node authenticates or if set via the CLI/API. Tags can only be fully overwritten/replaced and any use of either auth or CLI will replace the current set if different.
A user owned device can be converted to a tagged device, but it cannot be changed back. A tagged device can never remove the last tag either, it has to have a minimum of one.
2025-12-08 18:51:07 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
visiting[tag] = true
|
|
|
|
|
|
|
|
|
|
chain = append(chain, tag)
|
|
|
|
|
defer delete(visiting, tag)
|
|
|
|
|
|
|
|
|
|
var result Owners
|
|
|
|
|
|
|
|
|
|
for _, owner := range tagOwners[tag] {
|
|
|
|
|
switch o := owner.(type) {
|
|
|
|
|
case *Tag:
|
|
|
|
|
if _, ok := tagOwners[*o]; !ok {
|
|
|
|
|
return nil, fmt.Errorf("tag %q %w %q", tag, ErrUndefinedTagReference, *o)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
nested, err := flattenTags(tagOwners, *o, visiting, chain)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
result = append(result, nested...)
|
|
|
|
|
default:
|
|
|
|
|
result = append(result, owner)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return result, nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-18 18:35:23 +00:00
|
|
|
// flattenTagOwners flattens all [TagOwners] by resolving nested tags and detecting cycles.
|
|
|
|
|
// It will return a new [TagOwners] map where all the [Tag] types have been resolved to their underlying [Owners].
|
tags: process tags on registration, simplify policy (#2931)
This PR investigates, adds tests and aims to correctly implement Tailscale's model for how Tags should be accepted, assigned and used to identify nodes in the Tailscale access and ownership model.
When evaluating in Headscale's policy, Tags are now only checked against a nodes "tags" list, which defines the source of truth for all tags for a given node. This simplifies the code for dealing with tags greatly, and should help us have less access bugs related to nodes belonging to tags or users.
A node can either be owned by a user, or a tag.
Next, to ensure the tags list on the node is correctly implemented, we first add tests for every registration scenario and combination of user, pre auth key and pre auth key with tags with the same registration expectation as observed by trying them all with the Tailscale control server. This should ensure that we implement the correct behaviour and that it does not change or break over time.
Lastly, the missing parts of the auth has been added, or changed in the cases where it was wrong. This has in large parts allowed us to delete and simplify a lot of code.
Now, tags can only be changed when a node authenticates or if set via the CLI/API. Tags can only be fully overwritten/replaced and any use of either auth or CLI will replace the current set if different.
A user owned device can be converted to a tagged device, but it cannot be changed back. A tagged device can never remove the last tag either, it has to have a minimum of one.
2025-12-08 18:51:07 +01:00
|
|
|
func flattenTagOwners(tagOwners TagOwners) (TagOwners, error) {
|
|
|
|
|
ret := make(TagOwners)
|
|
|
|
|
|
|
|
|
|
for tag := range tagOwners {
|
|
|
|
|
flattened, err := flattenTags(tagOwners, tag, make(map[Tag]bool), nil)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
slices.SortFunc(flattened, func(a, b Owner) int {
|
|
|
|
|
return cmp.Compare(a.String(), b.String())
|
|
|
|
|
})
|
|
|
|
|
ret[tag] = slices.CompactFunc(flattened, func(a, b Owner) bool {
|
|
|
|
|
return a.String() == b.String()
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return ret, nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-18 18:35:23 +00:00
|
|
|
// resolveTagOwners resolves the [TagOwners] to a map of [Tag] to [netipx.IPSet].
|
|
|
|
|
// The resulting map can be used to quickly look up the IPSet for a given [Tag].
|
|
|
|
|
// It is intended for internal use in a [PolicyManager].
|
tags: process tags on registration, simplify policy (#2931)
This PR investigates, adds tests and aims to correctly implement Tailscale's model for how Tags should be accepted, assigned and used to identify nodes in the Tailscale access and ownership model.
When evaluating in Headscale's policy, Tags are now only checked against a nodes "tags" list, which defines the source of truth for all tags for a given node. This simplifies the code for dealing with tags greatly, and should help us have less access bugs related to nodes belonging to tags or users.
A node can either be owned by a user, or a tag.
Next, to ensure the tags list on the node is correctly implemented, we first add tests for every registration scenario and combination of user, pre auth key and pre auth key with tags with the same registration expectation as observed by trying them all with the Tailscale control server. This should ensure that we implement the correct behaviour and that it does not change or break over time.
Lastly, the missing parts of the auth has been added, or changed in the cases where it was wrong. This has in large parts allowed us to delete and simplify a lot of code.
Now, tags can only be changed when a node authenticates or if set via the CLI/API. Tags can only be fully overwritten/replaced and any use of either auth or CLI will replace the current set if different.
A user owned device can be converted to a tagged device, but it cannot be changed back. A tagged device can never remove the last tag either, it has to have a minimum of one.
2025-12-08 18:51:07 +01:00
|
|
|
func resolveTagOwners(p *Policy, users types.Users, nodes views.Slice[types.NodeView]) (map[Tag]*netipx.IPSet, error) {
|
|
|
|
|
if p == nil {
|
|
|
|
|
return make(map[Tag]*netipx.IPSet), nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if len(p.TagOwners) == 0 {
|
|
|
|
|
return make(map[Tag]*netipx.IPSet), nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
ret := make(map[Tag]*netipx.IPSet)
|
|
|
|
|
|
|
|
|
|
tagOwners, err := flattenTagOwners(p.TagOwners)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for tag, owners := range tagOwners {
|
|
|
|
|
var ips netipx.IPSetBuilder
|
|
|
|
|
|
|
|
|
|
for _, owner := range owners {
|
|
|
|
|
switch o := owner.(type) {
|
|
|
|
|
case *Tag:
|
|
|
|
|
// After flattening, Tag types should not appear in the owners list.
|
|
|
|
|
// If they do, skip them as they represent already-resolved references.
|
|
|
|
|
|
|
|
|
|
case Alias:
|
|
|
|
|
// If it does not resolve, that means the tag is not associated with any IP addresses.
|
|
|
|
|
resolved, _ := o.Resolve(p, users, nodes)
|
2026-03-04 16:16:40 +01:00
|
|
|
if resolved != nil {
|
|
|
|
|
for _, pref := range resolved.Prefixes() {
|
|
|
|
|
ips.AddPrefix(pref)
|
|
|
|
|
}
|
|
|
|
|
}
|
tags: process tags on registration, simplify policy (#2931)
This PR investigates, adds tests and aims to correctly implement Tailscale's model for how Tags should be accepted, assigned and used to identify nodes in the Tailscale access and ownership model.
When evaluating in Headscale's policy, Tags are now only checked against a nodes "tags" list, which defines the source of truth for all tags for a given node. This simplifies the code for dealing with tags greatly, and should help us have less access bugs related to nodes belonging to tags or users.
A node can either be owned by a user, or a tag.
Next, to ensure the tags list on the node is correctly implemented, we first add tests for every registration scenario and combination of user, pre auth key and pre auth key with tags with the same registration expectation as observed by trying them all with the Tailscale control server. This should ensure that we implement the correct behaviour and that it does not change or break over time.
Lastly, the missing parts of the auth has been added, or changed in the cases where it was wrong. This has in large parts allowed us to delete and simplify a lot of code.
Now, tags can only be changed when a node authenticates or if set via the CLI/API. Tags can only be fully overwritten/replaced and any use of either auth or CLI will replace the current set if different.
A user owned device can be converted to a tagged device, but it cannot be changed back. A tagged device can never remove the last tag either, it has to have a minimum of one.
2025-12-08 18:51:07 +01:00
|
|
|
|
|
|
|
|
default:
|
|
|
|
|
// Should never happen - after flattening, all owners should be Alias types
|
|
|
|
|
return nil, fmt.Errorf("%w: %v", ErrInvalidTagOwner, owner)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
ipSet, err := ips.IPSet()
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
ret[tag] = ipSet
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return ret, nil
|
|
|
|
|
}
|
policy/v2: parse, validate, and compile nodeAttrs
ACL policies now accept a top-level nodeAttrs block. Each entry hands
a list of tailcfg node capabilities to every node matching target.
Accepted target forms are the same as acls.src and grants.src: users,
groups, tags, hosts, prefixes, autogroup:member, autogroup:tagged,
and *. autogroup:self, autogroup:internet, and autogroup:danger-all
are rejected at validate time because none describes a stable
identity set a node-level attribute can attach to.
NodeAttrGrant carries Targets, Attrs, and IPPool. IPPool is parsed
but rejected at validate time -- the allocator that consumes it is
not yet implemented. nodeAttrUnsupportedCaps lists caps SaaS accepts
that headscale cannot act on (funnel today) and rejects them with a
tracking-issue link in the error.
compileNodeAttrs resolves each entry's targets, then maps every
targeted node to a tailcfg.NodeCapMap of the entry's attrs. Per-node
IPs are cached once per call so the inner attr loop is O(grants)
instead of O(grants * nodes) IP allocations.
PolicyManager grows NodeCapMap (per-node), NodeCapMaps (snapshot for
batched callers), and NodesWithChangedCapMap (drain buffer for the
self-broadcast diff). refreshNodeAttrsLocked appends to the drain
rather than overwriting so a SetUsers/SetNodes between SetPolicy and
the drain cannot lose the policy-reload diff.
2026-05-11 14:46:38 +00:00
|
|
|
|
|
|
|
|
// refreshNodeAttrsLocked recompiles the per-node nodeAttrs CapMap and
|
|
|
|
|
// appends the IDs whose CapMap differs from the previous snapshot
|
|
|
|
|
// (including newly-targeted nodes and nodes that lost all attrs) to
|
|
|
|
|
// pm.nodeAttrsChanged. Append, not overwrite: a concurrent
|
|
|
|
|
// SetUsers/SetNodes between SetPolicy and a NodesWithChangedCapMap
|
|
|
|
|
// drain cannot clobber the policy-reload diff.
|
|
|
|
|
//
|
|
|
|
|
// Caller must hold pm.mu.
|
|
|
|
|
func (pm *PolicyManager) refreshNodeAttrsLocked() error {
|
|
|
|
|
// Fast path for the common steady-state shape: tailnet has no
|
|
|
|
|
// nodeAttrs entries and never had any. Skip the compile + per-node
|
|
|
|
|
// hash walk entirely. As soon as the operator adds a nodeAttrs
|
|
|
|
|
// entry pm.nodeAttrsHashes becomes non-empty and the gate opens.
|
|
|
|
|
if pm.pol != nil &&
|
|
|
|
|
len(pm.pol.NodeAttrs) == 0 &&
|
|
|
|
|
!pm.pol.RandomizeClientPort &&
|
|
|
|
|
len(pm.nodeAttrsHashes) == 0 {
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
newMap, err := pm.pol.compileNodeAttrs(pm.users, pm.nodes)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return fmt.Errorf("compiling nodeAttrs: %w", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
newHashes := make(map[types.NodeID]deephash.Sum, len(newMap))
|
|
|
|
|
for id, capMap := range newMap {
|
|
|
|
|
newHashes[id] = deephash.Hash(&capMap)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Walk the union of old and new node IDs and emit the delta.
|
|
|
|
|
seen := make(map[types.NodeID]struct{}, len(newHashes)+len(pm.nodeAttrsHashes))
|
|
|
|
|
|
|
|
|
|
var changed []types.NodeID
|
|
|
|
|
|
|
|
|
|
for id, h := range newHashes {
|
|
|
|
|
seen[id] = struct{}{}
|
|
|
|
|
if pm.nodeAttrsHashes[id] != h {
|
|
|
|
|
changed = append(changed, id)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for id := range pm.nodeAttrsHashes {
|
|
|
|
|
if _, ok := seen[id]; ok {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
// Node lost all nodeAttrs since the last update.
|
|
|
|
|
changed = append(changed, id)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pm.nodeAttrsMap = newMap
|
|
|
|
|
pm.nodeAttrsHashes = newHashes
|
|
|
|
|
pm.nodeAttrsChanged = append(pm.nodeAttrsChanged, changed...)
|
|
|
|
|
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// NodeCapMap returns the policy-derived CapMap for the given node, or
|
|
|
|
|
// nil when the node has no nodeAttrs entries that target it. The
|
|
|
|
|
// returned map is a defensive clone — caller mutations cannot reach
|
|
|
|
|
// the manager-owned cache.
|
|
|
|
|
func (pm *PolicyManager) NodeCapMap(id types.NodeID) tailcfg.NodeCapMap {
|
|
|
|
|
if pm == nil {
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pm.mu.Lock()
|
|
|
|
|
defer pm.mu.Unlock()
|
|
|
|
|
|
|
|
|
|
src := pm.nodeAttrsMap[id]
|
|
|
|
|
if len(src) == 0 {
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
out := make(tailcfg.NodeCapMap, len(src))
|
|
|
|
|
maps.Copy(out, src)
|
|
|
|
|
|
|
|
|
|
return out
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// NodeCapMaps returns a snapshot of the per-node policy CapMap. The
|
|
|
|
|
// mapper calls this once per request to amortise lock acquisitions
|
|
|
|
|
// over a peer-loop instead of taking the lock per peer. The returned
|
|
|
|
|
// map is a fresh container; the inner [tailcfg.NodeCapMap] values are
|
|
|
|
|
// shared with the manager and must be treated as read-only.
|
|
|
|
|
func (pm *PolicyManager) NodeCapMaps() map[types.NodeID]tailcfg.NodeCapMap {
|
|
|
|
|
if pm == nil {
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pm.mu.Lock()
|
|
|
|
|
defer pm.mu.Unlock()
|
|
|
|
|
|
|
|
|
|
out := make(map[types.NodeID]tailcfg.NodeCapMap, len(pm.nodeAttrsMap))
|
|
|
|
|
maps.Copy(out, pm.nodeAttrsMap)
|
|
|
|
|
|
|
|
|
|
return out
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// NodesWithChangedCapMap returns the IDs of nodes whose nodeAttrs
|
2026-05-18 18:35:23 +00:00
|
|
|
// CapMap shifted across one or more [PolicyManager.updateLocked] calls
|
|
|
|
|
// since the last drain. The buffer drains on return. The mapper calls
|
|
|
|
|
// this once per [state.State.ReloadPolicy] to decide which nodes need
|
|
|
|
|
// a [change.SelfUpdate].
|
policy/v2: parse, validate, and compile nodeAttrs
ACL policies now accept a top-level nodeAttrs block. Each entry hands
a list of tailcfg node capabilities to every node matching target.
Accepted target forms are the same as acls.src and grants.src: users,
groups, tags, hosts, prefixes, autogroup:member, autogroup:tagged,
and *. autogroup:self, autogroup:internet, and autogroup:danger-all
are rejected at validate time because none describes a stable
identity set a node-level attribute can attach to.
NodeAttrGrant carries Targets, Attrs, and IPPool. IPPool is parsed
but rejected at validate time -- the allocator that consumes it is
not yet implemented. nodeAttrUnsupportedCaps lists caps SaaS accepts
that headscale cannot act on (funnel today) and rejects them with a
tracking-issue link in the error.
compileNodeAttrs resolves each entry's targets, then maps every
targeted node to a tailcfg.NodeCapMap of the entry's attrs. Per-node
IPs are cached once per call so the inner attr loop is O(grants)
instead of O(grants * nodes) IP allocations.
PolicyManager grows NodeCapMap (per-node), NodeCapMaps (snapshot for
batched callers), and NodesWithChangedCapMap (drain buffer for the
self-broadcast diff). refreshNodeAttrsLocked appends to the drain
rather than overwriting so a SetUsers/SetNodes between SetPolicy and
the drain cannot lose the policy-reload diff.
2026-05-11 14:46:38 +00:00
|
|
|
//
|
2026-05-18 18:35:23 +00:00
|
|
|
// [PolicyManager.refreshNodeAttrsLocked] APPENDS to the buffer; the drain
|
|
|
|
|
// returns the union of every change since the previous read. A concurrent
|
|
|
|
|
// [PolicyManager.SetUsers]/[PolicyManager.SetNodes] between
|
|
|
|
|
// [PolicyManager.SetPolicy] and a drain cannot silently lose the
|
|
|
|
|
// policy-reload diff.
|
policy/v2: parse, validate, and compile nodeAttrs
ACL policies now accept a top-level nodeAttrs block. Each entry hands
a list of tailcfg node capabilities to every node matching target.
Accepted target forms are the same as acls.src and grants.src: users,
groups, tags, hosts, prefixes, autogroup:member, autogroup:tagged,
and *. autogroup:self, autogroup:internet, and autogroup:danger-all
are rejected at validate time because none describes a stable
identity set a node-level attribute can attach to.
NodeAttrGrant carries Targets, Attrs, and IPPool. IPPool is parsed
but rejected at validate time -- the allocator that consumes it is
not yet implemented. nodeAttrUnsupportedCaps lists caps SaaS accepts
that headscale cannot act on (funnel today) and rejects them with a
tracking-issue link in the error.
compileNodeAttrs resolves each entry's targets, then maps every
targeted node to a tailcfg.NodeCapMap of the entry's attrs. Per-node
IPs are cached once per call so the inner attr loop is O(grants)
instead of O(grants * nodes) IP allocations.
PolicyManager grows NodeCapMap (per-node), NodeCapMaps (snapshot for
batched callers), and NodesWithChangedCapMap (drain buffer for the
self-broadcast diff). refreshNodeAttrsLocked appends to the drain
rather than overwriting so a SetUsers/SetNodes between SetPolicy and
the drain cannot lose the policy-reload diff.
2026-05-11 14:46:38 +00:00
|
|
|
func (pm *PolicyManager) NodesWithChangedCapMap() []types.NodeID {
|
|
|
|
|
if pm == nil {
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pm.mu.Lock()
|
|
|
|
|
defer pm.mu.Unlock()
|
|
|
|
|
|
|
|
|
|
out := pm.nodeAttrsChanged
|
|
|
|
|
pm.nodeAttrsChanged = nil
|
|
|
|
|
|
|
|
|
|
return out
|
|
|
|
|
}
|