mirror of
https://github.com/juanfont/headscale.git
synced 2026-01-16 14:06:46 +01:00
Compare commits
3 Commits
copilot/de
...
copilot/in
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4aa9292b91 | ||
|
|
31bf3a6637 | ||
|
|
309437fa14 |
1
.github/workflows/test-integration.yaml
vendored
1
.github/workflows/test-integration.yaml
vendored
@@ -62,7 +62,6 @@ jobs:
|
||||
- TestDERPServerScenario
|
||||
- TestDERPServerWebsocketScenario
|
||||
- TestPingAllByIP
|
||||
- TestPingAllByIPRandomClientPort
|
||||
- TestPingAllByIPPublicDERP
|
||||
- TestEphemeral
|
||||
- TestEphemeralInAlternateTimezone
|
||||
|
||||
6
go.sum
6
go.sum
@@ -124,8 +124,6 @@ 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=
|
||||
@@ -278,8 +276,6 @@ 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=
|
||||
@@ -463,8 +459,6 @@ 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=
|
||||
|
||||
@@ -7,12 +7,55 @@ 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.
|
||||
//
|
||||
// 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
|
||||
@@ -81,6 +124,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 +307,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
|
||||
|
||||
336
hscontrol/mapper/exit_node_visibility_test.go
Normal file
336
hscontrol/mapper/exit_node_visibility_test.go
Normal file
@@ -0,0 +1,336 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,7 @@ func tailNodes(
|
||||
capVer tailcfg.CapabilityVersion,
|
||||
checker NodeCanHaveTagChecker,
|
||||
primaryRouteFunc routeFilterFunc,
|
||||
exitRouteFunc routeFilterFunc,
|
||||
cfg *types.Config,
|
||||
) ([]*tailcfg.Node, error) {
|
||||
tNodes := make([]*tailcfg.Node, 0, nodes.Len())
|
||||
@@ -31,6 +32,7 @@ func tailNodes(
|
||||
capVer,
|
||||
checker,
|
||||
primaryRouteFunc,
|
||||
exitRouteFunc,
|
||||
cfg,
|
||||
)
|
||||
if err != nil {
|
||||
@@ -49,6 +51,7 @@ func tailNode(
|
||||
capVer tailcfg.CapabilityVersion,
|
||||
checker NodeCanHaveTagChecker,
|
||||
primaryRouteFunc routeFilterFunc,
|
||||
exitRouteFunc routeFilterFunc,
|
||||
cfg *types.Config,
|
||||
) (*tailcfg.Node, error) {
|
||||
addrs := node.Prefixes()
|
||||
@@ -90,7 +93,10 @@ func tailNode(
|
||||
|
||||
routes := primaryRouteFunc(node.ID())
|
||||
allowed := append(addrs, routes...)
|
||||
allowed = append(allowed, node.ExitRoutes()...)
|
||||
|
||||
// Only include exit routes if the exitRouteFunc allows them
|
||||
exitRoutes := exitRouteFunc(node.ID())
|
||||
allowed = append(allowed, exitRoutes...)
|
||||
tsaddr.SortPrefixes(allowed)
|
||||
|
||||
tNode := tailcfg.Node{
|
||||
|
||||
@@ -221,6 +221,13 @@ 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,
|
||||
)
|
||||
|
||||
@@ -281,6 +288,9 @@ 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 {
|
||||
|
||||
@@ -91,3 +91,10 @@ 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)
|
||||
}
|
||||
|
||||
@@ -86,108 +86,6 @@ func TestPingAllByIP(t *testing.T) {
|
||||
t.Logf("%d successful pings out of %d", success, len(allClients)*len(allIps))
|
||||
}
|
||||
|
||||
// TestPingAllByIPRandomClientPort is a variant of TestPingAllByIP that validates
|
||||
// direct connections between nodes with randomize_client_port enabled. This test
|
||||
// ensures that nodes can establish direct peer-to-peer connections without relying
|
||||
// on DERP relay servers, and that the randomize_client_port feature works correctly.
|
||||
func TestPingAllByIPRandomClientPort(t *testing.T) {
|
||||
IntegrationSkip(t)
|
||||
|
||||
spec := ScenarioSpec{
|
||||
NodesPerUser: len(MustTestVersions),
|
||||
Users: []string{"user1", "user2"},
|
||||
MaxWait: dockertestMaxWait(),
|
||||
}
|
||||
|
||||
scenario, err := NewScenario(spec)
|
||||
require.NoError(t, err)
|
||||
defer scenario.ShutdownAssertNoPanics(t)
|
||||
|
||||
err = scenario.CreateHeadscaleEnv(
|
||||
[]tsic.Option{},
|
||||
hsic.WithTestName("pingdirect"),
|
||||
hsic.WithEmbeddedDERPServerOnly(),
|
||||
hsic.WithTLS(),
|
||||
hsic.WithIPAllocationStrategy(types.IPAllocationStrategyRandom),
|
||||
hsic.WithConfigEnv(map[string]string{
|
||||
"HEADSCALE_RANDOMIZE_CLIENT_PORT": "true",
|
||||
}),
|
||||
)
|
||||
requireNoErrHeadscaleEnv(t, err)
|
||||
|
||||
allClients, err := scenario.ListTailscaleClients()
|
||||
requireNoErrListClients(t, err)
|
||||
|
||||
allIps, err := scenario.ListTailscaleClientsIPs()
|
||||
requireNoErrListClientIPs(t, err)
|
||||
|
||||
err = scenario.WaitForTailscaleSync()
|
||||
requireNoErrSync(t, err)
|
||||
|
||||
hs, err := scenario.Headscale()
|
||||
require.NoError(t, err)
|
||||
|
||||
// Extract node IDs for validation
|
||||
expectedNodes := make([]types.NodeID, 0, len(allClients))
|
||||
for _, client := range allClients {
|
||||
status := client.MustStatus()
|
||||
nodeID, err := strconv.ParseUint(string(status.Self.ID), 10, 64)
|
||||
require.NoError(t, err, "failed to parse node ID")
|
||||
expectedNodes = append(expectedNodes, types.NodeID(nodeID))
|
||||
}
|
||||
requireAllClientsOnline(t, hs, expectedNodes, true, "all clients should be online across all systems", 30*time.Second)
|
||||
|
||||
allAddrs := lo.Map(allIps, func(x netip.Addr, index int) string {
|
||||
return x.String()
|
||||
})
|
||||
|
||||
// Perform pings to establish connections
|
||||
success := pingAllHelper(t, allClients, allAddrs)
|
||||
t.Logf("%d successful pings out of %d", success, len(allClients)*len(allIps))
|
||||
|
||||
// Validate that connections are direct (not relayed through DERP)
|
||||
// We check that each client has direct connections to its peers
|
||||
t.Logf("Validating direct connections...")
|
||||
assert.EventuallyWithT(t, func(ct *assert.CollectT) {
|
||||
for _, client := range allClients {
|
||||
status, err := client.Status()
|
||||
assert.NoError(ct, err, "failed to get status for client %s", client.Hostname())
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// Check each peer to see if we have a direct connection
|
||||
directCount := 0
|
||||
relayedCount := 0
|
||||
|
||||
for _, peerKey := range status.Peers() {
|
||||
peerStatus := status.Peer[peerKey]
|
||||
|
||||
// CurAddr indicates the current address being used to communicate with this peer
|
||||
// Direct connections have CurAddr set to an actual IP:port
|
||||
// DERP-relayed connections either have no CurAddr or it contains the DERP magic IP
|
||||
if peerStatus.CurAddr != "" && !strings.Contains(peerStatus.CurAddr, "127.3.3.40") {
|
||||
// This is a direct connection - CurAddr contains the actual peer IP:port
|
||||
directCount++
|
||||
t.Logf("Client %s -> Peer %s: DIRECT connection via %s (relay: %s)",
|
||||
client.Hostname(), peerStatus.HostName, peerStatus.CurAddr, peerStatus.Relay)
|
||||
} else {
|
||||
// This is a relayed connection through DERP
|
||||
relayedCount++
|
||||
t.Logf("Client %s -> Peer %s: RELAYED connection (CurAddr: %s, relay: %s)",
|
||||
client.Hostname(), peerStatus.HostName, peerStatus.CurAddr, peerStatus.Relay)
|
||||
}
|
||||
}
|
||||
|
||||
// Assert that we have at least some direct connections
|
||||
// In a local Docker network, we should be able to establish direct connections
|
||||
assert.Greater(ct, directCount, 0,
|
||||
"Client %s should have at least one direct connection, got %d direct and %d relayed",
|
||||
client.Hostname(), directCount, relayedCount)
|
||||
}
|
||||
}, 60*time.Second, 2*time.Second, "validating direct connections between peers")
|
||||
}
|
||||
|
||||
func TestPingAllByIPPublicDERP(t *testing.T) {
|
||||
IntegrationSkip(t)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user