Fix exit node visibility issue - filter based on autogroup:internet permission

- Modified tailNode/tailNodes functions to accept exitRouteFilterFunc parameter
- Added canUseExitRoutes helper to check for broad internet access permission
- Added DestsContainsPrefixes method to matcher for checking prefix containment
- Exit routes now only included in peer AllowedIPs when requesting node has internet access
- Added comprehensive unit tests for both scenarios (with and without autogroup:internet)

Fixes #2788

Co-authored-by: kradalby <98431+kradalby@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot]
2025-11-01 08:53:35 +00:00
parent 309437fa14
commit 31bf3a6637
5 changed files with 422 additions and 1 deletions

View File

@@ -7,12 +7,50 @@ import (
"time"
"github.com/juanfont/headscale/hscontrol/policy"
"github.com/juanfont/headscale/hscontrol/policy/matcher"
"github.com/juanfont/headscale/hscontrol/types"
"tailscale.com/tailcfg"
"tailscale.com/types/views"
"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.
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 these well-known public IPs, it's granting
// internet access (e.g., via autogroup:internet).
// Use popular public DNS servers as representatives of internet access.
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 public internet IPs.
// 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
@@ -81,6 +119,14 @@ func (b *MapResponseBuilder) WithSelfNode() *MapResponseBuilder {
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.mapper.cfg)
if err != nil {
b.addError(err)
@@ -256,6 +302,22 @@ func (b *MapResponseBuilder) buildTailPeers(peers views.Slice[types.NodeView]) (
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.mapper.cfg)
if err != nil {
return nil, err