diff --git a/tool/tctl/common/resources/linux_desktop.go b/tool/tctl/common/resources/linux_desktop.go new file mode 100644 index 00000000000..f1e40f97951 --- /dev/null +++ b/tool/tctl/common/resources/linux_desktop.go @@ -0,0 +1,147 @@ +/* + * Teleport + * Copyright (C) 2026 Gravitational, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package resources + +import ( + "context" + "fmt" + "io" + + "github.com/gravitational/trace" + + linuxdesktopv1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/linuxdesktop/v1" + "github.com/gravitational/teleport/api/types" + "github.com/gravitational/teleport/api/utils/clientutils" + "github.com/gravitational/teleport/lib/asciitable" + "github.com/gravitational/teleport/lib/auth/authclient" + "github.com/gravitational/teleport/lib/itertools/stream" + "github.com/gravitational/teleport/lib/services" + "github.com/gravitational/teleport/tool/common" +) + +type linuxDesktopCollection struct { + desktops []*linuxdesktopv1.LinuxDesktop +} + +func linuxDesktopHandler() Handler { + return Handler{ + getHandler: getLinuxDesktops, + createHandler: createLinuxDesktop, + updateHandler: updateLinuxDesktop, + deleteHandler: deleteLinuxDesktop, + singleton: false, + mfaRequired: false, + description: "A Linux remote desktop protected by Teleport", + } +} + +func (c *linuxDesktopCollection) Resources() (r []types.Resource) { + for _, resource := range c.desktops { + r = append(r, types.ProtoResource153ToLegacy(resource)) + } + return r +} + +func (c *linuxDesktopCollection) WriteText(w io.Writer, verbose bool) error { + var rows [][]string + for _, d := range c.desktops { + labels := common.FormatLabels(d.Metadata.Labels, verbose) + rows = append(rows, []string{d.Metadata.Name, d.Spec.Addr, d.Spec.Hostname, labels}) + } + headers := []string{"Name", "Address", "Hostname", "Labels"} + var t asciitable.Table + if verbose { + t = asciitable.MakeTable(headers, rows...) + } else { + t = asciitable.MakeTableWithTruncatedColumn(headers, rows, "Labels") + } + _, err := t.AsBuffer().WriteTo(w) + return trace.Wrap(err) +} + +func getLinuxDesktops(ctx context.Context, client *authclient.Client, ref services.Ref, opts GetOpts) (Collection, error) { + linuxDesktopClient := client.LinuxDesktopClient() + + if ref.Name != "" { + desktop, err := linuxDesktopClient.GetLinuxDesktop(ctx, ref.Name) + if err != nil { + if trace.IsNotFound(err) { + return nil, trace.NotFound("Linux desktop %q not found", ref.Name) + } + return nil, trace.Wrap(err) + } + return &linuxDesktopCollection{desktops: []*linuxdesktopv1.LinuxDesktop{desktop}}, nil + } + + desktops, err := stream.Collect(clientutils.Resources(ctx, linuxDesktopClient.ListLinuxDesktops)) + if err != nil { + return nil, trace.Wrap(err) + } + + return &linuxDesktopCollection{desktops: desktops}, nil +} + +func deleteLinuxDesktop(ctx context.Context, client *authclient.Client, ref services.Ref) error { + linuxDesktopClient := client.LinuxDesktopClient() + if err := linuxDesktopClient.DeleteLinuxDesktop(ctx, ref.Name); err != nil { + return trace.Wrap(err) + } + fmt.Printf("linux desktop %q has been deleted\n", ref.Name) + return nil +} + +func createLinuxDesktop(ctx context.Context, client *authclient.Client, raw services.UnknownResource, opts CreateOpts) error { + desktop, err := services.UnmarshalLinuxDesktop(raw.Raw, services.DisallowUnknown()) + if err != nil { + return trace.Wrap(err) + } + + linuxDesktopClient := client.LinuxDesktopClient() + if _, err := linuxDesktopClient.CreateLinuxDesktop(ctx, desktop); err != nil { + if trace.IsAlreadyExists(err) { + if !opts.Force { + return trace.AlreadyExists("linux desktop %q already exists", desktop.Metadata.Name) + } + if _, err := linuxDesktopClient.UpsertLinuxDesktop(ctx, desktop); err != nil { + return trace.Wrap(err) + } + fmt.Printf("linux desktop %q has been updated\n", desktop.Metadata.Name) + return nil + } + return trace.Wrap(err) + } + + fmt.Printf("linux desktop %q has been created\n", desktop.Metadata.Name) + return nil +} + +func updateLinuxDesktop(ctx context.Context, client *authclient.Client, raw services.UnknownResource, opts CreateOpts) error { + desktop, err := services.UnmarshalLinuxDesktop(raw.Raw, services.DisallowUnknown()) + if err != nil { + return trace.Wrap(err) + } + + linuxDesktopClient := client.LinuxDesktopClient() + if _, err := linuxDesktopClient.UpdateLinuxDesktop(ctx, desktop); err != nil { + return trace.Wrap(err) + } + + fmt.Printf("linux desktop %q has been updated\n", desktop.Metadata.Name) + return nil +} diff --git a/tool/tctl/common/resources/linux_desktop_test.go b/tool/tctl/common/resources/linux_desktop_test.go new file mode 100644 index 00000000000..f97c0a6a101 --- /dev/null +++ b/tool/tctl/common/resources/linux_desktop_test.go @@ -0,0 +1,99 @@ +/* + * Teleport + * Copyright (C) 2026 Gravitational, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package resources + +import ( + "testing" + + "github.com/stretchr/testify/require" + + headerv1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/header/v1" + linuxdesktopv1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/linuxdesktop/v1" + "github.com/gravitational/teleport/api/types" + "github.com/gravitational/teleport/lib/asciitable" +) + +func makeLinuxDesktop(name, addr, hostname string, labels map[string]string) *linuxdesktopv1.LinuxDesktop { + return &linuxdesktopv1.LinuxDesktop{ + Kind: types.KindLinuxDesktop, + Version: types.V1, + Metadata: &headerv1.Metadata{ + Name: name, + Labels: labels, + }, + Spec: &linuxdesktopv1.LinuxDesktopSpec{ + Addr: addr, + Hostname: hostname, + }, + } +} + +func TestLinuxDesktopCollection_WriteText(t *testing.T) { + desktops := []*linuxdesktopv1.LinuxDesktop{ + makeLinuxDesktop("desktop-1", "192.168.1.100:22", "prod-host-1", map[string]string{ + "env": "prod", + }), + makeLinuxDesktop("desktop-2", "192.168.1.101:22", "dev-host-1", map[string]string{ + "env": "dev", + }), + makeLinuxDesktop("desktop-3", "192.168.1.102:22", "test-host-1", map[string]string{ + "env": "test", + }), + } + + table := asciitable.MakeTable( + []string{"Name", "Address", "Hostname", "Labels"}, + []string{"desktop-1", "192.168.1.100:22", "prod-host-1", "env=prod"}, + []string{"desktop-2", "192.168.1.101:22", "dev-host-1", "env=dev"}, + []string{"desktop-3", "192.168.1.102:22", "test-host-1", "env=test"}, + ) + + formatted := table.AsBuffer().String() + + collectionFormatTest(t, &linuxDesktopCollection{desktops: desktops}, formatted, formatted) +} + +func TestLinuxDesktopCollection_Resources(t *testing.T) { + t.Parallel() + + desktop := makeLinuxDesktop("test-desktop", "127.0.0.1:22", "test-host", map[string]string{ + "env": "test", + }) + + collection := &linuxDesktopCollection{desktops: []*linuxdesktopv1.LinuxDesktop{desktop}} + resources := collection.Resources() + + require.Len(t, resources, 1) + require.Equal(t, types.KindLinuxDesktop, resources[0].GetKind()) + require.Equal(t, "test-desktop", resources[0].GetName()) +} + +func TestLinuxDesktopHandler(t *testing.T) { + t.Parallel() + + handler := linuxDesktopHandler() + + require.NotNil(t, handler.getHandler) + require.NotNil(t, handler.createHandler) + require.NotNil(t, handler.updateHandler) + require.NotNil(t, handler.deleteHandler) + require.False(t, handler.singleton) + require.False(t, handler.mfaRequired) + require.Equal(t, "A Linux remote desktop protected by Teleport", handler.description) +} diff --git a/tool/tctl/common/resources/resource.go b/tool/tctl/common/resources/resource.go index 97da1627c36..557886c2db8 100644 --- a/tool/tctl/common/resources/resource.go +++ b/tool/tctl/common/resources/resource.go @@ -70,6 +70,7 @@ func Handlers() map[string]Handler { types.KindInstaller: installerHandler(), types.KindKubeServer: kubeServerHandler(), types.KindKubernetesCluster: kubeClusterHandler(), + types.KindLinuxDesktop: linuxDesktopHandler(), types.KindLock: lockHandler(), types.KindNode: serverHandler(), types.KindOIDCConnector: oidcConnectorHandler(),