mirror of
https://github.com/juanfont/headscale.git
synced 2026-04-14 04:50:00 +02:00
Compare commits
4 Commits
copilot/in
...
copilot/in
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1f4b645d5b | ||
|
|
4fa1f4baa3 | ||
|
|
e0107024e8 | ||
|
|
a55cdc2636 |
6
go.sum
6
go.sum
@@ -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=
|
||||
|
||||
@@ -7,6 +7,7 @@ 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"
|
||||
@@ -67,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)
|
||||
@@ -78,9 +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)
|
||||
},
|
||||
b.buildRouteFilterFunc(nv, matchers),
|
||||
b.mapper.cfg)
|
||||
if err != nil {
|
||||
b.addError(err)
|
||||
@@ -253,9 +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)
|
||||
},
|
||||
b.buildRouteFilterFunc(node, matchers),
|
||||
b.mapper.cfg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -57,10 +57,10 @@ func TestMapResponseBuilder_WithCapabilityVersion(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestMapResponseBuilder_WithDomain(t *testing.T) {
|
||||
baseDomain := "internal.example.com"
|
||||
domain := "test.example.com"
|
||||
cfg := &types.Config{
|
||||
ServerURL: "https://headscale.external.com",
|
||||
BaseDomain: baseDomain,
|
||||
ServerURL: "https://test.example.com",
|
||||
BaseDomain: domain,
|
||||
}
|
||||
|
||||
mockState := &state.State{}
|
||||
@@ -74,8 +74,7 @@ func TestMapResponseBuilder_WithDomain(t *testing.T) {
|
||||
builder := m.NewMapResponseBuilder(nodeID).
|
||||
WithDomain()
|
||||
|
||||
// Domain should be the BaseDomain (internal tailnet domain), not ServerURL hostname
|
||||
assert.Equal(t, baseDomain, builder.resp.Domain)
|
||||
assert.Equal(t, domain, builder.resp.Domain)
|
||||
assert.False(t, builder.hasErrors())
|
||||
}
|
||||
|
||||
|
||||
@@ -88,9 +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...)
|
||||
allowed = append(allowed, node.ExitRoutes()...)
|
||||
tsaddr.SortPrefixes(allowed)
|
||||
|
||||
tNode := tailcfg.Node{
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -246,11 +246,15 @@ func validatePKCEMethod(method string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Domain returns the base domain for the tailnet.
|
||||
// This is the domain used for MagicDNS and displayed in Tailscale clients
|
||||
// as the network name.
|
||||
// Domain returns the hostname/domain part of the ServerURL.
|
||||
// If the ServerURL is not a valid URL, it returns the BaseDomain.
|
||||
func (c *Config) Domain() string {
|
||||
return c.BaseDomain
|
||||
u, err := url.Parse(c.ServerURL)
|
||||
if err != nil {
|
||||
return c.BaseDomain
|
||||
}
|
||||
|
||||
return u.Hostname()
|
||||
}
|
||||
|
||||
// LoadConfig prepares and loads the Headscale configuration into Viper.
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user