policy/v2: make a user's own devices peers under any policy

As SaaS. Without the peer, sending a file to your own device failed
with 404 under a restrictive policy. The packet filter is unchanged.
This commit is contained in:
Kristoffer Dalby committed 2026-11-03 17:05:00 +01:00
1 parent 420293cecf
commit 490305417c
11 files changed
+127 -107

No files matched your search

+2 -2
View File
@@ -2291,8 +2291,8 @@ func TestEvictedSessionLeavesPeerTrackingAlone(t *testing.T) {
td, cleanup := setupBatcherWithTestData(
t,
NewBatcherAndMapper,
1,
2,
1,
normalBufferSize,
)
defer cleanup()
@@ -2379,8 +2379,8 @@ func TestOlderInitialMapLeavesNewerStreamsPeers(t *testing.T) {
td, cleanup := setupBatcherWithTestData(
t,
NewBatcherAndMapper,
1,
2,
1,
normalBufferSize,
)
defer cleanup()
+2 -2
View File
@@ -385,7 +385,7 @@ func TestBatcherRetryUsesCurrentPolicyAfterReconnect(t *testing.T) {
}
func TestBatcherUnreadyRetryRegeneratesRemovals(t *testing.T) {
testData, cleanup := setupBatcherWithTestData(t, NewBatcherAndMapper, 1, 2, 1)
testData, cleanup := setupBatcherWithTestData(t, NewBatcherAndMapper, 2, 1, 1)
defer cleanup()
b := NewBatcher(time.Hour, 1, testData.Batcher.mapper)
@@ -1767,7 +1767,7 @@ func TestMultiChannelSend_SSHPolicyPerConnection(t *testing.T) {
}
func TestPartialPeerAdditionThenRevocation(t *testing.T) {
td, cleanup := setupBatcherWithTestData(t, NewBatcherAndMapper, 1, 2, 1)
td, cleanup := setupBatcherWithTestData(t, NewBatcherAndMapper, 2, 1, 1)
defer cleanup()
_, err := td.State.SetPolicy([]byte(`{"acls":[]}`))
+3 -3
View File
@@ -673,8 +673,8 @@ func TestBuildFromChangeVisibilityMatchesFullMap(t *testing.T) {
// wantFull pins the actual peer-visibility semantics so the cross-path
// check below cannot pass vacuously (e.g. if every path broke to zero
// identically).
// Note deny_all: an empty ACL set yields no peer adjacency, so nothing is
// visible on any path. user_isolation and autogroup_self remain the
// Note deny_all: an empty ACL set yields no peer adjacency beyond a user's
// own devices, on any path. user_isolation and autogroup_self remain the
// discriminating cases that prove filtering works.
tests := []struct {
name string
@@ -690,7 +690,7 @@ func TestBuildFromChangeVisibilityMatchesFullMap(t *testing.T) {
]}`,
1,
},
{"deny_all", `{"acls":[]}`, 0},
{"deny_all", `{"acls":[]}`, 1},
{
"autogroup_self",
`{"acls":[{"action":"accept","src":["autogroup:member"],"dst":["autogroup:self:*"]}]}`,
+1 -1
View File
@@ -197,7 +197,7 @@ func peerMapPrefixReference(
canAccessPrefixReference(b, a, mi) ||
canAccessPrefixReference(a, b, mj) ||
canAccessPrefixReference(b, a, mj)
if linkedByEitherPolicy {
if linkedByEitherPolicy || sameUser(a.View(), b.View()) {
peers[a.ID] = append(peers[a.ID], b.ID)
peers[b.ID] = append(peers[b.ID], a.ID)
}
+15 -10
View File
@@ -44,8 +44,10 @@ func buildPeerMapPairScan(pm *PolicyManager, nodes views.Slice[types.NodeView])
}
ri, rj := routeInfo[nodes.At(i).ID()], routeInfo[nodes.At(j).ID()]
if nodes.At(i).CanAccessWithRoutes(pm.matchers, nodes.At(j), ri.subnet, rj.subnet, rj.isExit) ||
nodes.At(j).CanAccessWithRoutes(pm.matchers, nodes.At(i), rj.subnet, ri.subnet, ri.isExit) {
linked := nodes.At(i).CanAccessWithRoutes(pm.matchers, nodes.At(j), ri.subnet, rj.subnet, rj.isExit) ||
nodes.At(j).CanAccessWithRoutes(pm.matchers, nodes.At(i), rj.subnet, ri.subnet, ri.isExit)
if linked || sameUser(nodes.At(i), nodes.At(j)) {
ret[nodes.At(i).ID()] = append(ret[nodes.At(i).ID()], nodes.At(j).ID())
ret[nodes.At(j).ID()] = append(ret[nodes.At(j).ID()], nodes.At(i).ID())
}
@@ -70,10 +72,13 @@ func buildPeerMapPairScan(pm *PolicyManager, nodes views.Slice[types.NodeView])
matchersJ, hasFilterJ := nodeMatchers[nodeJ.ID()]
riJ := routeInfo[nodeJ.ID()]
if hasFilterI && nodeI.CanAccessWithRoutes(matchersI, nodeJ, riI.subnet, riJ.subnet, riJ.isExit) ||
hasFilterJ && nodeJ.CanAccessWithRoutes(matchersJ, nodeI, riJ.subnet, riI.subnet, riI.isExit) ||
hasFilterI && nodeJ.CanAccessWithRoutes(matchersI, nodeI, riJ.subnet, riI.subnet, riI.isExit) ||
hasFilterJ && nodeI.CanAccessWithRoutes(matchersJ, nodeJ, riI.subnet, riJ.subnet, riJ.isExit) {
canIAccessJ := hasFilterI && nodeI.CanAccessWithRoutes(matchersI, nodeJ, riI.subnet, riJ.subnet, riJ.isExit)
canJAccessI := hasFilterJ && nodeJ.CanAccessWithRoutes(matchersJ, nodeI, riJ.subnet, riI.subnet, riI.isExit)
canJReachI := hasFilterI && nodeJ.CanAccessWithRoutes(matchersI, nodeI, riJ.subnet, riI.subnet, riI.isExit)
canIReachJ := hasFilterJ && nodeI.CanAccessWithRoutes(matchersJ, nodeJ, riI.subnet, riJ.subnet, riJ.isExit)
linked := canIAccessJ || canJAccessI || canJReachI || canIReachJ
if linked || sameUser(nodeI, nodeJ) {
ret[nodeI.ID()] = append(ret[nodeI.ID()], nodeJ.ID())
ret[nodeJ.ID()] = append(ret[nodeJ.ID()], nodeI.ID())
}
@@ -578,8 +583,8 @@ func buildPeerMapSortedSlice(
continue
}
if sortedMatcherSetsIntersect(srcOf[i], dstOf[j]) ||
sortedMatcherSetsIntersect(srcOf[j], dstOf[i]) {
linked := sortedMatcherSetsIntersect(srcOf[i], dstOf[j]) || sortedMatcherSetsIntersect(srcOf[j], dstOf[i])
if linked || sameUser(nodes.At(i), nodes.At(j)) {
ret[ids[i]] = append(ret[ids[i]], ids[j])
ret[ids[j]] = append(ret[ids[j]], ids[i])
}
@@ -674,8 +679,8 @@ func buildPeerMapSliceMap(
continue
}
if sliceMapMatcherSetsIntersect(srcOf[i], dstOf[j]) ||
sliceMapMatcherSetsIntersect(srcOf[j], dstOf[i]) {
linked := sliceMapMatcherSetsIntersect(srcOf[i], dstOf[j]) || sliceMapMatcherSetsIntersect(srcOf[j], dstOf[i])
if linked || sameUser(nodes.At(i), nodes.At(j)) {
ret[ids[i]] = append(ret[ids[i]], ids[j])
ret[ids[j]] = append(ret[ids[j]], ids[i])
}
+19 -2
View File
@@ -906,7 +906,8 @@ func (pm *PolicyManager) BuildPeerMap(nodes views.Slice[types.NodeView]) map[typ
continue
}
if canAccess(srcOf[i], dstOf[j]) || canAccess(srcOf[j], dstOf[i]) {
linked := canAccess(srcOf[i], dstOf[j]) || canAccess(srcOf[j], dstOf[i])
if linked || sameUser(nodes.At(i), nodes.At(j)) {
ret[ids[i]] = append(ret[ids[i]], ids[j])
ret[ids[j]] = append(ret[ids[j]], ids[i])
}
@@ -959,7 +960,8 @@ func (pm *PolicyManager) BuildPeerMap(nodes views.Slice[types.NodeView]) map[typ
canJReachI := hasFilterI && nodeJ.CanAccessWithRoutes(matchersI, nodeI, riJ.subnet, riI.subnet, riI.isExit)
canIReachJ := hasFilterJ && nodeI.CanAccessWithRoutes(matchersJ, nodeJ, riI.subnet, riJ.subnet, riJ.isExit)
if canIAccessJ || canJAccessI || canJReachI || canIReachJ {
linked := canIAccessJ || canJAccessI || canJReachI || canIReachJ
if linked || sameUser(nodeI, nodeJ) {
ret[nodeI.ID()] = append(ret[nodeI.ID()], nodeJ.ID())
ret[nodeJ.ID()] = append(ret[nodeJ.ID()], nodeI.ID())
}
@@ -983,6 +985,21 @@ func matcherReachesDestination(
(isExit && m.DestsIsTheInternet())
}
// sameUser reports whether two nodes belong to one user. A user's own devices
// are always each other's peers, whatever the rules say, as on SaaS: clients
// need the peer to send a file to their own device. It grants no access, so
// the packet filter still decides what can connect.
func sameUser(a, b types.NodeView) bool {
if a.IsTagged() || b.IsTagged() {
return false
}
userA, okA := a.UserID().GetOk()
userB, okB := b.UserID().GetOk()
return okA && okB && userA == userB
}
// filterRulesForNodeLocked returns the unreduced compiled filter rules
// for a node, combining pre-compiled global rules with per-node self
// and via rules from the stored compiled grants.
@@ -189,15 +189,6 @@ var routesSkipReasons = map[string]string{
"routes-m4-member-self/m-odin1/filter": filterAddressForm,
"routes-m4-member-self/m-odin2/filter": filterAddressForm,
"routes-m4-member-self/r-exit/filter": "FILTER_ADDRESS_FORM: same addresses, different wire form and rule order",
"routes-m1-wildcard-src-multi-range/m-odin1/peers": sameUserPeers,
"routes-m1-wildcard-src-multi-range/m-odin2/peers": sameUserPeers,
"routes-m2-member-partial-subnet/m-odin1/peers": sameUserPeers,
"routes-m2-member-partial-subnet/m-odin2/peers": sameUserPeers,
"routes-m3-subnet-route-as-src/m-odin1/peers": sameUserPeers,
"routes-m3-subnet-route-as-src/m-odin2/peers": sameUserPeers,
"routes-m6-subnet-to-subnet-multi-route/m-odin1/peers": sameUserPeers,
"routes-m6-subnet-to-subnet-multi-route/m-odin2/peers": sameUserPeers,
}
// routesNotCompared lists, by key prefix, checks left out: SaaS's answer
@@ -276,8 +267,6 @@ func notCompared(prefixes []string, key string) bool {
const filterAddressForm = "FILTER_ADDRESS_FORM: same addresses, different wire form"
const sameUserPeers = "SAME_USER_PEERS: SaaS lists a user's own devices as each other's peers with no rule linking them; headscale peers only what the policy links"
// routesSetByAPI reports whether the capture's routes were approved through
// the routes API rather than by the policy.
func routesSetByAPI(c *testcapture.Capture) bool {
@@ -63,9 +63,6 @@ var mapStreamSkipReasons = map[string]string{
"map-delivery/S2.5/laptop/endpoints-gap-bystander": restartEndpointsKept,
"map-delivery/S2.5/laptop/endpoints-gap-svc": restartEndpointsKept,
"map-delivery/S3.6/bystander/peers": sameUserPeers,
"map-delivery/S3.6/laptop/peers": sameUserPeers,
"hidden-peers/S2/witness/presence": policyResponseToEveryNode,
"hidden-peers/S8/observer/presence": policyResponseToEveryNode,
"hidden-peers/S9/observer/presence": policyResponseToEveryNode,
@@ -81,19 +78,6 @@ var mapStreamSkipReasons = map[string]string{
"route-announce/RA9/rtr/presence": policyResponseToEveryNode,
"route-announce/RA9/srv/presence": policyResponseToEveryNode,
"route-announce/RA9/wit/presence": policyResponseToEveryNode,
"policy-generation/G2/thor-1/peers": sameUserPeers,
"policy-generation/G2/thor-2/peers": sameUserPeers,
"policy-generation/G3/thor-1/peers": sameUserPeers,
"policy-generation/G3/thor-2/peers": sameUserPeers,
"policy-generation/G4/thor-1/peers": sameUserPeers,
"policy-generation/G4/thor-2/peers": sameUserPeers,
"policy-generation/G2/thor-1/peer-owners": sameUserPeers,
"policy-generation/G2/thor-2/peer-owners": sameUserPeers,
"policy-generation/G3/thor-1/peer-owners": sameUserPeers,
"policy-generation/G3/thor-2/peer-owners": sameUserPeers,
"policy-generation/G4/thor-1/peer-owners": sameUserPeers,
"policy-generation/G4/thor-2/peer-owners": sameUserPeers,
}
const (
+80 -48
View File
@@ -30,7 +30,6 @@ type scaleCase struct {
policy string
taggedService bool
ssh bool
peerCount func(node, total int) int
canSend func(source, destination int) bool
}
@@ -47,10 +46,6 @@ func TestScale(t *testing.T) {
require.GreaterOrEqual(t, nodes, 2, "need at least two connected nodes")
}
personalPeers := func(node, total int) int {
first := node / scaleDevicesPerUser * scaleDevicesPerUser
return min(scaleDevicesPerUser, total-first) - 1
}
personalVisibility := func(node, peer int) bool {
return node/scaleDevicesPerUser == peer/scaleDevicesPerUser
}
@@ -61,14 +56,12 @@ func TestScale(t *testing.T) {
policy: `{
"grants": [{"src": ["*"], "dst": ["*"], "ip": ["tcp:443"]}]
}`,
peerCount: func(_, total int) int { return total - 1 },
canSend: func(_, _ int) bool { return true },
canSend: func(_, _ int) bool { return true },
},
{
name: "isolated",
policy: `{"acls": []}`,
peerCount: func(_, _ int) int { return 0 },
canSend: func(_, _ int) bool { return false },
name: "isolated",
policy: `{"acls": []}`,
canSend: func(_, _ int) bool { return false },
},
{
name: "personal_devices",
@@ -78,8 +71,7 @@ func TestScale(t *testing.T) {
"ip": ["tcp:443"]
}]
}`,
peerCount: personalPeers,
canSend: personalVisibility,
canSend: personalVisibility,
},
{
name: "shared_service",
@@ -91,14 +83,7 @@ func TestScale(t *testing.T) {
}]
}`,
taggedService: true,
peerCount: func(node, total int) int {
if node == 0 {
return total - 1
}
return 1
},
canSend: func(source, destination int) bool { return source != 0 && destination == 0 },
canSend: func(source, destination int) bool { return source != 0 && destination == 0 },
},
{
name: "personal_ssh",
@@ -112,9 +97,8 @@ func TestScale(t *testing.T) {
"dst": ["autogroup:self"], "users": ["root"]
}]
}`,
ssh: true,
peerCount: personalPeers,
canSend: personalVisibility,
ssh: true,
canSend: personalVisibility,
},
}
@@ -220,10 +204,7 @@ func runScale(t *testing.T, nodes int, tc scaleCase) {
deadline := time.Now().Add(convergenceTimeout)
for i, c := range clients {
wantPeers := tc.peerCount(i, nodes)
if revoked {
wantPeers = 0
}
wantPeers := tc.peerCount(i, nodes, revoked)
c.WaitForCondition(
t,
@@ -244,10 +225,13 @@ func runScale(t *testing.T, nodes int, tc scaleCase) {
return false
}
return tc.peersMatch(nm, i, indices, osMarker)
// The policy's attribute can reach the node a frame ahead of
// its filter, and peers alone may not tell the two apart.
return tc.peersMatch(nm, i, indices, osMarker) &&
scalePacketFilterMismatch(tc, nm, selfNodes, i, revoked) == ""
},
)
checkScalePacketFilter(t, tc, c.Netmap(), selfNodes, i, revoked)
require.Empty(t, scalePacketFilterMismatch(tc, c.Netmap(), selfNodes, i, revoked))
if tc.ssh && !revoked {
checkScaleSSH(t, c.Netmap().SSHPolicy, selfNodes, i)
@@ -337,6 +321,40 @@ func runScale(t *testing.T, nodes int, tc scaleCase) {
})
}
// peerCount is how many peers viewer has: the nodes it can reach or be reached
// from, and its user's other devices, which are peers under any policy.
func (tc scaleCase) peerCount(viewer, total int, revoked bool) int {
count := 0
for j := range total {
if j == viewer {
continue
}
reachable := !revoked && tc.linked(viewer, j)
if reachable || tc.sameUser(viewer, j) {
count++
}
}
return count
}
// sameUser reports whether two nodes are devices of one user. The tagged
// service belongs to no user.
func (tc scaleCase) sameUser(a, b int) bool {
if tc.taggedService && (a == 0 || b == 0) {
return false
}
return a/scaleDevicesPerUser == b/scaleDevicesPerUser
}
// linked reports whether the policy lets either node reach the other.
func (tc scaleCase) linked(a, b int) bool {
return tc.canSend(a, b) || tc.canSend(b, a)
}
func (tc scaleCase) peersMatch(
nm *netmap.NetworkMap,
viewer int,
@@ -347,8 +365,11 @@ func (tc scaleCase) peersMatch(
for _, peer := range nm.Peers {
j, ok := indices[peer.ID()]
if !ok || j == viewer || (!tc.canSend(viewer, j) && !tc.canSend(j, viewer)) ||
peer.ID() <= previous {
if !ok || j == viewer || peer.ID() <= previous {
return false
}
if !tc.linked(viewer, j) && !tc.sameUser(viewer, j) {
return false
}
@@ -405,24 +426,25 @@ func updateScaleClients(
}
}
// Probe a few sources per node instead of making the test itself all-pairs.
func checkScalePacketFilter(
t *testing.T,
// scalePacketFilterMismatch names the first verdict nm's packet filter gets
// wrong, or returns "". Probe a few sources per node instead of making the test
// itself all-pairs.
func scalePacketFilterMismatch(
tc scaleCase,
nm *netmap.NetworkMap,
selfNodes []tailcfg.NodeView,
viewer int,
revoked bool,
) {
t.Helper()
) string {
var localIPs netipx.IPSetBuilder
for _, prefix := range nm.SelfNode.Addresses().All() {
localIPs.AddPrefix(prefix)
}
local, err := localIPs.IPSet()
require.NoError(t, err)
if err != nil {
return err.Error()
}
packetFilter := filter.New(nm.PacketFilter, nil, local, nil, nil, logger.Discard)
@@ -439,20 +461,30 @@ func checkScalePacketFilter(
want = filter.Accept
}
require.Equal(t, want, packetFilter.CheckTCP(src, dst.Addr(), 443),
"%d -> %d TCP 443", selfNodes[source].ID(), selfNodes[viewer].ID())
require.Equal(t, filter.Drop, packetFilter.CheckTCP(src, dst.Addr(), 80))
require.Equal(
t,
filter.Drop,
packetFilter.Check(src, dst.Addr(), 443, ipproto.UDP),
)
type verdict struct {
name string
want, got filter.Response
}
checks := []verdict{
{"TCP 443", want, packetFilter.CheckTCP(src, dst.Addr(), 443)},
{"TCP 80", filter.Drop, packetFilter.CheckTCP(src, dst.Addr(), 80)},
{"UDP 443", filter.Drop, packetFilter.Check(src, dst.Addr(), 443, ipproto.UDP)},
}
if tc.ssh {
require.Equal(t, want, packetFilter.CheckTCP(src, dst.Addr(), 22))
checks = append(checks, verdict{"TCP 22", want, packetFilter.CheckTCP(src, dst.Addr(), 22)})
}
for _, check := range checks {
if check.got != check.want {
return fmt.Sprintf("%d -> %d %s: got %v, want %v",
selfNodes[source].ID(), selfNodes[viewer].ID(), check.name, check.got, check.want)
}
}
}
}
return ""
}
func checkScaleSSH(
-11
View File
@@ -143,19 +143,8 @@ var aclMapCompatTests = []string{
// same way. A listed check that matches fails, so a fix must delete its entry.
var viaCompatSkipReasons = map[string]string{
"tagged-self": "TAGGED_SELF_PROFILE: SaaS gives a tagged node a profile of its own, headscale the shared tagged-devices one",
"routes-m1-wildcard-src-multi-range/m-odin1/peers": sameUserPeers,
"routes-m1-wildcard-src-multi-range/m-odin2/peers": sameUserPeers,
"routes-m2-member-partial-subnet/m-odin1/peers": sameUserPeers,
"routes-m2-member-partial-subnet/m-odin2/peers": sameUserPeers,
"routes-m3-subnet-route-as-src/m-odin1/peers": sameUserPeers,
"routes-m3-subnet-route-as-src/m-odin2/peers": sameUserPeers,
"routes-m6-subnet-to-subnet-multi-route/m-odin1/peers": sameUserPeers,
"routes-m6-subnet-to-subnet-multi-route/m-odin2/peers": sameUserPeers,
}
const sameUserPeers = "SAME_USER_PEERS: SaaS lists a user's own devices as each other's peers with no rule linking them; headscale peers only what the policy links"
// TestRoutesMapCompat replays routesMapCompatTests through
// [runViaMapCompat].
func TestRoutesMapCompat(t *testing.T) {
+5 -1
View File
@@ -25,7 +25,11 @@ func TestReloadPolicyRebuildsPeerMapOnChangeOnly(t *testing.T) {
database, err := db.NewHeadscaleDatabase(cfg)
require.NoError(t, err)
nodes := database.CreateRegisteredNodesForTest(database.CreateUserForTest("reload-user"), 2, "reload-node")
// Two users: a user's own devices stay peers under any policy.
nodes := append(
database.CreateRegisteredNodesForTest(database.CreateUserForTest("reload-user"), 1, "reload-node"),
database.CreateRegisteredNodesForTest(database.CreateUserForTest("reload-other"), 1, "reload-other")...,
)
require.NoError(t, database.Close())
s, err := NewState(cfg)