diff --git a/hscontrol/mapper/builder.go b/hscontrol/mapper/builder.go index b37cba928..9cf89568a 100644 --- a/hscontrol/mapper/builder.go +++ b/hscontrol/mapper/builder.go @@ -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. diff --git a/hscontrol/servertest/via_compat_test.go b/hscontrol/servertest/via_compat_test.go index 6db3efc2c..63b742ccf 100644 --- a/hscontrol/servertest/via_compat_test.go +++ b/hscontrol/servertest/via_compat_test.go @@ -141,9 +141,7 @@ var aclMapCompatTests = []string{ // viaCompatSkipReasons lists checks where headscale differs from SaaS, keyed // //, 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() } diff --git a/integration/cli_nodes_test.go b/integration/cli_nodes_test.go index 9dc0d65c6..1cc93cce8 100644 --- a/integration/cli_nodes_test.go +++ b/integration/cli_nodes_test.go @@ -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:". 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) }