mapper: give a tagged node its own user in its own entry

As SaaS. Clients take a peer with their own user ID for a device of the same
owner, so every tagged peer was a file target and could read debug endpoints.
Updates #2462, #2970
This commit is contained in:
Kristoffer Dalby committed 2026-11-03 17:05:00 +01:00
1 parent 490305417c
commit 93aef86908
3 files changed
+63 -27

No files matched your search

+27
View File
@@ -6,6 +6,7 @@ import (
"fmt"
"net/netip"
"slices"
"strings"
"time"
policyv2 "github.com/juanfont/headscale/hscontrol/policy/v2"
@@ -102,6 +103,10 @@ func (b *MapResponseBuilder) WithSelfNode() *MapResponseBuilder {
return b
}
if nv.IsTagged() {
tailnode.User = taggedSelfUserID(nv.ID())
}
// PrimaryRoutes is what the router serves: the routes it is primary
// for, under the rule its peers get them by.
tailnode.PrimaryRoutes = slices.DeleteFunc(
@@ -204,9 +209,31 @@ func (b *MapResponseBuilder) WithUserProfiles(peers views.Slice[types.NodeView])
b.resp.UserProfiles = generateUserProfiles(node, peers)
if node.IsTagged() {
fqdn, err := node.GetFQDN(b.mapper.cfg.BaseDomain)
if err != nil {
b.addError(err)
return b
}
b.resp.UserProfiles = append(b.resp.UserProfiles, tailcfg.UserProfile{
ID: taggedSelfUserID(node.ID()),
LoginName: strings.TrimSuffix(fqdn, "."),
DisplayName: node.GivenName(),
})
}
return b
}
// taggedSelfUserID is the user a tagged node sees itself as: its own, as on
// SaaS, past every real user ID. Clients take a peer with their own user ID for
// a device of the same owner, so with the shared tagged-devices ID every tagged
// peer looked like one: a file target, and a reader of its debug endpoints.
func taggedSelfUserID(id types.NodeID) tailcfg.UserID {
return tailcfg.UserID(types.TaggedDevicesUserID) + 1 + tailcfg.UserID(id) //nolint:gosec // node IDs fit in int64
}
// WithPacketFilters adds packet filter rules based on policy.
//
// [State.FilterForNode] returns rules already reduced to only those relevant for this node.
+25 -15
View File
@@ -141,9 +141,7 @@ var aclMapCompatTests = []string{
// viaCompatSkipReasons lists checks where headscale differs from SaaS, keyed
// <test_id>/<node>/<check>, or by check alone where every capture differs the
// 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",
}
var viaCompatSkipReasons = map[string]string{}
// TestRoutesMapCompat replays routesMapCompatTests through
// [runViaMapCompat].
@@ -953,22 +951,31 @@ func compareUserProfiles(
check := "peer"
if name == "" {
check = "self"
if want.SelfNode.Tags().Len() > 0 {
check = "tagged-self"
}
}
if reason, listed := viaCompatSkipReasons[check]; listed {
if gotProfiles[name] == wantProfile {
t.Errorf("node %q: %s profile matches SaaS; delete viaCompatSkipReasons[%q] (%s)",
name, check, check, reason)
}
continue
}
assert.Equalf(t, wantProfile, gotProfiles[name], "node %q: %s profile mismatch", name, check)
}
// Clients take a peer with their own user ID for a device of the same owner.
sameOwner := func(nm *netmap.NetworkMap) map[string]bool {
out := map[string]bool{}
for _, peer := range nm.Peers {
name := extractHostname(peer.Name())
if _, isOurs := clients[name]; isOurs {
out[name] = peer.User() == nm.SelfNode.User()
}
}
return out
}
gotSame := sameOwner(got)
for name, wantSame := range sameOwner(want) {
if same, ok := gotSame[name]; ok {
assert.Equalf(t, wantSame, same, "peer %q: same owner as the viewer", name)
}
}
}
// nodeProfiles maps "" (self) and each peer in clients to its user profile.
@@ -987,6 +994,9 @@ func nodeProfiles(
login = name
}
// A tagged node's login is its name, under each side's own domain.
login, _, _ = strings.Cut(login, ".")
return login + " / " + p.DisplayName()
}
+11 -12
View File
@@ -694,9 +694,10 @@ func TestPreAuthKeyCorrectUserLoggedInCommand(t *testing.T) {
status, err := client.Status()
assert.NoError(ct, err)
assert.Equal(ct, "Running", status.BackendState, "Expected node to be logged in, backend state: %s", status.BackendState)
// With tags-as-identity model, tagged nodes show as [types.TaggedDevices] user (2147455555)
// The PreAuthKey was created with tags, so the node is tagged
assert.Equal(ct, "userid:2147455555", status.Self.UserID.String(), "Expected node to be logged in as tagged-devices user")
// The PreAuthKey was created with tags, so the node is tagged, and a
// tagged node sees itself as a user of its own, named after it, as on SaaS.
assert.Equal(ct, strings.TrimSuffix(status.Self.DNSName, "."), status.User[status.Self.UserID].LoginName,
"Expected the tagged node to be a user of its own")
}, integrationutil.StatusReadyTimeout, 2*time.Second)
assert.EventuallyWithT(t, func(ct *assert.CollectT) {
@@ -810,8 +811,9 @@ func TestTaggedNodesCLIOutput(t *testing.T) {
status, err := client.Status()
assert.NoError(ct, err)
assert.Equal(ct, "Running", status.BackendState, "Expected node to be logged in, backend state: %s", status.BackendState)
// With tags-as-identity model, tagged nodes show as [types.TaggedDevices] user (2147455555)
assert.Equal(ct, "userid:2147455555", status.Self.UserID.String(), "Expected node to be logged in as tagged-devices user")
// A tagged node sees itself as a user of its own, named after it, as on SaaS.
assert.Equal(ct, strings.TrimSuffix(status.Self.DNSName, "."), status.User[status.Self.UserID].LoginName,
"Expected the tagged node to be a user of its own")
}, integrationutil.StatusReadyTimeout, 2*time.Second)
// A tagged key converts the machine's user-owned node in place.
@@ -826,19 +828,16 @@ func TestTaggedNodesCLIOutput(t *testing.T) {
}
}, integrationutil.ScaledTimeout(20*time.Second), 1*time.Second)
// Test: tailscale status output should show "tagged-devices" not "userid:2147455555"
// This is the fix for issue #2970 - the Tailscale client should display user-friendly names
// Test: tailscale status names a tagged node, not "userid:<n>". This is the
// fix for issue #2970; the node is its own user, named after it, as on SaaS.
assert.EventuallyWithT(t, func(ct *assert.CollectT) {
stdout, stderr, err := client.Execute([]string{"tailscale", "status"})
assert.NoError(ct, err, "tailscale status command should succeed, stderr: %s", stderr)
t.Logf("Tailscale status output:\n%s", stdout)
// The output should contain "tagged-devices" for tagged nodes
assert.Contains(ct, stdout, "tagged-devices", "Tailscale status should show 'tagged-devices' for tagged nodes")
// The output should NOT show the raw numeric userid to the user
assert.NotContains(ct, stdout, "userid:2147455555", "Tailscale status should not show numeric userid for tagged nodes")
// The output should NOT show a raw numeric userid to the user
assert.NotContains(ct, stdout, "userid:", "Tailscale status should not show numeric userid for tagged nodes")
}, integrationutil.ScaledTimeout(20*time.Second), 1*time.Second)
}