mirror of
https://github.com/juanfont/headscale.git
synced 2026-04-28 03:27:15 +02:00
types: consider subnet routes as source identity in ACL matching
CanAccess now treats a node's advertised subnet routes as part of its source identity, so an ACL granting the subnet-owner as source lets traffic from the subnet through. Matches SaaS semantics. Updates #3157
This commit is contained in:
@@ -40,11 +40,19 @@ var (
|
||||
type RouteFunc func(id NodeID) []netip.Prefix
|
||||
|
||||
// ViaRouteResult describes via grant effects for a viewer-peer pair.
|
||||
// UsePrimary is always a subset of Include: it marks which included
|
||||
// prefixes must additionally defer to HA primary election.
|
||||
type ViaRouteResult struct {
|
||||
// Include contains prefixes this peer should serve to this viewer (via-designated).
|
||||
Include []netip.Prefix
|
||||
// Exclude contains prefixes steered to OTHER peers (suppress from global primary).
|
||||
Exclude []netip.Prefix
|
||||
// UsePrimary contains prefixes from Include where a regular
|
||||
// (non-via) grant also covers the prefix. In these cases HA
|
||||
// primary election wins — only the primary router should get
|
||||
// the route in AllowedIPs. When a prefix is NOT in UsePrimary,
|
||||
// per-viewer via steering applies.
|
||||
UsePrimary []netip.Prefix
|
||||
}
|
||||
|
||||
type (
|
||||
@@ -190,6 +198,9 @@ func (node *Node) IsEphemeral() bool {
|
||||
return node.AuthKey != nil && node.AuthKey.Ephemeral
|
||||
}
|
||||
|
||||
// IPs returns the node's allocated Tailscale addresses. Order is
|
||||
// deterministic: IPv4 (if allocated) first, IPv6 second. At most one
|
||||
// of each family.
|
||||
func (node *Node) IPs() []netip.Addr {
|
||||
var ret []netip.Addr
|
||||
|
||||
@@ -267,9 +278,10 @@ func (node *Node) Prefixes() []netip.Prefix {
|
||||
return addrs
|
||||
}
|
||||
|
||||
// ExitRoutes returns a list of both exit routes if the
|
||||
// node has any exit routes enabled.
|
||||
// If none are enabled, it will return nil.
|
||||
// ExitRoutes returns the node's approved exit routes (0.0.0.0/0
|
||||
// and/or ::/0). Consumed unconditionally by RoutesForPeer when the
|
||||
// viewer uses an exit node; excluded from CanAccessRoute which only
|
||||
// handles non-exit routing.
|
||||
func (node *Node) ExitRoutes() []netip.Prefix {
|
||||
var routes []netip.Prefix
|
||||
|
||||
@@ -282,6 +294,9 @@ func (node *Node) ExitRoutes() []netip.Prefix {
|
||||
return routes
|
||||
}
|
||||
|
||||
// IsExitNode reports whether the node has any approved exit routes.
|
||||
// Approval is required: an advertised-but-unapproved exit route does
|
||||
// not make the node an exit node (fix for #3169).
|
||||
func (node *Node) IsExitNode() bool {
|
||||
return len(node.ExitRoutes()) > 0
|
||||
}
|
||||
@@ -319,27 +334,39 @@ func (node *Node) AppendToIPSet(build *netipx.IPSetBuilder) {
|
||||
}
|
||||
}
|
||||
|
||||
// CanAccess reports whether node may reach node2 under the given
|
||||
// matchers. A node owns two source identities for ACL purposes:
|
||||
// - its own IPs (regular peer membership)
|
||||
// - any approved subnet routes it advertises (subnet-router-as-src,
|
||||
// used for subnet-to-subnet ACLs — issue #3157)
|
||||
//
|
||||
// Either identity matching a rule's src — combined with the dst
|
||||
// matching node2's IPs, node2's approved subnet routes, or "the
|
||||
// internet" when node2 is an exit node — grants access.
|
||||
func (node *Node) CanAccess(matchers []matcher.Match, node2 *Node) bool {
|
||||
src := node.IPs()
|
||||
allowedIPs := node2.IPs()
|
||||
srcRoutes := node.SubnetRoutes()
|
||||
dstRoutes := node2.SubnetRoutes()
|
||||
dstIsExit := node2.IsExitNode()
|
||||
|
||||
for _, matcher := range matchers {
|
||||
if !matcher.SrcsContainsIPs(src...) {
|
||||
for _, m := range matchers {
|
||||
srcMatchesIP := m.SrcsContainsIPs(src...)
|
||||
srcMatchesRoutes := len(srcRoutes) > 0 && m.SrcsOverlapsPrefixes(srcRoutes...)
|
||||
|
||||
if !srcMatchesIP && !srcMatchesRoutes {
|
||||
continue
|
||||
}
|
||||
|
||||
if matcher.DestsContainsIP(allowedIPs...) {
|
||||
if m.DestsContainsIP(allowedIPs...) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check if the node has access to routes that might be part of a
|
||||
// smaller subnet that is served from node2 as a subnet router.
|
||||
if matcher.DestsOverlapsPrefixes(node2.SubnetRoutes()...) {
|
||||
if len(dstRoutes) > 0 && m.DestsOverlapsPrefixes(dstRoutes...) {
|
||||
return true
|
||||
}
|
||||
|
||||
// If the dst is "the internet" and node2 is an exit node, allow access.
|
||||
if matcher.DestsIsTheInternet() && node2.IsExitNode() {
|
||||
if dstIsExit && m.DestsIsTheInternet() {
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -347,8 +374,25 @@ func (node *Node) CanAccess(matchers []matcher.Match, node2 *Node) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// CanAccessRoute determines whether a specific route prefix should be
|
||||
// visible to this node based on the given matchers.
|
||||
//
|
||||
// Unlike CanAccess, this function intentionally does NOT check
|
||||
// DestsIsTheInternet(). Exit routes (0.0.0.0/0, ::/0) are handled by
|
||||
// RoutesForPeer (state.go) which adds them unconditionally from
|
||||
// ExitRoutes(), not through ACL-based route filtering. The
|
||||
// DestsIsTheInternet check in CanAccess exists solely for peer
|
||||
// visibility determination (should two nodes see each other), which
|
||||
// is a separate concern from route prefix authorization.
|
||||
//
|
||||
// Additionally, autogroup:internet is explicitly skipped during filter
|
||||
// rule compilation (filter.go), so no matchers ever contain "the
|
||||
// internet" from internet-targeted ACLs. Wildcard "*" dests produce
|
||||
// matchers where DestsOverlapsPrefixes(0.0.0.0/0) already returns
|
||||
// true, so the check would be redundant for that case.
|
||||
func (node *Node) CanAccessRoute(matchers []matcher.Match, route netip.Prefix) bool {
|
||||
src := node.IPs()
|
||||
subnetRoutes := node.SubnetRoutes()
|
||||
|
||||
for _, matcher := range matchers {
|
||||
if matcher.SrcsContainsIPs(src...) && matcher.DestsOverlapsPrefixes(route) {
|
||||
@@ -358,6 +402,25 @@ func (node *Node) CanAccessRoute(matchers []matcher.Match, route netip.Prefix) b
|
||||
if matcher.SrcsOverlapsPrefixes(route) && matcher.DestsContainsIP(src...) {
|
||||
return true
|
||||
}
|
||||
|
||||
// A subnet router acts on behalf of its advertised subnets.
|
||||
// If the node's approved subnet routes overlap the source set
|
||||
// and the route overlaps the destination set, the router needs
|
||||
// this route to forward traffic from its local subnet.
|
||||
if len(subnetRoutes) > 0 {
|
||||
if matcher.SrcsOverlapsPrefixes(subnetRoutes...) &&
|
||||
matcher.DestsOverlapsPrefixes(route) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Reverse: traffic from the route's subnet is destined for
|
||||
// this node's subnets; the router needs the route for return
|
||||
// traffic.
|
||||
if matcher.SrcsOverlapsPrefixes(route) &&
|
||||
matcher.DestsOverlapsPrefixes(subnetRoutes...) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
@@ -465,8 +528,10 @@ func (node *Node) GetFQDN(baseDomain string) (string, error) {
|
||||
return hostname, nil
|
||||
}
|
||||
|
||||
// AnnouncedRoutes returns the list of routes that the node announces.
|
||||
// It should be used instead of checking Hostinfo.RoutableIPs directly.
|
||||
// AnnouncedRoutes returns the list of routes the node announces, as
|
||||
// reported by the client in Hostinfo.RoutableIPs. Announcement alone
|
||||
// does not grant visibility — see SubnetRoutes for approval-gated
|
||||
// access.
|
||||
func (node *Node) AnnouncedRoutes() []netip.Prefix {
|
||||
if node.Hostinfo == nil {
|
||||
return nil
|
||||
@@ -476,7 +541,8 @@ func (node *Node) AnnouncedRoutes() []netip.Prefix {
|
||||
}
|
||||
|
||||
// SubnetRoutes returns the list of routes (excluding exit routes) that the node
|
||||
// announces and are approved.
|
||||
// announces and are approved. Also used by CanAccess and CanAccessRoute as part
|
||||
// of the subnet-router-as-source identity (issue #3157).
|
||||
//
|
||||
// IMPORTANT: This method is used for internal data structures and should NOT be
|
||||
// used for the gRPC Proto conversion. For Proto, SubnetRoutes must be populated
|
||||
@@ -610,12 +676,8 @@ func EndpointsChanged(oldEndpoints, newEndpoints []netip.AddrPort) bool {
|
||||
newCopy := slices.Clone(newEndpoints)
|
||||
|
||||
// Sort both slices to enable order-independent comparison
|
||||
slices.SortFunc(oldCopy, func(a, b netip.AddrPort) int {
|
||||
return a.Compare(b)
|
||||
})
|
||||
slices.SortFunc(newCopy, func(a, b netip.AddrPort) int {
|
||||
return a.Compare(b)
|
||||
})
|
||||
slices.SortFunc(oldCopy, netip.AddrPort.Compare)
|
||||
slices.SortFunc(newCopy, netip.AddrPort.Compare)
|
||||
|
||||
return !slices.Equal(oldCopy, newCopy)
|
||||
}
|
||||
@@ -1041,28 +1103,49 @@ func (nv NodeView) IPsAsString() []string {
|
||||
|
||||
// HasNetworkChanges checks if the node has network-related changes.
|
||||
// Returns true if IPs, announced routes, or approved routes changed.
|
||||
// This is primarily used for policy cache invalidation.
|
||||
// This is primarily used for policy cache invalidation. Route slices
|
||||
// are compared order-insensitively since clients may re-advertise the
|
||||
// same routes in a different order.
|
||||
func (nv NodeView) HasNetworkChanges(other NodeView) bool {
|
||||
if !slices.Equal(nv.IPs(), other.IPs()) {
|
||||
return true
|
||||
}
|
||||
|
||||
if !slices.Equal(nv.AnnouncedRoutes(), other.AnnouncedRoutes()) {
|
||||
if !equalPrefixesUnordered(nv.AnnouncedRoutes(), other.AnnouncedRoutes()) {
|
||||
return true
|
||||
}
|
||||
|
||||
if !slices.Equal(nv.SubnetRoutes(), other.SubnetRoutes()) {
|
||||
if !equalPrefixesUnordered(nv.SubnetRoutes(), other.SubnetRoutes()) {
|
||||
return true
|
||||
}
|
||||
|
||||
if !slices.Equal(nv.ExitRoutes(), other.ExitRoutes()) {
|
||||
if !equalPrefixesUnordered(nv.ExitRoutes(), other.ExitRoutes()) {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// HasPolicyChange reports whether the node has changes that affect policy evaluation.
|
||||
// equalPrefixesUnordered reports whether a and b contain the same
|
||||
// prefixes, order-independent. Inputs are cloned before sorting so
|
||||
// callers' slices are not mutated.
|
||||
func equalPrefixesUnordered(a, b []netip.Prefix) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
|
||||
ac := slices.Clone(a)
|
||||
bc := slices.Clone(b)
|
||||
|
||||
slices.SortFunc(ac, netip.Prefix.Compare)
|
||||
slices.SortFunc(bc, netip.Prefix.Compare)
|
||||
|
||||
return slices.Equal(ac, bc)
|
||||
}
|
||||
|
||||
// HasPolicyChange reports whether the node has changes that affect
|
||||
// policy evaluation. Includes approved subnet routes because they act
|
||||
// as source identity in CanAccess for subnet-to-subnet ACLs (#3157).
|
||||
func (nv NodeView) HasPolicyChange(other NodeView) bool {
|
||||
if nv.UserID() != other.UserID() {
|
||||
return true
|
||||
@@ -1076,6 +1159,10 @@ func (nv NodeView) HasPolicyChange(other NodeView) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
if !equalPrefixesUnordered(nv.SubnetRoutes(), other.SubnetRoutes()) {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user