mirror of
https://github.com/juanfont/headscale
synced 2026-04-25 17:15:33 +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:
@@ -4,7 +4,6 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/google/go-cmp/cmp"
|
"github.com/google/go-cmp/cmp"
|
||||||
"github.com/juanfont/headscale/hscontrol/policy/matcher"
|
"github.com/juanfont/headscale/hscontrol/policy/matcher"
|
||||||
@@ -768,6 +767,65 @@ func TestReduceNodes(t *testing.T) {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
// Subnet-to-subnet: routers must see each other when ACL
|
||||||
|
// uses only subnet CIDRs. Issue #3157.
|
||||||
|
{
|
||||||
|
name: "subnet-to-subnet-routers-see-each-other-3157",
|
||||||
|
args: args{
|
||||||
|
nodes: []*types.Node{
|
||||||
|
{
|
||||||
|
ID: 1,
|
||||||
|
IPv4: ap("100.64.0.1"),
|
||||||
|
Hostname: "router-a",
|
||||||
|
User: &types.User{Name: "router-a"},
|
||||||
|
Hostinfo: &tailcfg.Hostinfo{
|
||||||
|
RoutableIPs: []netip.Prefix{netip.MustParsePrefix("10.88.8.0/24")},
|
||||||
|
},
|
||||||
|
ApprovedRoutes: []netip.Prefix{netip.MustParsePrefix("10.88.8.0/24")},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: 2,
|
||||||
|
IPv4: ap("100.64.0.2"),
|
||||||
|
Hostname: "router-b",
|
||||||
|
User: &types.User{Name: "router-b"},
|
||||||
|
Hostinfo: &tailcfg.Hostinfo{
|
||||||
|
RoutableIPs: []netip.Prefix{netip.MustParsePrefix("10.99.9.0/24")},
|
||||||
|
},
|
||||||
|
ApprovedRoutes: []netip.Prefix{netip.MustParsePrefix("10.99.9.0/24")},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
rules: []tailcfg.FilterRule{
|
||||||
|
{
|
||||||
|
SrcIPs: []string{"10.88.8.0/24"},
|
||||||
|
DstPorts: []tailcfg.NetPortRange{
|
||||||
|
{IP: "10.99.9.0/24", Ports: tailcfg.PortRangeAny},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
node: &types.Node{
|
||||||
|
ID: 1,
|
||||||
|
IPv4: ap("100.64.0.1"),
|
||||||
|
Hostname: "router-a",
|
||||||
|
User: &types.User{Name: "router-a"},
|
||||||
|
Hostinfo: &tailcfg.Hostinfo{
|
||||||
|
RoutableIPs: []netip.Prefix{netip.MustParsePrefix("10.88.8.0/24")},
|
||||||
|
},
|
||||||
|
ApprovedRoutes: []netip.Prefix{netip.MustParsePrefix("10.88.8.0/24")},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
want: []*types.Node{
|
||||||
|
{
|
||||||
|
ID: 2,
|
||||||
|
IPv4: ap("100.64.0.2"),
|
||||||
|
Hostname: "router-b",
|
||||||
|
User: &types.User{Name: "router-b"},
|
||||||
|
Hostinfo: &tailcfg.Hostinfo{
|
||||||
|
RoutableIPs: []netip.Prefix{netip.MustParsePrefix("10.99.9.0/24")},
|
||||||
|
},
|
||||||
|
ApprovedRoutes: []netip.Prefix{netip.MustParsePrefix("10.99.9.0/24")},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
@@ -1205,11 +1263,11 @@ func TestSSHPolicyRules(t *testing.T) {
|
|||||||
},
|
},
|
||||||
Action: &tailcfg.SSHAction{
|
Action: &tailcfg.SSHAction{
|
||||||
Accept: false,
|
Accept: false,
|
||||||
SessionDuration: 24 * time.Hour,
|
SessionDuration: 0,
|
||||||
HoldAndDelegate: "unused-url/machine/ssh/action/from/$SRC_NODE_ID/to/$DST_NODE_ID?ssh_user=$SSH_USER&local_user=$LOCAL_USER",
|
HoldAndDelegate: "unused-url/machine/ssh/action/$SRC_NODE_ID/to/$DST_NODE_ID?local_user=$LOCAL_USER",
|
||||||
AllowAgentForwarding: true,
|
AllowAgentForwarding: false,
|
||||||
AllowLocalPortForwarding: true,
|
AllowLocalPortForwarding: false,
|
||||||
AllowRemotePortForwarding: true,
|
AllowRemotePortForwarding: false,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}},
|
}},
|
||||||
@@ -2230,6 +2288,127 @@ func TestReduceRoutes(t *testing.T) {
|
|||||||
netip.MustParsePrefix("192.168.1.0/14"),
|
netip.MustParsePrefix("192.168.1.0/14"),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
// Subnet-to-subnet tests for issue #3157.
|
||||||
|
// When an ACL references subnet CIDRs as both source and destination,
|
||||||
|
// the subnet routers for those subnets must receive routes to each
|
||||||
|
// other's subnets.
|
||||||
|
{
|
||||||
|
name: "subnet-to-subnet-src-router-gets-dst-route-3157",
|
||||||
|
args: args{
|
||||||
|
node: &types.Node{
|
||||||
|
ID: 1,
|
||||||
|
IPv4: ap("100.64.0.1"),
|
||||||
|
User: &types.User{Name: "router-a"},
|
||||||
|
Hostinfo: &tailcfg.Hostinfo{
|
||||||
|
RoutableIPs: []netip.Prefix{
|
||||||
|
netip.MustParsePrefix("10.88.8.0/24"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
ApprovedRoutes: []netip.Prefix{
|
||||||
|
netip.MustParsePrefix("10.88.8.0/24"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
routes: []netip.Prefix{
|
||||||
|
netip.MustParsePrefix("10.99.9.0/24"),
|
||||||
|
},
|
||||||
|
rules: []tailcfg.FilterRule{
|
||||||
|
{
|
||||||
|
SrcIPs: []string{"10.88.8.0/24"},
|
||||||
|
DstPorts: []tailcfg.NetPortRange{
|
||||||
|
{IP: "10.99.9.0/24", Ports: tailcfg.PortRangeAny},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
want: []netip.Prefix{
|
||||||
|
netip.MustParsePrefix("10.99.9.0/24"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "subnet-to-subnet-dst-router-gets-src-route-3157",
|
||||||
|
args: args{
|
||||||
|
node: &types.Node{
|
||||||
|
ID: 2,
|
||||||
|
IPv4: ap("100.64.0.2"),
|
||||||
|
User: &types.User{Name: "router-b"},
|
||||||
|
Hostinfo: &tailcfg.Hostinfo{
|
||||||
|
RoutableIPs: []netip.Prefix{
|
||||||
|
netip.MustParsePrefix("10.99.9.0/24"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
ApprovedRoutes: []netip.Prefix{
|
||||||
|
netip.MustParsePrefix("10.99.9.0/24"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
routes: []netip.Prefix{
|
||||||
|
netip.MustParsePrefix("10.88.8.0/24"),
|
||||||
|
},
|
||||||
|
rules: []tailcfg.FilterRule{
|
||||||
|
{
|
||||||
|
SrcIPs: []string{"10.88.8.0/24"},
|
||||||
|
DstPorts: []tailcfg.NetPortRange{
|
||||||
|
{IP: "10.99.9.0/24", Ports: tailcfg.PortRangeAny},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
want: []netip.Prefix{
|
||||||
|
netip.MustParsePrefix("10.88.8.0/24"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "subnet-to-subnet-regular-node-no-route-leak-3157",
|
||||||
|
args: args{
|
||||||
|
node: &types.Node{
|
||||||
|
ID: 3,
|
||||||
|
IPv4: ap("100.64.0.3"),
|
||||||
|
User: &types.User{Name: "regular-node"},
|
||||||
|
},
|
||||||
|
routes: []netip.Prefix{
|
||||||
|
netip.MustParsePrefix("10.88.8.0/24"),
|
||||||
|
netip.MustParsePrefix("10.99.9.0/24"),
|
||||||
|
},
|
||||||
|
rules: []tailcfg.FilterRule{
|
||||||
|
{
|
||||||
|
SrcIPs: []string{"10.88.8.0/24"},
|
||||||
|
DstPorts: []tailcfg.NetPortRange{
|
||||||
|
{IP: "10.99.9.0/24", Ports: tailcfg.PortRangeAny},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
want: nil,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "subnet-to-subnet-unrelated-router-no-route-leak-3157",
|
||||||
|
args: args{
|
||||||
|
node: &types.Node{
|
||||||
|
ID: 4,
|
||||||
|
IPv4: ap("100.64.0.4"),
|
||||||
|
User: &types.User{Name: "router-c"},
|
||||||
|
Hostinfo: &tailcfg.Hostinfo{
|
||||||
|
RoutableIPs: []netip.Prefix{
|
||||||
|
netip.MustParsePrefix("172.16.0.0/24"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
ApprovedRoutes: []netip.Prefix{
|
||||||
|
netip.MustParsePrefix("172.16.0.0/24"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
routes: []netip.Prefix{
|
||||||
|
netip.MustParsePrefix("10.88.8.0/24"),
|
||||||
|
},
|
||||||
|
rules: []tailcfg.FilterRule{
|
||||||
|
{
|
||||||
|
SrcIPs: []string{"10.88.8.0/24"},
|
||||||
|
DstPorts: []tailcfg.NetPortRange{
|
||||||
|
{IP: "10.99.9.0/24", Ports: tailcfg.PortRangeAny},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
want: nil,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
|
|||||||
@@ -40,11 +40,19 @@ var (
|
|||||||
type RouteFunc func(id NodeID) []netip.Prefix
|
type RouteFunc func(id NodeID) []netip.Prefix
|
||||||
|
|
||||||
// ViaRouteResult describes via grant effects for a viewer-peer pair.
|
// 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 {
|
type ViaRouteResult struct {
|
||||||
// Include contains prefixes this peer should serve to this viewer (via-designated).
|
// Include contains prefixes this peer should serve to this viewer (via-designated).
|
||||||
Include []netip.Prefix
|
Include []netip.Prefix
|
||||||
// Exclude contains prefixes steered to OTHER peers (suppress from global primary).
|
// Exclude contains prefixes steered to OTHER peers (suppress from global primary).
|
||||||
Exclude []netip.Prefix
|
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 (
|
type (
|
||||||
@@ -190,6 +198,9 @@ func (node *Node) IsEphemeral() bool {
|
|||||||
return node.AuthKey != nil && node.AuthKey.Ephemeral
|
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 {
|
func (node *Node) IPs() []netip.Addr {
|
||||||
var ret []netip.Addr
|
var ret []netip.Addr
|
||||||
|
|
||||||
@@ -267,9 +278,10 @@ func (node *Node) Prefixes() []netip.Prefix {
|
|||||||
return addrs
|
return addrs
|
||||||
}
|
}
|
||||||
|
|
||||||
// ExitRoutes returns a list of both exit routes if the
|
// ExitRoutes returns the node's approved exit routes (0.0.0.0/0
|
||||||
// node has any exit routes enabled.
|
// and/or ::/0). Consumed unconditionally by RoutesForPeer when the
|
||||||
// If none are enabled, it will return nil.
|
// viewer uses an exit node; excluded from CanAccessRoute which only
|
||||||
|
// handles non-exit routing.
|
||||||
func (node *Node) ExitRoutes() []netip.Prefix {
|
func (node *Node) ExitRoutes() []netip.Prefix {
|
||||||
var routes []netip.Prefix
|
var routes []netip.Prefix
|
||||||
|
|
||||||
@@ -282,6 +294,9 @@ func (node *Node) ExitRoutes() []netip.Prefix {
|
|||||||
return routes
|
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 {
|
func (node *Node) IsExitNode() bool {
|
||||||
return len(node.ExitRoutes()) > 0
|
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 {
|
func (node *Node) CanAccess(matchers []matcher.Match, node2 *Node) bool {
|
||||||
src := node.IPs()
|
src := node.IPs()
|
||||||
allowedIPs := node2.IPs()
|
allowedIPs := node2.IPs()
|
||||||
|
srcRoutes := node.SubnetRoutes()
|
||||||
|
dstRoutes := node2.SubnetRoutes()
|
||||||
|
dstIsExit := node2.IsExitNode()
|
||||||
|
|
||||||
for _, matcher := range matchers {
|
for _, m := range matchers {
|
||||||
if !matcher.SrcsContainsIPs(src...) {
|
srcMatchesIP := m.SrcsContainsIPs(src...)
|
||||||
|
srcMatchesRoutes := len(srcRoutes) > 0 && m.SrcsOverlapsPrefixes(srcRoutes...)
|
||||||
|
|
||||||
|
if !srcMatchesIP && !srcMatchesRoutes {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if matcher.DestsContainsIP(allowedIPs...) {
|
if m.DestsContainsIP(allowedIPs...) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if the node has access to routes that might be part of a
|
if len(dstRoutes) > 0 && m.DestsOverlapsPrefixes(dstRoutes...) {
|
||||||
// smaller subnet that is served from node2 as a subnet router.
|
|
||||||
if matcher.DestsOverlapsPrefixes(node2.SubnetRoutes()...) {
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// If the dst is "the internet" and node2 is an exit node, allow access.
|
if dstIsExit && m.DestsIsTheInternet() {
|
||||||
if matcher.DestsIsTheInternet() && node2.IsExitNode() {
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -347,8 +374,25 @@ func (node *Node) CanAccess(matchers []matcher.Match, node2 *Node) bool {
|
|||||||
return false
|
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 {
|
func (node *Node) CanAccessRoute(matchers []matcher.Match, route netip.Prefix) bool {
|
||||||
src := node.IPs()
|
src := node.IPs()
|
||||||
|
subnetRoutes := node.SubnetRoutes()
|
||||||
|
|
||||||
for _, matcher := range matchers {
|
for _, matcher := range matchers {
|
||||||
if matcher.SrcsContainsIPs(src...) && matcher.DestsOverlapsPrefixes(route) {
|
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...) {
|
if matcher.SrcsOverlapsPrefixes(route) && matcher.DestsContainsIP(src...) {
|
||||||
return true
|
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
|
return false
|
||||||
@@ -465,8 +528,10 @@ func (node *Node) GetFQDN(baseDomain string) (string, error) {
|
|||||||
return hostname, nil
|
return hostname, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// AnnouncedRoutes returns the list of routes that the node announces.
|
// AnnouncedRoutes returns the list of routes the node announces, as
|
||||||
// It should be used instead of checking Hostinfo.RoutableIPs directly.
|
// 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 {
|
func (node *Node) AnnouncedRoutes() []netip.Prefix {
|
||||||
if node.Hostinfo == nil {
|
if node.Hostinfo == nil {
|
||||||
return 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
|
// 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
|
// 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
|
// 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)
|
newCopy := slices.Clone(newEndpoints)
|
||||||
|
|
||||||
// Sort both slices to enable order-independent comparison
|
// Sort both slices to enable order-independent comparison
|
||||||
slices.SortFunc(oldCopy, func(a, b netip.AddrPort) int {
|
slices.SortFunc(oldCopy, netip.AddrPort.Compare)
|
||||||
return a.Compare(b)
|
slices.SortFunc(newCopy, netip.AddrPort.Compare)
|
||||||
})
|
|
||||||
slices.SortFunc(newCopy, func(a, b netip.AddrPort) int {
|
|
||||||
return a.Compare(b)
|
|
||||||
})
|
|
||||||
|
|
||||||
return !slices.Equal(oldCopy, newCopy)
|
return !slices.Equal(oldCopy, newCopy)
|
||||||
}
|
}
|
||||||
@@ -1041,28 +1103,49 @@ func (nv NodeView) IPsAsString() []string {
|
|||||||
|
|
||||||
// HasNetworkChanges checks if the node has network-related changes.
|
// HasNetworkChanges checks if the node has network-related changes.
|
||||||
// Returns true if IPs, announced routes, or approved routes changed.
|
// 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 {
|
func (nv NodeView) HasNetworkChanges(other NodeView) bool {
|
||||||
if !slices.Equal(nv.IPs(), other.IPs()) {
|
if !slices.Equal(nv.IPs(), other.IPs()) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
if !slices.Equal(nv.AnnouncedRoutes(), other.AnnouncedRoutes()) {
|
if !equalPrefixesUnordered(nv.AnnouncedRoutes(), other.AnnouncedRoutes()) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
if !slices.Equal(nv.SubnetRoutes(), other.SubnetRoutes()) {
|
if !equalPrefixesUnordered(nv.SubnetRoutes(), other.SubnetRoutes()) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
if !slices.Equal(nv.ExitRoutes(), other.ExitRoutes()) {
|
if !equalPrefixesUnordered(nv.ExitRoutes(), other.ExitRoutes()) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
return false
|
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 {
|
func (nv NodeView) HasPolicyChange(other NodeView) bool {
|
||||||
if nv.UserID() != other.UserID() {
|
if nv.UserID() != other.UserID() {
|
||||||
return true
|
return true
|
||||||
@@ -1076,6 +1159,10 @@ func (nv NodeView) HasPolicyChange(other NodeView) bool {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if !equalPrefixesUnordered(nv.SubnetRoutes(), other.SubnetRoutes()) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -113,6 +113,183 @@ func Test_NodeCanAccess(t *testing.T) {
|
|||||||
},
|
},
|
||||||
want: true,
|
want: true,
|
||||||
},
|
},
|
||||||
|
// Subnet-to-subnet tests for issue #3157.
|
||||||
|
// When ACL src and dst are both subnet CIDRs, subnet
|
||||||
|
// routers advertising those subnets must see each other.
|
||||||
|
{
|
||||||
|
name: "subnet-to-subnet-src-router-sees-dst-router-3157",
|
||||||
|
node1: Node{
|
||||||
|
IPv4: iap("100.64.0.1"),
|
||||||
|
Hostinfo: &tailcfg.Hostinfo{
|
||||||
|
RoutableIPs: []netip.Prefix{
|
||||||
|
netip.MustParsePrefix("10.88.8.0/24"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
ApprovedRoutes: []netip.Prefix{
|
||||||
|
netip.MustParsePrefix("10.88.8.0/24"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
node2: Node{
|
||||||
|
IPv4: iap("100.64.0.2"),
|
||||||
|
Hostinfo: &tailcfg.Hostinfo{
|
||||||
|
RoutableIPs: []netip.Prefix{
|
||||||
|
netip.MustParsePrefix("10.99.9.0/24"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
ApprovedRoutes: []netip.Prefix{
|
||||||
|
netip.MustParsePrefix("10.99.9.0/24"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
rules: []tailcfg.FilterRule{
|
||||||
|
{
|
||||||
|
SrcIPs: []string{"10.88.8.0/24"},
|
||||||
|
DstPorts: []tailcfg.NetPortRange{
|
||||||
|
{IP: "10.99.9.0/24", Ports: tailcfg.PortRangeAny},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
want: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// With a unidirectional ACL (src=A→dst=B), the dst
|
||||||
|
// router cannot access the src router. Bidirectional
|
||||||
|
// peer visibility comes from ReduceNodes checking
|
||||||
|
// both A.CanAccess(B) || B.CanAccess(A).
|
||||||
|
name: "subnet-to-subnet-unidirectional-dst-cannot-access-src-3157",
|
||||||
|
node1: Node{
|
||||||
|
IPv4: iap("100.64.0.2"),
|
||||||
|
Hostinfo: &tailcfg.Hostinfo{
|
||||||
|
RoutableIPs: []netip.Prefix{
|
||||||
|
netip.MustParsePrefix("10.99.9.0/24"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
ApprovedRoutes: []netip.Prefix{
|
||||||
|
netip.MustParsePrefix("10.99.9.0/24"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
node2: Node{
|
||||||
|
IPv4: iap("100.64.0.1"),
|
||||||
|
Hostinfo: &tailcfg.Hostinfo{
|
||||||
|
RoutableIPs: []netip.Prefix{
|
||||||
|
netip.MustParsePrefix("10.88.8.0/24"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
ApprovedRoutes: []netip.Prefix{
|
||||||
|
netip.MustParsePrefix("10.88.8.0/24"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
rules: []tailcfg.FilterRule{
|
||||||
|
{
|
||||||
|
SrcIPs: []string{"10.88.8.0/24"},
|
||||||
|
DstPorts: []tailcfg.NetPortRange{
|
||||||
|
{IP: "10.99.9.0/24", Ports: tailcfg.PortRangeAny},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// With a bidirectional ACL, both routers can access
|
||||||
|
// each other.
|
||||||
|
name: "subnet-to-subnet-bidirectional-3157",
|
||||||
|
node1: Node{
|
||||||
|
IPv4: iap("100.64.0.2"),
|
||||||
|
Hostinfo: &tailcfg.Hostinfo{
|
||||||
|
RoutableIPs: []netip.Prefix{
|
||||||
|
netip.MustParsePrefix("10.99.9.0/24"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
ApprovedRoutes: []netip.Prefix{
|
||||||
|
netip.MustParsePrefix("10.99.9.0/24"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
node2: Node{
|
||||||
|
IPv4: iap("100.64.0.1"),
|
||||||
|
Hostinfo: &tailcfg.Hostinfo{
|
||||||
|
RoutableIPs: []netip.Prefix{
|
||||||
|
netip.MustParsePrefix("10.88.8.0/24"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
ApprovedRoutes: []netip.Prefix{
|
||||||
|
netip.MustParsePrefix("10.88.8.0/24"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
rules: []tailcfg.FilterRule{
|
||||||
|
{
|
||||||
|
SrcIPs: []string{"10.88.8.0/24"},
|
||||||
|
DstPorts: []tailcfg.NetPortRange{
|
||||||
|
{IP: "10.99.9.0/24", Ports: tailcfg.PortRangeAny},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
SrcIPs: []string{"10.99.9.0/24"},
|
||||||
|
DstPorts: []tailcfg.NetPortRange{
|
||||||
|
{IP: "10.88.8.0/24", Ports: tailcfg.PortRangeAny},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
want: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "subnet-to-subnet-regular-node-excluded-3157",
|
||||||
|
node1: Node{
|
||||||
|
IPv4: iap("100.64.0.3"),
|
||||||
|
},
|
||||||
|
node2: Node{
|
||||||
|
IPv4: iap("100.64.0.2"),
|
||||||
|
Hostinfo: &tailcfg.Hostinfo{
|
||||||
|
RoutableIPs: []netip.Prefix{
|
||||||
|
netip.MustParsePrefix("10.99.9.0/24"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
ApprovedRoutes: []netip.Prefix{
|
||||||
|
netip.MustParsePrefix("10.99.9.0/24"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
rules: []tailcfg.FilterRule{
|
||||||
|
{
|
||||||
|
SrcIPs: []string{"10.88.8.0/24"},
|
||||||
|
DstPorts: []tailcfg.NetPortRange{
|
||||||
|
{IP: "10.99.9.0/24", Ports: tailcfg.PortRangeAny},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "subnet-to-subnet-unrelated-router-excluded-3157",
|
||||||
|
node1: Node{
|
||||||
|
IPv4: iap("100.64.0.3"),
|
||||||
|
Hostinfo: &tailcfg.Hostinfo{
|
||||||
|
RoutableIPs: []netip.Prefix{
|
||||||
|
netip.MustParsePrefix("172.16.0.0/24"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
ApprovedRoutes: []netip.Prefix{
|
||||||
|
netip.MustParsePrefix("172.16.0.0/24"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
node2: Node{
|
||||||
|
IPv4: iap("100.64.0.2"),
|
||||||
|
Hostinfo: &tailcfg.Hostinfo{
|
||||||
|
RoutableIPs: []netip.Prefix{
|
||||||
|
netip.MustParsePrefix("10.99.9.0/24"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
ApprovedRoutes: []netip.Prefix{
|
||||||
|
netip.MustParsePrefix("10.99.9.0/24"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
rules: []tailcfg.FilterRule{
|
||||||
|
{
|
||||||
|
SrcIPs: []string{"10.88.8.0/24"},
|
||||||
|
DstPorts: []tailcfg.NetPortRange{
|
||||||
|
{IP: "10.99.9.0/24", Ports: tailcfg.PortRangeAny},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
@@ -127,6 +304,40 @@ func Test_NodeCanAccess(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Test_NodeCanAccess_Unidirectional asserts that a one-way rule grants
|
||||||
|
// access in one direction only. A unidirectional ACL is a valid and
|
||||||
|
// intentional pattern; the "OR" aggregation in the v1 compat harness
|
||||||
|
// loses this asymmetry, which motivated the directional split in
|
||||||
|
// TestRoutesCompatPeerVisibility.
|
||||||
|
func Test_NodeCanAccess_Unidirectional(t *testing.T) {
|
||||||
|
iap := func(ipStr string) *netip.Addr {
|
||||||
|
ip := netip.MustParseAddr(ipStr)
|
||||||
|
return &ip
|
||||||
|
}
|
||||||
|
|
||||||
|
nodeA := Node{IPv4: iap("100.64.0.1")}
|
||||||
|
nodeB := Node{IPv4: iap("100.64.0.2")}
|
||||||
|
|
||||||
|
rules := []tailcfg.FilterRule{
|
||||||
|
{
|
||||||
|
SrcIPs: []string{"100.64.0.1/32"},
|
||||||
|
DstPorts: []tailcfg.NetPortRange{
|
||||||
|
{IP: "100.64.0.2/32", Ports: tailcfg.PortRangeAny},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
matchers := matcher.MatchesFromFilterRules(rules)
|
||||||
|
|
||||||
|
if !nodeA.CanAccess(matchers, &nodeB) {
|
||||||
|
t.Errorf("A→B: want true, got false")
|
||||||
|
}
|
||||||
|
|
||||||
|
if nodeB.CanAccess(matchers, &nodeA) {
|
||||||
|
t.Errorf("B→A: want false, got true (unidirectional rule leaked reverse access)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestNodeFQDN(t *testing.T) {
|
func TestNodeFQDN(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
|
|||||||
Reference in New Issue
Block a user