From 72fcb93ef3ae99a5f9fe769a51ea0d50ef702b97 Mon Sep 17 00:00:00 2001 From: Kristoffer Dalby Date: Fri, 9 Jan 2026 16:31:23 +0100 Subject: [PATCH] cli: ensure tagged-devices is included in profile list (#2991) --- .github/workflows/test-integration.yaml | 1 + gen/go/headscale/v1/apikey.pb.go | 2 +- gen/go/headscale/v1/device.pb.go | 2 +- gen/go/headscale/v1/headscale.pb.go | 2 +- gen/go/headscale/v1/headscale_grpc.pb.go | 54 +++++------ gen/go/headscale/v1/node.pb.go | 2 +- gen/go/headscale/v1/policy.pb.go | 2 +- gen/go/headscale/v1/preauthkey.pb.go | 2 +- gen/go/headscale/v1/user.pb.go | 2 +- hscontrol/auth.go | 10 +- hscontrol/mapper/mapper.go | 5 +- hscontrol/state/debug.go | 4 +- hscontrol/state/node_store.go | 18 +++- hscontrol/types/node.go | 8 +- integration/cli_test.go | 117 +++++++++++++++++++++++ 15 files changed, 184 insertions(+), 47 deletions(-) diff --git a/.github/workflows/test-integration.yaml b/.github/workflows/test-integration.yaml index a1b61cdb..dd7eb971 100644 --- a/.github/workflows/test-integration.yaml +++ b/.github/workflows/test-integration.yaml @@ -165,6 +165,7 @@ jobs: - TestPreAuthKeyCommandWithoutExpiry - TestPreAuthKeyCommandReusableEphemeral - TestPreAuthKeyCorrectUserLoggedInCommand + - TestTaggedNodesCLIOutput - TestApiKeyCommand - TestNodeCommand - TestNodeExpireCommand diff --git a/gen/go/headscale/v1/apikey.pb.go b/gen/go/headscale/v1/apikey.pb.go index a9f6a7b8..e3e31ad2 100644 --- a/gen/go/headscale/v1/apikey.pb.go +++ b/gen/go/headscale/v1/apikey.pb.go @@ -1,6 +1,6 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: -// protoc-gen-go v1.36.10 +// protoc-gen-go v1.36.11 // protoc (unknown) // source: headscale/v1/apikey.proto diff --git a/gen/go/headscale/v1/device.pb.go b/gen/go/headscale/v1/device.pb.go index 8b150f96..e2362b05 100644 --- a/gen/go/headscale/v1/device.pb.go +++ b/gen/go/headscale/v1/device.pb.go @@ -1,6 +1,6 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: -// protoc-gen-go v1.36.10 +// protoc-gen-go v1.36.11 // protoc (unknown) // source: headscale/v1/device.proto diff --git a/gen/go/headscale/v1/headscale.pb.go b/gen/go/headscale/v1/headscale.pb.go index a9d84b3d..3d16778c 100644 --- a/gen/go/headscale/v1/headscale.pb.go +++ b/gen/go/headscale/v1/headscale.pb.go @@ -1,6 +1,6 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: -// protoc-gen-go v1.36.10 +// protoc-gen-go v1.36.11 // protoc (unknown) // source: headscale/v1/headscale.proto diff --git a/gen/go/headscale/v1/headscale_grpc.pb.go b/gen/go/headscale/v1/headscale_grpc.pb.go index 5704866b..a3963935 100644 --- a/gen/go/headscale/v1/headscale_grpc.pb.go +++ b/gen/go/headscale/v1/headscale_grpc.pb.go @@ -1,6 +1,6 @@ // Code generated by protoc-gen-go-grpc. DO NOT EDIT. // versions: -// - protoc-gen-go-grpc v1.5.1 +// - protoc-gen-go-grpc v1.6.0 // - protoc (unknown) // source: headscale/v1/headscale.proto @@ -387,79 +387,79 @@ type HeadscaleServiceServer interface { type UnimplementedHeadscaleServiceServer struct{} func (UnimplementedHeadscaleServiceServer) CreateUser(context.Context, *CreateUserRequest) (*CreateUserResponse, error) { - return nil, status.Errorf(codes.Unimplemented, "method CreateUser not implemented") + return nil, status.Error(codes.Unimplemented, "method CreateUser not implemented") } func (UnimplementedHeadscaleServiceServer) RenameUser(context.Context, *RenameUserRequest) (*RenameUserResponse, error) { - return nil, status.Errorf(codes.Unimplemented, "method RenameUser not implemented") + return nil, status.Error(codes.Unimplemented, "method RenameUser not implemented") } func (UnimplementedHeadscaleServiceServer) DeleteUser(context.Context, *DeleteUserRequest) (*DeleteUserResponse, error) { - return nil, status.Errorf(codes.Unimplemented, "method DeleteUser not implemented") + return nil, status.Error(codes.Unimplemented, "method DeleteUser not implemented") } func (UnimplementedHeadscaleServiceServer) ListUsers(context.Context, *ListUsersRequest) (*ListUsersResponse, error) { - return nil, status.Errorf(codes.Unimplemented, "method ListUsers not implemented") + return nil, status.Error(codes.Unimplemented, "method ListUsers not implemented") } func (UnimplementedHeadscaleServiceServer) CreatePreAuthKey(context.Context, *CreatePreAuthKeyRequest) (*CreatePreAuthKeyResponse, error) { - return nil, status.Errorf(codes.Unimplemented, "method CreatePreAuthKey not implemented") + return nil, status.Error(codes.Unimplemented, "method CreatePreAuthKey not implemented") } func (UnimplementedHeadscaleServiceServer) ExpirePreAuthKey(context.Context, *ExpirePreAuthKeyRequest) (*ExpirePreAuthKeyResponse, error) { - return nil, status.Errorf(codes.Unimplemented, "method ExpirePreAuthKey not implemented") + return nil, status.Error(codes.Unimplemented, "method ExpirePreAuthKey not implemented") } func (UnimplementedHeadscaleServiceServer) DeletePreAuthKey(context.Context, *DeletePreAuthKeyRequest) (*DeletePreAuthKeyResponse, error) { - return nil, status.Errorf(codes.Unimplemented, "method DeletePreAuthKey not implemented") + return nil, status.Error(codes.Unimplemented, "method DeletePreAuthKey not implemented") } func (UnimplementedHeadscaleServiceServer) ListPreAuthKeys(context.Context, *ListPreAuthKeysRequest) (*ListPreAuthKeysResponse, error) { - return nil, status.Errorf(codes.Unimplemented, "method ListPreAuthKeys not implemented") + return nil, status.Error(codes.Unimplemented, "method ListPreAuthKeys not implemented") } func (UnimplementedHeadscaleServiceServer) DebugCreateNode(context.Context, *DebugCreateNodeRequest) (*DebugCreateNodeResponse, error) { - return nil, status.Errorf(codes.Unimplemented, "method DebugCreateNode not implemented") + return nil, status.Error(codes.Unimplemented, "method DebugCreateNode not implemented") } func (UnimplementedHeadscaleServiceServer) GetNode(context.Context, *GetNodeRequest) (*GetNodeResponse, error) { - return nil, status.Errorf(codes.Unimplemented, "method GetNode not implemented") + return nil, status.Error(codes.Unimplemented, "method GetNode not implemented") } func (UnimplementedHeadscaleServiceServer) SetTags(context.Context, *SetTagsRequest) (*SetTagsResponse, error) { - return nil, status.Errorf(codes.Unimplemented, "method SetTags not implemented") + return nil, status.Error(codes.Unimplemented, "method SetTags not implemented") } func (UnimplementedHeadscaleServiceServer) SetApprovedRoutes(context.Context, *SetApprovedRoutesRequest) (*SetApprovedRoutesResponse, error) { - return nil, status.Errorf(codes.Unimplemented, "method SetApprovedRoutes not implemented") + return nil, status.Error(codes.Unimplemented, "method SetApprovedRoutes not implemented") } func (UnimplementedHeadscaleServiceServer) RegisterNode(context.Context, *RegisterNodeRequest) (*RegisterNodeResponse, error) { - return nil, status.Errorf(codes.Unimplemented, "method RegisterNode not implemented") + return nil, status.Error(codes.Unimplemented, "method RegisterNode not implemented") } func (UnimplementedHeadscaleServiceServer) DeleteNode(context.Context, *DeleteNodeRequest) (*DeleteNodeResponse, error) { - return nil, status.Errorf(codes.Unimplemented, "method DeleteNode not implemented") + return nil, status.Error(codes.Unimplemented, "method DeleteNode not implemented") } func (UnimplementedHeadscaleServiceServer) ExpireNode(context.Context, *ExpireNodeRequest) (*ExpireNodeResponse, error) { - return nil, status.Errorf(codes.Unimplemented, "method ExpireNode not implemented") + return nil, status.Error(codes.Unimplemented, "method ExpireNode not implemented") } func (UnimplementedHeadscaleServiceServer) RenameNode(context.Context, *RenameNodeRequest) (*RenameNodeResponse, error) { - return nil, status.Errorf(codes.Unimplemented, "method RenameNode not implemented") + return nil, status.Error(codes.Unimplemented, "method RenameNode not implemented") } func (UnimplementedHeadscaleServiceServer) ListNodes(context.Context, *ListNodesRequest) (*ListNodesResponse, error) { - return nil, status.Errorf(codes.Unimplemented, "method ListNodes not implemented") + return nil, status.Error(codes.Unimplemented, "method ListNodes not implemented") } func (UnimplementedHeadscaleServiceServer) BackfillNodeIPs(context.Context, *BackfillNodeIPsRequest) (*BackfillNodeIPsResponse, error) { - return nil, status.Errorf(codes.Unimplemented, "method BackfillNodeIPs not implemented") + return nil, status.Error(codes.Unimplemented, "method BackfillNodeIPs not implemented") } func (UnimplementedHeadscaleServiceServer) CreateApiKey(context.Context, *CreateApiKeyRequest) (*CreateApiKeyResponse, error) { - return nil, status.Errorf(codes.Unimplemented, "method CreateApiKey not implemented") + return nil, status.Error(codes.Unimplemented, "method CreateApiKey not implemented") } func (UnimplementedHeadscaleServiceServer) ExpireApiKey(context.Context, *ExpireApiKeyRequest) (*ExpireApiKeyResponse, error) { - return nil, status.Errorf(codes.Unimplemented, "method ExpireApiKey not implemented") + return nil, status.Error(codes.Unimplemented, "method ExpireApiKey not implemented") } func (UnimplementedHeadscaleServiceServer) ListApiKeys(context.Context, *ListApiKeysRequest) (*ListApiKeysResponse, error) { - return nil, status.Errorf(codes.Unimplemented, "method ListApiKeys not implemented") + return nil, status.Error(codes.Unimplemented, "method ListApiKeys not implemented") } func (UnimplementedHeadscaleServiceServer) DeleteApiKey(context.Context, *DeleteApiKeyRequest) (*DeleteApiKeyResponse, error) { - return nil, status.Errorf(codes.Unimplemented, "method DeleteApiKey not implemented") + return nil, status.Error(codes.Unimplemented, "method DeleteApiKey not implemented") } func (UnimplementedHeadscaleServiceServer) GetPolicy(context.Context, *GetPolicyRequest) (*GetPolicyResponse, error) { - return nil, status.Errorf(codes.Unimplemented, "method GetPolicy not implemented") + return nil, status.Error(codes.Unimplemented, "method GetPolicy not implemented") } func (UnimplementedHeadscaleServiceServer) SetPolicy(context.Context, *SetPolicyRequest) (*SetPolicyResponse, error) { - return nil, status.Errorf(codes.Unimplemented, "method SetPolicy not implemented") + return nil, status.Error(codes.Unimplemented, "method SetPolicy not implemented") } func (UnimplementedHeadscaleServiceServer) Health(context.Context, *HealthRequest) (*HealthResponse, error) { - return nil, status.Errorf(codes.Unimplemented, "method Health not implemented") + return nil, status.Error(codes.Unimplemented, "method Health not implemented") } func (UnimplementedHeadscaleServiceServer) mustEmbedUnimplementedHeadscaleServiceServer() {} func (UnimplementedHeadscaleServiceServer) testEmbeddedByValue() {} @@ -472,7 +472,7 @@ type UnsafeHeadscaleServiceServer interface { } func RegisterHeadscaleServiceServer(s grpc.ServiceRegistrar, srv HeadscaleServiceServer) { - // If the following call pancis, it indicates UnimplementedHeadscaleServiceServer was + // If the following call panics, it indicates UnimplementedHeadscaleServiceServer was // embedded by pointer and is nil. This will cause panics if an // unimplemented method is ever invoked, so we test this at initialization // time to prevent it from happening at runtime later due to I/O. diff --git a/gen/go/headscale/v1/node.pb.go b/gen/go/headscale/v1/node.pb.go index 191de8a4..bdf5ce72 100644 --- a/gen/go/headscale/v1/node.pb.go +++ b/gen/go/headscale/v1/node.pb.go @@ -1,6 +1,6 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: -// protoc-gen-go v1.36.10 +// protoc-gen-go v1.36.11 // protoc (unknown) // source: headscale/v1/node.proto diff --git a/gen/go/headscale/v1/policy.pb.go b/gen/go/headscale/v1/policy.pb.go index fefcfb22..faa3fc40 100644 --- a/gen/go/headscale/v1/policy.pb.go +++ b/gen/go/headscale/v1/policy.pb.go @@ -1,6 +1,6 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: -// protoc-gen-go v1.36.10 +// protoc-gen-go v1.36.11 // protoc (unknown) // source: headscale/v1/policy.proto diff --git a/gen/go/headscale/v1/preauthkey.pb.go b/gen/go/headscale/v1/preauthkey.pb.go index ecf016a0..216b17c6 100644 --- a/gen/go/headscale/v1/preauthkey.pb.go +++ b/gen/go/headscale/v1/preauthkey.pb.go @@ -1,6 +1,6 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: -// protoc-gen-go v1.36.10 +// protoc-gen-go v1.36.11 // protoc (unknown) // source: headscale/v1/preauthkey.proto diff --git a/gen/go/headscale/v1/user.pb.go b/gen/go/headscale/v1/user.pb.go index fa6d49bb..5f05d084 100644 --- a/gen/go/headscale/v1/user.pb.go +++ b/gen/go/headscale/v1/user.pb.go @@ -1,6 +1,6 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: -// protoc-gen-go v1.36.10 +// protoc-gen-go v1.36.11 // protoc (unknown) // source: headscale/v1/user.proto diff --git a/hscontrol/auth.go b/hscontrol/auth.go index 5c72ea81..ac5968e3 100644 --- a/hscontrol/auth.go +++ b/hscontrol/auth.go @@ -247,9 +247,9 @@ func nodeToRegisterResponse(node types.NodeView) *tailcfg.RegisterResponse { if node.IsTagged() { resp.User = types.TaggedDevices.View().TailscaleUser() resp.Login = types.TaggedDevices.View().TailscaleLogin() - } else if node.UserView().Valid() { - resp.User = node.UserView().TailscaleUser() - resp.Login = node.UserView().TailscaleLogin() + } else if node.Owner().Valid() { + resp.User = node.Owner().TailscaleUser() + resp.Login = node.Owner().TailscaleLogin() } return resp @@ -389,8 +389,8 @@ func (h *Headscale) handleRegisterWithAuthKey( resp := &tailcfg.RegisterResponse{ MachineAuthorized: true, NodeKeyExpired: node.IsExpired(), - User: node.UserView().TailscaleUser(), - Login: node.UserView().TailscaleLogin(), + User: node.Owner().TailscaleUser(), + Login: node.Owner().TailscaleLogin(), } log.Trace(). diff --git a/hscontrol/mapper/mapper.go b/hscontrol/mapper/mapper.go index bb2c4d6d..759c9568 100644 --- a/hscontrol/mapper/mapper.go +++ b/hscontrol/mapper/mapper.go @@ -69,18 +69,19 @@ func newMapper( } } +// generateUserProfiles creates user profiles for MapResponse. func generateUserProfiles( node types.NodeView, peers views.Slice[types.NodeView], ) []tailcfg.UserProfile { userMap := make(map[uint]*types.UserView) ids := make([]uint, 0, len(userMap)) - user := node.User() + user := node.Owner() userID := user.Model().ID userMap[userID] = &user ids = append(ids, userID) for _, peer := range peers.All() { - peerUser := peer.User() + peerUser := peer.Owner() peerUserID := peerUser.Model().ID userMap[peerUserID] = &peerUser ids = append(ids, peerUserID) diff --git a/hscontrol/state/debug.go b/hscontrol/state/debug.go index ef6ef50c..02d674d5 100644 --- a/hscontrol/state/debug.go +++ b/hscontrol/state/debug.go @@ -78,7 +78,7 @@ func (s *State) DebugOverview() string { now := time.Now() for _, node := range allNodes.All() { if node.Valid() { - userName := node.User().Name() + userName := node.Owner().Name() userNodeCounts[userName]++ if node.IsOnline().Valid() && node.IsOnline().Get() { @@ -281,7 +281,7 @@ func (s *State) DebugOverviewJSON() DebugOverviewInfo { for _, node := range allNodes.All() { if node.Valid() { - userName := node.User().Name() + userName := node.Owner().Name() info.Users[userName]++ if node.IsOnline().Valid() && node.IsOnline().Get() { diff --git a/hscontrol/state/node_store.go b/hscontrol/state/node_store.go index 241d2f46..6327b46b 100644 --- a/hscontrol/state/node_store.go +++ b/hscontrol/state/node_store.go @@ -509,15 +509,27 @@ func (s *NodeStore) DebugString() string { sb.WriteString(fmt.Sprintf("Users with Nodes: %d\n", len(snapshot.nodesByUser))) sb.WriteString("\n") - // User distribution - sb.WriteString("Nodes by User:\n") + // User distribution (shows internal UserID tracking, not display owner) + sb.WriteString("Nodes by Internal User ID:\n") for userID, nodes := range snapshot.nodesByUser { if len(nodes) > 0 { userName := "unknown" + taggedCount := 0 if len(nodes) > 0 && nodes[0].Valid() { userName = nodes[0].User().Name() + // Count tagged nodes (which have UserID set but are owned by "tagged-devices") + for _, n := range nodes { + if n.IsTagged() { + taggedCount++ + } + } + } + + if taggedCount > 0 { + sb.WriteString(fmt.Sprintf(" - User %d (%s): %d nodes (%d tagged)\n", userID, userName, len(nodes), taggedCount)) + } else { + sb.WriteString(fmt.Sprintf(" - User %d (%s): %d nodes\n", userID, userName, len(nodes))) } - sb.WriteString(fmt.Sprintf(" - User %d (%s): %d nodes\n", userID, userName, len(nodes))) } } sb.WriteString("\n") diff --git a/hscontrol/types/node.go b/hscontrol/types/node.go index e115df51..fe4c544f 100644 --- a/hscontrol/types/node.go +++ b/hscontrol/types/node.go @@ -719,7 +719,13 @@ func (node Node) DebugString() string { return sb.String() } -func (nv NodeView) UserView() UserView { +// Owner returns the owner for display purposes. +// For tagged nodes, returns TaggedDevices. For user-owned nodes, returns the user. +func (nv NodeView) Owner() UserView { + if nv.IsTagged() { + return TaggedDevices.View() + } + return nv.User() } diff --git a/integration/cli_test.go b/integration/cli_test.go index 3bbfe8d5..24f48eae 100644 --- a/integration/cli_test.go +++ b/integration/cli_test.go @@ -659,6 +659,123 @@ func TestPreAuthKeyCorrectUserLoggedInCommand(t *testing.T) { }, 20*time.Second, 1*time.Second) } +func TestTaggedNodesCLIOutput(t *testing.T) { + IntegrationSkip(t) + + user1 := "user1" + user2 := "user2" + + spec := ScenarioSpec{ + NodesPerUser: 1, + Users: []string{user1}, + } + + scenario, err := NewScenario(spec) + + require.NoError(t, err) + defer scenario.ShutdownAssertNoPanics(t) + + err = scenario.CreateHeadscaleEnv( + []tsic.Option{}, + hsic.WithTestName("tagcli"), + hsic.WithEmbeddedDERPServerOnly(), + hsic.WithTLS(), + ) + require.NoError(t, err) + + headscale, err := scenario.Headscale() + require.NoError(t, err) + + u2, err := headscale.CreateUser(user2) + require.NoError(t, err) + + var user2Key v1.PreAuthKey + + // Create a tagged PreAuthKey for user2 + assert.EventuallyWithT(t, func(c *assert.CollectT) { + err = executeAndUnmarshal( + headscale, + []string{ + "headscale", + "preauthkeys", + "--user", + strconv.FormatUint(u2.GetId(), 10), + "create", + "--reusable", + "--expiration", + "24h", + "--output", + "json", + "--tags", + "tag:test1,tag:test2", + }, + &user2Key, + ) + assert.NoError(c, err) + }, 10*time.Second, 200*time.Millisecond, "Waiting for user2 tagged preauth key creation") + + allClients, err := scenario.ListTailscaleClients() + requireNoErrListClients(t, err) + + require.Len(t, allClients, 1) + + client := allClients[0] + + // Log out from user1 + err = client.Logout() + require.NoError(t, err) + + err = scenario.WaitForTailscaleLogout() + require.NoError(t, err) + + assert.EventuallyWithT(t, func(ct *assert.CollectT) { + status, err := client.Status() + assert.NoError(ct, err) + assert.NotContains(ct, []string{"Starting", "Running"}, status.BackendState, + "Expected node to be logged out, backend state: %s", status.BackendState) + }, 30*time.Second, 2*time.Second) + + // Log in with the tagged PreAuthKey (from user2, with tags) + err = client.Login(headscale.GetEndpoint(), user2Key.GetKey()) + require.NoError(t, err) + + assert.EventuallyWithT(t, func(ct *assert.CollectT) { + 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 TaggedDevices user (2147455555) + assert.Equal(ct, "userid:2147455555", status.Self.UserID.String(), "Expected node to be logged in as tagged-devices user") + }, 30*time.Second, 2*time.Second) + + // Wait for the second node to appear + var listNodes []*v1.Node + + assert.EventuallyWithT(t, func(ct *assert.CollectT) { + var err error + + listNodes, err = headscale.ListNodes() + assert.NoError(ct, err) + assert.Len(ct, listNodes, 2, "Should have 2 nodes after re-login with tagged key") + assert.Equal(ct, user1, listNodes[0].GetUser().GetName(), "First node should belong to user1") + assert.Equal(ct, "tagged-devices", listNodes[1].GetUser().GetName(), "Second node should be tagged-devices") + }, 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 + 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") + }, 20*time.Second, 1*time.Second) +} + func TestApiKeyCommand(t *testing.T) { IntegrationSkip(t)