Compare commits

..

4 Commits

Author SHA1 Message Date
copilot-swe-agent[bot]
1f4b645d5b Refactor: Extract route filtering logic into helper function
Addressed code review feedback:
- Extracted duplicated exit route filtering logic into buildRouteFilterFunc
- Reused exitNode lookup in integration test to avoid duplication
- Added missing matcher package import

This improves code maintainability while preserving the same functionality.

Co-authored-by: kradalby <98431+kradalby@users.noreply.github.com>
2025-11-01 09:05:37 +00:00
copilot-swe-agent[bot]
4fa1f4baa3 Add integration test for exit node ACL visibility (issue #2788)
Added TestExitNodeVisibilityWithACL to verify that exit nodes are only
visible to nodes that have permission according to ACL policy. The test:
- Creates 3 nodes: mobile, server, and exit-owner
- Sets up ACL allowing only mobile->server:80 (no autogroup:internet)
- Advertises and approves exit routes on exit-owner node
- Verifies mobile and server do NOT see exit node in peer list

This is a regression test for issue #2788 where exit nodes were visible
to all nodes regardless of ACL policy.

Co-authored-by: kradalby <98431+kradalby@users.noreply.github.com>
2025-11-01 08:54:29 +00:00
copilot-swe-agent[bot]
e0107024e8 Filter exit routes through ACL policy to fix issue #2788
Exit nodes are now only visible to nodes that have permission to use them
according to ACL policy. Previously, exit routes (0.0.0.0/0 and ::/0) were
unconditionally added to the AllowedIPs field in the network map, making
exit nodes visible to all peers regardless of policy.

Changes:
- Modified buildTailPeers and WithSelfNode in builder.go to filter exit
  routes through policy.ReduceRoutes, same as primary routes
- Removed unconditional addition of exit routes in tail.go tailNode function
- Updated tail_test.go to reflect new behavior where exit routes are filtered

The fix ensures that exit nodes are only visible when a node has
autogroup:internet in their ACL destination rules.

Co-authored-by: kradalby <98431+kradalby@users.noreply.github.com>
2025-11-01 08:52:29 +00:00
copilot-swe-agent[bot]
a55cdc2636 Initial plan 2025-11-01 08:27:52 +00:00
7 changed files with 187 additions and 434 deletions

6
go.sum
View File

@@ -124,6 +124,8 @@ github.com/creachadair/command v0.2.0 h1:qTA9cMMhZePAxFoNdnk6F6nn94s1qPndIg9hJbq
github.com/creachadair/command v0.2.0/go.mod h1:j+Ar+uYnFsHpkMeV9kGj6lJ45y9u2xqtg8FYy6cm+0o=
github.com/creachadair/flax v0.0.5 h1:zt+CRuXQASxwQ68e9GHAOnEgAU29nF0zYMHOCrL5wzE=
github.com/creachadair/flax v0.0.5/go.mod h1:F1PML0JZLXSNDMNiRGK2yjm5f+L9QCHchyHBldFymj8=
github.com/creachadair/mds v0.25.2 h1:xc0S0AfDq5GX9KUR5sLvi5XjA61/P6S5e0xFs1vA18Q=
github.com/creachadair/mds v0.25.2/go.mod h1:+s4CFteFRj4eq2KcGHW8Wei3u9NyzSPzNV32EvjyK/Q=
github.com/creachadair/mds v0.25.10 h1:9k9JB35D1xhOCFl0liBhagBBp8fWWkKZrA7UXsfoHtA=
github.com/creachadair/mds v0.25.10/go.mod h1:4hatI3hRM+qhzuAmqPRFvaBM8mONkS7nsLxkcuTYUIs=
github.com/creachadair/taskgroup v0.13.2 h1:3KyqakBuFsm3KkXi/9XIb0QcA8tEzLHLgaoidf0MdVc=
@@ -276,6 +278,8 @@ github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfC
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
github.com/jsimonetti/rtnetlink v1.4.1 h1:JfD4jthWBqZMEffc5RjgmlzpYttAVw1sdnmiNaPO3hE=
github.com/jsimonetti/rtnetlink v1.4.1/go.mod h1:xJjT7t59UIZ62GLZbv6PLLo8VFrostJMPBAheR6OM8w=
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
github.com/klauspost/compress v1.18.1 h1:bcSGx7UbpBqMChDtsF28Lw6v/G94LPrrbMbdC3JH2co=
github.com/klauspost/compress v1.18.1/go.mod h1:ZQFFVG+MdnR0P+l6wpXgIL4NTtwiKIdBnrBd8Nrxr+0=
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
@@ -459,6 +463,8 @@ github.com/tailscale/peercred v0.0.0-20250107143737-35a0c7bd7edc h1:24heQPtnFR+y
github.com/tailscale/peercred v0.0.0-20250107143737-35a0c7bd7edc/go.mod h1:f93CXfllFsO9ZQVq+Zocb1Gp4G5Fz0b0rXHLOzt/Djc=
github.com/tailscale/setec v0.0.0-20250305161714-445cadbbca3d h1:mnqtPWYyvNiPU9l9tzO2YbHXU/xV664XthZYA26lOiE=
github.com/tailscale/setec v0.0.0-20250305161714-445cadbbca3d/go.mod h1:9BzmlFc3OLqLzLTF/5AY+BMs+clxMqyhSGzgXIm8mNI=
github.com/tailscale/squibble v0.0.0-20250108170732-a4ca58afa694 h1:95eIP97c88cqAFU/8nURjgI9xxPbD+Ci6mY/a79BI/w=
github.com/tailscale/squibble v0.0.0-20250108170732-a4ca58afa694/go.mod h1:veguaG8tVg1H/JG5RfpoUW41I+O8ClPElo/fTYr8mMk=
github.com/tailscale/squibble v0.0.0-20251030164342-4d5df9caa993 h1:FyiiAvDAxpB0DrW2GW3KOVfi3YFOtsQUEeFWbf55JJU=
github.com/tailscale/squibble v0.0.0-20251030164342-4d5df9caa993/go.mod h1:xJkMmR3t+thnUQhA3Q4m2VSlS5pcOq+CIjmU/xfKKx4=
github.com/tailscale/tailsql v0.0.0-20250421235516-02f85f087b97 h1:JJkDnrAhHvOCttk8z9xeZzcDlzzkRA7+Duxj9cwOyxk=

View File

@@ -14,48 +14,6 @@ import (
"tailscale.com/util/multierr"
)
// canUseExitRoutes checks if a node can access exit routes (0.0.0.0/0 and ::/0)
// based on ACL matchers. This specifically checks if the node has permission to
// access the internet broadly, which is required to use exit nodes.
//
// Exit routes should only be visible when the ACL explicitly grants broad internet
// access (e.g., via autogroup:internet), not just access to specific services.
//
// The function tests if the ACL grants access to well-known public DNS servers.
// If any of these are accessible, it indicates the ACL grants broad internet access
// (as opposed to just specific private services), which is sufficient for exit node usage.
func canUseExitRoutes(node types.NodeView, matchers []matcher.Match) bool {
src := node.IPs()
// Sample public internet IPs to test for broad internet access.
// If the ACL grants access to any of these well-known public IPs, it indicates
// broad internet access (e.g., via autogroup:internet) rather than just access
// to specific private services.
samplePublicIPs := []netip.Addr{
netip.MustParseAddr("1.1.1.1"), // Cloudflare DNS
netip.MustParseAddr("8.8.8.8"), // Google DNS
netip.MustParseAddr("208.67.222.222"), // OpenDNS
}
// Check if any matcher grants access to sample public IPs
for _, matcher := range matchers {
// Check if this node is in the source
if !matcher.SrcsContainsIPs(src...) {
continue
}
// Check if the destination includes any public internet IPs.
// DestsContainsIP returns true if ANY of the provided IPs is in the destination set.
// This will be true for autogroup:internet (which resolves to the public internet)
// but false for rules that only allow access to specific private IPs or services.
if matcher.DestsContainsIP(samplePublicIPs...) {
return true
}
}
return false
}
// MapResponseBuilder provides a fluent interface for building tailcfg.MapResponse.
type MapResponseBuilder struct {
resp *tailcfg.MapResponse
@@ -110,6 +68,33 @@ func (b *MapResponseBuilder) WithCapabilityVersion(capVer tailcfg.CapabilityVers
return b
}
// buildRouteFilterFunc creates a route filter function that includes both primary and exit routes.
// It filters routes based on ACL policy to ensure only authorized routes are visible.
func (b *MapResponseBuilder) buildRouteFilterFunc(
viewingNode types.NodeView,
matchers []matcher.Match,
) routeFilterFunc {
return func(id types.NodeID) []netip.Prefix {
// Get the peer node to check for exit routes
peer, ok := b.mapper.state.GetNodeByID(id)
if !ok {
return nil
}
// Start with primary routes (subnet routes, but not exit routes)
routes := policy.ReduceRoutes(viewingNode, b.mapper.state.GetNodePrimaryRoutes(id), matchers)
// Also filter exit routes through policy
// Only add exit routes if the viewing node has permission to use them
if exitRoutes := peer.ExitRoutes(); len(exitRoutes) > 0 {
filteredExitRoutes := policy.ReduceRoutes(viewingNode, exitRoutes, matchers)
routes = append(routes, filteredExitRoutes...)
}
return routes
}
}
// WithSelfNode adds the requesting node to the response.
func (b *MapResponseBuilder) WithSelfNode() *MapResponseBuilder {
nv, ok := b.mapper.state.GetNodeByID(b.nodeID)
@@ -121,17 +106,7 @@ func (b *MapResponseBuilder) WithSelfNode() *MapResponseBuilder {
_, matchers := b.mapper.state.Filter()
tailnode, err := tailNode(
nv, b.capVer, b.mapper.state,
func(id types.NodeID) []netip.Prefix {
return policy.ReduceRoutes(nv, b.mapper.state.GetNodePrimaryRoutes(id), matchers)
},
func(id types.NodeID) []netip.Prefix {
// For self node, always include its own exit routes
peerNode, ok := b.mapper.state.GetNodeByID(id)
if !ok {
return nil
}
return peerNode.ExitRoutes()
},
b.buildRouteFilterFunc(nv, matchers),
b.mapper.cfg)
if err != nil {
b.addError(err)
@@ -304,25 +279,7 @@ func (b *MapResponseBuilder) buildTailPeers(peers views.Slice[types.NodeView]) (
tailPeers, err := tailNodes(
changedViews, b.capVer, b.mapper.state,
func(id types.NodeID) []netip.Prefix {
return policy.ReduceRoutes(node, b.mapper.state.GetNodePrimaryRoutes(id), matchers)
},
func(id types.NodeID) []netip.Prefix {
// For peer nodes, only include exit routes if the requesting node can use exit nodes
peerNode, ok := b.mapper.state.GetNodeByID(id)
if !ok {
return nil
}
exitRoutes := peerNode.ExitRoutes()
if len(exitRoutes) == 0 {
return nil
}
// Check if the requesting node has permission to use exit nodes
if canUseExitRoutes(node, matchers) {
return exitRoutes
}
return nil
},
b.buildRouteFilterFunc(node, matchers),
b.mapper.cfg)
if err != nil {
return nil, err

View File

@@ -1,336 +0,0 @@
package mapper
import (
"net/netip"
"testing"
"github.com/juanfont/headscale/hscontrol/policy"
"github.com/juanfont/headscale/hscontrol/types"
"github.com/stretchr/testify/require"
"tailscale.com/net/tsaddr"
"tailscale.com/tailcfg"
"tailscale.com/types/key"
)
// TestExitNodeVisibilityWithoutAutogroupInternet tests that exit nodes are not visible
// to nodes that don't have autogroup:internet permission in their ACL.
// This is a regression test for https://github.com/juanfont/headscale/issues/2788
func TestExitNodeVisibilityWithoutAutogroupInternet(t *testing.T) {
mustNK := func(str string) key.NodePublic {
var k key.NodePublic
_ = k.UnmarshalText([]byte(str))
return k
}
mustDK := func(str string) key.DiscoPublic {
var k key.DiscoPublic
_ = k.UnmarshalText([]byte(str))
return k
}
mustMK := func(str string) key.MachinePublic {
var k key.MachinePublic
_ = k.UnmarshalText([]byte(str))
return k
}
// Create three nodes: mobile, server, exit
mobile := &types.Node{
ID: 1,
MachineKey: mustMK(
"mkey:f08305b4ee4250b95a70f3b7504d048d75d899993c624a26d422c67af0422507",
),
NodeKey: mustNK(
"nodekey:9b2ffa7e08cc421a3d2cca9012280f6a236fd0de0b4ce005b30a98ad930306fe",
),
DiscoKey: mustDK(
"discokey:cf7b0fd05da556fdc3bab365787b506fd82d64a70745db70e00e86c1b1c03084",
),
IPv4: iap("100.64.0.1"),
Hostname: "mobile",
GivenName: "mobile",
UserID: 1,
User: types.User{
Name: "alice",
},
}
server := &types.Node{
ID: 2,
MachineKey: mustMK(
"mkey:e08305b4ee4250b95a70f3b7504d048d75d899993c624a26d422c67af0422508",
),
NodeKey: mustNK(
"nodekey:8b2ffa7e08cc421a3d2cca9012280f6a236fd0de0b4ce005b30a98ad930306ff",
),
DiscoKey: mustDK(
"discokey:df7b0fd05da556fdc3bab365787b506fd82d64a70745db70e00e86c1b1c03085",
),
IPv4: iap("100.64.0.2"),
Hostname: "server",
GivenName: "server",
UserID: 1,
User: types.User{
Name: "alice",
},
}
exitNode := &types.Node{
ID: 3,
MachineKey: mustMK(
"mkey:d08305b4ee4250b95a70f3b7504d048d75d899993c624a26d422c67af0422509",
),
NodeKey: mustNK(
"nodekey:7b2ffa7e08cc421a3d2cca9012280f6a236fd0de0b4ce005b30a98ad930306fd",
),
DiscoKey: mustDK(
"discokey:ef7b0fd05da556fdc3bab365787b506fd82d64a70745db70e00e86c1b1c03086",
),
IPv4: iap("100.64.0.3"),
Hostname: "exit",
GivenName: "exit",
UserID: 1,
User: types.User{
Name: "alice",
},
Hostinfo: &tailcfg.Hostinfo{
RoutableIPs: []netip.Prefix{
tsaddr.AllIPv4(),
tsaddr.AllIPv6(),
},
},
// Exit node has approved exit routes
ApprovedRoutes: []netip.Prefix{tsaddr.AllIPv4(), tsaddr.AllIPv6()},
}
// ACL that only allows mobile -> server:80, no autogroup:internet
pol := []byte(`{
"hosts": {
"mobile": "100.64.0.1/32",
"server": "100.64.0.2/32",
"exit": "100.64.0.3/32"
},
"acls": [
{
"action": "accept",
"src": ["mobile"],
"dst": ["server:80"]
}
]
}`)
polMan, err := policy.NewPolicyManager(pol, []types.User{mobile.User}, types.Nodes{mobile, server, exitNode}.ViewSlice())
require.NoError(t, err)
matchers, err := polMan.MatchersForNode(mobile.View())
require.NoError(t, err)
cfg := &types.Config{
BaseDomain: "",
RandomizeClientPort: false,
}
// Build the exit node as a peer from mobile's perspective
exitTailNode, err := tailNode(
exitNode.View(),
0,
polMan,
func(id types.NodeID) []netip.Prefix {
// No primary routes for this test
return nil
},
func(id types.NodeID) []netip.Prefix {
// For peer nodes, only include exit routes if the requesting node can use exit nodes
peerNode := exitNode
if id != peerNode.ID {
return nil
}
exitRoutes := peerNode.ExitRoutes()
if len(exitRoutes) == 0 {
return nil
}
// Check if the requesting node has permission to use exit nodes
if canUseExitRoutes(mobile.View(), matchers) {
return exitRoutes
}
return nil
},
cfg,
)
require.NoError(t, err)
// Verify that exit routes are NOT included in AllowedIPs
// since mobile doesn't have autogroup:internet permission
hasExitRoutes := false
for _, prefix := range exitTailNode.AllowedIPs {
if tsaddr.IsExitRoute(prefix) {
hasExitRoutes = true
break
}
}
if hasExitRoutes {
t.Errorf("Exit node should NOT have exit routes in AllowedIPs when requesting node lacks autogroup:internet permission.\nAllowedIPs: %v", exitTailNode.AllowedIPs)
}
// The AllowedIPs should only contain the exit node's own IP, not the exit routes
// Check the count and that no exit routes are present
if len(exitTailNode.AllowedIPs) != 1 {
t.Errorf("Expected exactly 1 IP in AllowedIPs (node's own IP), got %d: %v", len(exitTailNode.AllowedIPs), exitTailNode.AllowedIPs)
}
// Verify the one IP is the node's own IP
expectedIP := netip.MustParsePrefix("100.64.0.3/32")
found := false
for _, ip := range exitTailNode.AllowedIPs {
if ip == expectedIP {
found = true
break
}
}
if !found {
t.Errorf("Expected to find node's own IP %s in AllowedIPs, got: %v", expectedIP, exitTailNode.AllowedIPs)
}
}
// TestExitNodeVisibilityWithAutogroupInternet tests that exit nodes ARE visible
// to nodes that have autogroup:internet permission in their ACL.
func TestExitNodeVisibilityWithAutogroupInternet(t *testing.T) {
mustNK := func(str string) key.NodePublic {
var k key.NodePublic
_ = k.UnmarshalText([]byte(str))
return k
}
mustDK := func(str string) key.DiscoPublic {
var k key.DiscoPublic
_ = k.UnmarshalText([]byte(str))
return k
}
mustMK := func(str string) key.MachinePublic {
var k key.MachinePublic
_ = k.UnmarshalText([]byte(str))
return k
}
mobile := &types.Node{
ID: 1,
MachineKey: mustMK(
"mkey:f08305b4ee4250b95a70f3b7504d048d75d899993c624a26d422c67af0422507",
),
NodeKey: mustNK(
"nodekey:9b2ffa7e08cc421a3d2cca9012280f6a236fd0de0b4ce005b30a98ad930306fe",
),
DiscoKey: mustDK(
"discokey:cf7b0fd05da556fdc3bab365787b506fd82d64a70745db70e00e86c1b1c03084",
),
IPv4: iap("100.64.0.1"),
Hostname: "mobile",
GivenName: "mobile",
UserID: 1,
User: types.User{
Name: "alice",
},
}
exitNode := &types.Node{
ID: 3,
MachineKey: mustMK(
"mkey:d08305b4ee4250b95a70f3b7504d048d75d899993c624a26d422c67af0422509",
),
NodeKey: mustNK(
"nodekey:7b2ffa7e08cc421a3d2cca9012280f6a236fd0de0b4ce005b30a98ad930306fd",
),
DiscoKey: mustDK(
"discokey:ef7b0fd05da556fdc3bab365787b506fd82d64a70745db70e00e86c1b1c03086",
),
IPv4: iap("100.64.0.3"),
Hostname: "exit",
GivenName: "exit",
UserID: 1,
User: types.User{
Name: "alice",
},
Hostinfo: &tailcfg.Hostinfo{
RoutableIPs: []netip.Prefix{
tsaddr.AllIPv4(),
tsaddr.AllIPv6(),
},
},
ApprovedRoutes: []netip.Prefix{tsaddr.AllIPv4(), tsaddr.AllIPv6()},
}
// ACL that allows mobile to use autogroup:internet
pol := []byte(`{
"hosts": {
"mobile": "100.64.0.1/32",
"exit": "100.64.0.3/32"
},
"acls": [
{
"action": "accept",
"src": ["mobile"],
"dst": ["autogroup:internet:*"]
}
]
}`)
polMan, err := policy.NewPolicyManager(pol, []types.User{mobile.User}, types.Nodes{mobile, exitNode}.ViewSlice())
require.NoError(t, err)
matchers, err := polMan.MatchersForNode(mobile.View())
require.NoError(t, err)
cfg := &types.Config{
BaseDomain: "",
RandomizeClientPort: false,
}
// Build the exit node as a peer from mobile's perspective
exitTailNode, err := tailNode(
exitNode.View(),
0,
polMan,
func(id types.NodeID) []netip.Prefix {
return nil
},
func(id types.NodeID) []netip.Prefix {
peerNode := exitNode
if id != peerNode.ID {
return nil
}
exitRoutes := peerNode.ExitRoutes()
if len(exitRoutes) == 0 {
return nil
}
// Check if the requesting node has permission to use exit nodes - mobile has autogroup:internet permission
if canUseExitRoutes(mobile.View(), matchers) {
return exitRoutes
}
return nil
},
cfg,
)
require.NoError(t, err)
// Verify that exit routes ARE included in AllowedIPs
hasIPv4ExitRoute := false
hasIPv6ExitRoute := false
for _, prefix := range exitTailNode.AllowedIPs {
if prefix == tsaddr.AllIPv4() {
hasIPv4ExitRoute = true
}
if prefix == tsaddr.AllIPv6() {
hasIPv6ExitRoute = true
}
}
if !hasIPv4ExitRoute {
t.Errorf("Exit node should have IPv4 exit route (0.0.0.0/0) in AllowedIPs when requesting node has autogroup:internet permission.\nAllowedIPs: %v", exitTailNode.AllowedIPs)
}
if !hasIPv6ExitRoute {
t.Errorf("Exit node should have IPv6 exit route (::/0) in AllowedIPs when requesting node has autogroup:internet permission.\nAllowedIPs: %v", exitTailNode.AllowedIPs)
}
}

View File

@@ -21,7 +21,6 @@ func tailNodes(
capVer tailcfg.CapabilityVersion,
checker NodeCanHaveTagChecker,
primaryRouteFunc routeFilterFunc,
exitRouteFunc routeFilterFunc,
cfg *types.Config,
) ([]*tailcfg.Node, error) {
tNodes := make([]*tailcfg.Node, 0, nodes.Len())
@@ -32,7 +31,6 @@ func tailNodes(
capVer,
checker,
primaryRouteFunc,
exitRouteFunc,
cfg,
)
if err != nil {
@@ -51,7 +49,6 @@ func tailNode(
capVer tailcfg.CapabilityVersion,
checker NodeCanHaveTagChecker,
primaryRouteFunc routeFilterFunc,
exitRouteFunc routeFilterFunc,
cfg *types.Config,
) (*tailcfg.Node, error) {
addrs := node.Prefixes()
@@ -91,12 +88,9 @@ func tailNode(
}
tags = lo.Uniq(tags)
// Get filtered routes (includes both primary routes and exit routes if allowed by policy)
routes := primaryRouteFunc(node.ID())
allowed := append(addrs, routes...)
// Only include exit routes if the exitRouteFunc allows them
exitRoutes := exitRouteFunc(node.ID())
allowed = append(allowed, exitRoutes...)
tsaddr.SortPrefixes(allowed)
tNode := tailcfg.Node{

View File

@@ -137,10 +137,8 @@ func TestTailNode(t *testing.T) {
),
Addresses: []netip.Prefix{netip.MustParsePrefix("100.64.0.1/32")},
AllowedIPs: []netip.Prefix{
tsaddr.AllIPv4(),
netip.MustParsePrefix("192.168.0.0/24"),
netip.MustParsePrefix("100.64.0.1/32"),
tsaddr.AllIPv6(),
},
PrimaryRoutes: []netip.Prefix{
netip.MustParsePrefix("192.168.0.0/24"),
@@ -221,13 +219,6 @@ func TestTailNode(t *testing.T) {
func(id types.NodeID) []netip.Prefix {
return primary.PrimaryRoutes(id)
},
func(id types.NodeID) []netip.Prefix {
// For tests, include exit routes if node has them
if id == tt.node.ID {
return tt.node.ExitRoutes()
}
return nil
},
cfg,
)
@@ -288,9 +279,6 @@ func TestNodeExpiry(t *testing.T) {
func(id types.NodeID) []netip.Prefix {
return []netip.Prefix{}
},
func(id types.NodeID) []netip.Prefix {
return []netip.Prefix{}
},
&types.Config{},
)
if err != nil {

View File

@@ -91,10 +91,3 @@ func (m *Match) SrcsOverlapsPrefixes(prefixes ...netip.Prefix) bool {
func (m *Match) DestsOverlapsPrefixes(prefixes ...netip.Prefix) bool {
return slices.ContainsFunc(prefixes, m.dests.OverlapsPrefix)
}
// DestsContainsPrefixes checks if the destination IPSet contains any of the given prefixes.
// Returns true if at least one prefix is fully contained in the destination IPSet.
// This is more strict than DestsOverlapsPrefixes which only requires overlap.
func (m *Match) DestsContainsPrefixes(prefixes ...netip.Prefix) bool {
return slices.ContainsFunc(prefixes, m.dests.ContainsPrefix)
}

View File

@@ -3042,3 +3042,154 @@ func TestSubnetRouteACLFiltering(t *testing.T) {
assertTracerouteViaIPWithCollect(c, tr, ip)
}, 20*time.Second, 200*time.Millisecond, "Verifying traceroute goes through router")
}
// TestExitNodeVisibilityWithACL tests that exit nodes are only visible
// to nodes that have permission to use them according to ACL policy.
// This is a regression test for issue #2788.
func TestExitNodeVisibilityWithACL(t *testing.T) {
IntegrationSkip(t)
spec := ScenarioSpec{
NodesPerUser: 1,
Users: []string{"mobile", "server", "exit-owner"},
}
scenario, err := NewScenario(spec)
require.NoErrorf(t, err, "failed to create scenario: %s", err)
defer scenario.ShutdownAssertNoPanics(t)
// Policy that allows:
// - mobile can communicate with server on port 80
// - mobile does NOT have autogroup:internet, so should NOT see exit node
policy := `
{
"hosts": {
"mobile": "100.64.0.1/32",
"server": "100.64.0.2/32",
"exit": "100.64.0.3/32"
},
"acls": [
{
"action": "accept",
"src": ["mobile"],
"dst": ["server:80"]
}
]
}
`
err = scenario.CreateHeadscaleEnv(
[]tsic.Option{},
hsic.WithTestName("exitnodeacl"),
hsic.WithConfigEnv(map[string]string{
"HEADSCALE_POLICY_MODE": "file",
"HEADSCALE_POLICY_PATH": "/etc/headscale/policy.json",
}),
hsic.WithFileInContainer("/etc/headscale/policy.json", []byte(policy)),
)
requireNoErrHeadscaleEnv(t, err)
allClients, err := scenario.ListTailscaleClients()
requireNoErrListClients(t, err)
require.Len(t, allClients, 3)
err = scenario.WaitForTailscaleSync()
requireNoErrSync(t, err)
headscale, err := scenario.Headscale()
requireNoErrGetHeadscale(t, err)
// Find the clients
var mobileClient, serverClient, exitClient TailscaleClient
for _, client := range allClients {
status := client.MustStatus()
switch status.User[status.Self.UserID].LoginName {
case "mobile@test.no":
mobileClient = client
case "server@test.no":
serverClient = client
case "exit-owner@test.no":
exitClient = client
}
}
require.NotNil(t, mobileClient, "mobile client not found")
require.NotNil(t, serverClient, "server client not found")
require.NotNil(t, exitClient, "exit client not found")
// Advertise exit node from the exit-owner node
_, _, err = exitClient.Execute([]string{
"tailscale",
"set",
"--advertise-exit-node",
})
require.NoErrorf(t, err, "failed to advertise exit node: %s", err)
// Wait for the exit node to be registered
var nodes []*v1.Node
var exitNode *v1.Node
exitStatus := exitClient.MustStatus()
assert.EventuallyWithT(t, func(c *assert.CollectT) {
nodes, err = headscale.ListNodes()
assert.NoError(c, err)
assert.Len(c, nodes, 3)
// Find the exit node
exitNode = nil
for _, node := range nodes {
if node.GetName() == exitStatus.Self.HostName {
exitNode = node
break
}
}
assert.NotNil(c, exitNode, "exit node not found")
if exitNode != nil {
// Exit node should have 2 available routes (0.0.0.0/0 and ::/0)
assert.Len(c, exitNode.GetAvailableRoutes(), 2, "exit node should advertise 2 routes")
}
}, 10*time.Second, 500*time.Millisecond, "waiting for exit node advertisement")
// Approve the exit routes
require.NotNil(t, exitNode, "exit node not found after advertisement")
_, err = headscale.ApproveRoutes(exitNode.GetId(), []netip.Prefix{tsaddr.AllIPv4(), tsaddr.AllIPv6()})
require.NoError(t, err, "failed to approve exit routes")
// Wait for routes to be approved in the database
assert.EventuallyWithT(t, func(c *assert.CollectT) {
nodes, err = headscale.ListNodes()
assert.NoError(c, err)
for _, node := range nodes {
if node.GetName() == exitStatus.Self.HostName {
assert.Len(c, node.GetApprovedRoutes(), 2, "exit node should have 2 approved routes")
assert.Len(c, node.GetSubnetRoutes(), 2, "exit node should have 2 subnet routes")
}
}
}, 10*time.Second, 500*time.Millisecond, "waiting for route approval")
// The key test: mobile client should NOT see the exit node in their peer list
// because they don't have autogroup:internet in their ACL
assert.EventuallyWithT(t, func(c *assert.CollectT) {
status, err := mobileClient.Status()
assert.NoError(c, err)
// Mobile should see server as a peer (allowed by ACL)
serverStatus := serverClient.MustStatus()
_, hasPeer := status.Peer[serverStatus.Self.PublicKey]
assert.True(c, hasPeer, "mobile should see server as peer")
// Mobile should NOT see exit node in peer list at all since no ACL allows access
_, hasExitPeer := status.Peer[exitStatus.Self.PublicKey]
assert.False(c, hasExitPeer, "mobile should NOT see exit node as peer without autogroup:internet in ACL")
}, 10*time.Second, 500*time.Millisecond, "verifying mobile cannot see exit node")
// Server should also not see the exit node (no ACL rule allowing it)
assert.EventuallyWithT(t, func(c *assert.CollectT) {
status, err := serverClient.Status()
assert.NoError(c, err)
_, hasExitPeer := status.Peer[exitStatus.Self.PublicKey]
assert.False(c, hasExitPeer, "server should NOT see exit node as peer without autogroup:internet in ACL")
}, 10*time.Second, 500*time.Millisecond, "verifying server cannot see exit node")
}