diff --git a/api/types/constants.go b/api/types/constants.go index 0c697d71db37f..fc6d1b95b5191 100644 --- a/api/types/constants.go +++ b/api/types/constants.go @@ -1174,3 +1174,10 @@ const ( // installer script when agentless mode is enabled for a matcher DefaultInstallerScriptNameAgentless = "default-agentless-installer" ) + +const ( + // ApplicationProtocolHTTP is the HTTP (Web) apps protocol + ApplicationProtocolHTTP = "HTTP" + // ApplicationProtocolTCP is the TCP apps protocol. + ApplicationProtocolTCP = "TCP" +) diff --git a/lib/teleterm/api/uri/parse.go b/lib/teleterm/api/uri/parse.go index d2bdbce63ea6a..25ef0ef7c972e 100644 --- a/lib/teleterm/api/uri/parse.go +++ b/lib/teleterm/api/uri/parse.go @@ -58,8 +58,8 @@ func validateProfileName(r ResourceURI) error { } func validateGatewayTargetResource(r ResourceURI) error { - if r.GetDbName() == "" && r.GetKubeName() == "" { - return trace.BadParameter("malformed gateway target URI %q, expecting a database or kube resource", r) + if r.GetDbName() == "" && r.GetKubeName() == "" && r.GetAppName() == "" { + return trace.BadParameter("malformed gateway target URI %q, expecting a database, kube or app resource", r) } return nil } diff --git a/lib/teleterm/clusters/cluster_apps.go b/lib/teleterm/clusters/cluster_apps.go index 5dc0450270797..93fb5796291c7 100644 --- a/lib/teleterm/clusters/cluster_apps.go +++ b/lib/teleterm/clusters/cluster_apps.go @@ -17,7 +17,16 @@ package clusters import ( + "context" + "crypto/tls" + "fmt" + + "github.com/gravitational/trace" + + "github.com/gravitational/teleport/api/client/proto" "github.com/gravitational/teleport/api/types" + "github.com/gravitational/teleport/lib/client" + libclient "github.com/gravitational/teleport/lib/client" "github.com/gravitational/teleport/lib/teleterm/api/uri" ) @@ -36,3 +45,91 @@ type SAMLIdPServiceProvider struct { Provider types.SAMLIdPServiceProvider } + +func (c *Cluster) getApp(ctx context.Context, appName string) (types.Application, error) { + var app types.Application + err := AddMetadataToRetryableError(ctx, func() error { + apps, err := c.clusterClient.ListApps(ctx, &proto.ListResourcesRequest{ + Namespace: c.clusterClient.Namespace, + ResourceType: types.KindAppServer, + PredicateExpression: fmt.Sprintf(`name == "%s"`, appName), + }) + if err != nil { + return trace.Wrap(err) + } + + if len(apps) == 0 { + return trace.NotFound("app %q not found", appName) + } + + app = apps[0] + return nil + }) + + return app, trace.Wrap(err) +} + +// reissueAppCert issue new certificates for the app and saves them to disk. +func (c *Cluster) reissueAppCert(ctx context.Context, app types.Application) (tls.Certificate, error) { + if app.IsAWSConsole() || app.IsGCP() || app.IsAzureCloud() { + return tls.Certificate{}, trace.BadParameter("cloud applications are not supported") + } + // Refresh the certs to account for clusterClient.SiteName pointing at a leaf cluster. + err := c.clusterClient.ReissueUserCerts(ctx, client.CertCacheKeep, client.ReissueParams{ + RouteToCluster: c.clusterClient.SiteName, + AccessRequests: c.status.ActiveRequests.AccessRequests, + }) + if err != nil { + return tls.Certificate{}, trace.Wrap(err) + } + + proxyClient, err := c.clusterClient.ConnectToProxy(ctx) + if err != nil { + return tls.Certificate{}, trace.Wrap(err) + } + defer proxyClient.Close() + + request := types.CreateAppSessionRequest{ + Username: c.status.Username, + PublicAddr: app.GetPublicAddr(), + ClusterName: c.clusterClient.SiteName, + AWSRoleARN: "", + AzureIdentity: "", + GCPServiceAccount: "", + } + + ws, err := proxyClient.CreateAppSession(ctx, request) + if err != nil { + return tls.Certificate{}, trace.Wrap(err) + } + + err = proxyClient.ReissueUserCerts(ctx, client.CertCacheKeep, client.ReissueParams{ + RouteToCluster: c.clusterClient.SiteName, + RouteToApp: proto.RouteToApp{ + Name: app.GetName(), + SessionID: ws.GetName(), + PublicAddr: app.GetPublicAddr(), + ClusterName: c.clusterClient.SiteName, + AWSRoleARN: "", + AzureIdentity: "", + GCPServiceAccount: "", + }, + AccessRequests: c.status.ActiveRequests.AccessRequests, + }) + if err != nil { + return tls.Certificate{}, trace.Wrap(err) + } + + key, err := c.clusterClient.LocalAgent().GetKey(c.clusterClient.SiteName, libclient.WithAppCerts{}) + if err != nil { + return tls.Certificate{}, trace.Wrap(err) + } + + cert, ok := key.AppTLSCerts[app.GetName()] + if !ok { + return tls.Certificate{}, trace.NotFound("the user is not logged in into the application %v", app.GetName()) + } + + tlsCert, err := key.TLSCertificate(cert) + return tlsCert, trace.Wrap(err) +} diff --git a/lib/teleterm/clusters/cluster_gateways.go b/lib/teleterm/clusters/cluster_gateways.go index 26759673920b4..6823c3f8f1359 100644 --- a/lib/teleterm/clusters/cluster_gateways.go +++ b/lib/teleterm/clusters/cluster_gateways.go @@ -60,6 +60,10 @@ func (c *Cluster) CreateGateway(ctx context.Context, params CreateGatewayParams) gateway, err := c.createKubeGateway(ctx, params) return gateway, trace.Wrap(err) + case params.TargetURI.IsApp(): + gateway, err := c.createAppGateway(ctx, params) + return gateway, trace.Wrap(err) + default: return nil, trace.NotImplemented("gateway not supported for %v", params.TargetURI) } @@ -148,6 +152,43 @@ func (c *Cluster) createKubeGateway(ctx context.Context, params CreateGatewayPar return gw, trace.Wrap(err) } +func (c *Cluster) createAppGateway(ctx context.Context, params CreateGatewayParams) (gateway.Gateway, error) { + appName := params.TargetURI.GetAppName() + + app, err := c.getApp(ctx, appName) + if err != nil { + return nil, trace.Wrap(err) + } + + var cert tls.Certificate + + if err := AddMetadataToRetryableError(ctx, func() error { + cert, err = c.reissueAppCert(ctx, app) + return trace.Wrap(err) + }); err != nil { + return nil, trace.Wrap(err) + } + + gw, err := gateway.New(gateway.Config{ + LocalPort: params.LocalPort, + TargetURI: params.TargetURI, + TargetName: appName, + Cert: cert, + Protocol: app.GetProtocol(), + Insecure: c.clusterClient.InsecureSkipVerify, + WebProxyAddr: c.clusterClient.WebProxyAddr, + Log: c.Log, + TCPPortAllocator: params.TCPPortAllocator, + OnExpiredCert: params.OnExpiredCert, + Clock: c.clock, + TLSRoutingConnUpgradeRequired: c.clusterClient.TLSRoutingConnUpgradeRequired, + RootClusterCACertPoolFunc: c.clusterClient.RootClusterCACertPool, + ClusterName: c.Name, + Username: c.status.Username, + }) + return gw, trace.Wrap(err) +} + // ReissueGatewayCerts reissues certificate for the provided gateway. // // At the moment, kube gateways reload their certs in memory while db gateways use the old approach @@ -177,6 +218,16 @@ func (c *Cluster) ReissueGatewayCerts(ctx context.Context, g gateway.Gateway) (t case g.TargetURI().IsKube(): cert, err := c.reissueKubeCert(ctx, g.TargetName()) return cert, trace.Wrap(err) + case g.TargetURI().IsApp(): + appName := g.TargetURI().GetAppName() + + app, err := c.getApp(ctx, appName) + if err != nil { + return tls.Certificate{}, trace.Wrap(err) + } + + cert, err := c.reissueAppCert(ctx, app) + return cert, trace.Wrap(err) default: return tls.Certificate{}, trace.NotImplemented("ReissueGatewayCerts does not support this gateway kind %v", g.TargetURI().String()) } diff --git a/lib/teleterm/cmd/app.go b/lib/teleterm/cmd/app.go new file mode 100644 index 0000000000000..cf5cfaceb842b --- /dev/null +++ b/lib/teleterm/cmd/app.go @@ -0,0 +1,43 @@ +/* + * Teleport + * Copyright (C) 2024 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 cmd + +import ( + "os/exec" + + "github.com/gravitational/trace" + + "github.com/gravitational/teleport/api/types" + "github.com/gravitational/teleport/lib/teleterm/gateway" +) + +// NewAppCLICommand creates CLI commands for app gateways. +func NewAppCLICommand(g gateway.Gateway) (*exec.Cmd, error) { + app, err := gateway.AsApp(g) + if err != nil { + return nil, trace.Wrap(err) + } + + if g.Protocol() == types.ApplicationProtocolTCP { + return exec.Command(""), nil + } + + cmd := exec.Command("curl", app.LocalProxyURL()) + return cmd, nil +} diff --git a/lib/teleterm/cmd/app_test.go b/lib/teleterm/cmd/app_test.go new file mode 100644 index 0000000000000..14341d2528067 --- /dev/null +++ b/lib/teleterm/cmd/app_test.go @@ -0,0 +1,81 @@ +/* + * Teleport + * Copyright (C) 2024 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 cmd + +import ( + "fmt" + "strings" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/gravitational/teleport/api/types" + "github.com/gravitational/teleport/lib/teleterm/gateway" +) + +type fakeAppGateway struct { + gateway.App + protocol string + generatedUrl string +} + +func (m fakeAppGateway) Protocol() string { return m.protocol } +func (m fakeAppGateway) LocalProxyURL() string { return m.generatedUrl } + +func TestNewAppCLICommand(t *testing.T) { + testCases := []struct { + name string + protocol string + generatedUrl string + output string + }{ + { + name: "TCP app", + protocol: types.ApplicationProtocolTCP, + generatedUrl: "tcp://localhost:8888", + output: "", + }, + { + name: "HTTP app", + protocol: types.ApplicationProtocolHTTP, + generatedUrl: "http://localhost:8888", + output: "curl http://localhost:8888", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + + mockGateway := fakeAppGateway{ + protocol: tc.protocol, + generatedUrl: tc.generatedUrl, + } + + command, err := NewAppCLICommand(mockGateway) + + require.NoError(t, err) + cmdString := strings.TrimSpace( + fmt.Sprintf("%s %s", + strings.Join(command.Env, " "), + strings.Join(command.Args, " "))) + + require.Equal(t, cmdString, tc.output) + }) + } +} diff --git a/lib/teleterm/daemon/daemon.go b/lib/teleterm/daemon/daemon.go index 5f995394b8196..e4227a56275a3 100644 --- a/lib/teleterm/daemon/daemon.go +++ b/lib/teleterm/daemon/daemon.go @@ -447,6 +447,10 @@ func (s *Service) GetGatewayCLICommand(gateway gateway.Gateway) (*exec.Cmd, erro cmd, err := cmd.NewKubeCLICommand(gateway) return cmd, trace.Wrap(err) + case targetURI.IsApp(): + cmd, err := cmd.NewAppCLICommand(gateway) + return cmd, trace.Wrap(err) + default: return nil, trace.NotImplemented("gateway not supported for %v", targetURI) } diff --git a/lib/teleterm/gateway/app.go b/lib/teleterm/gateway/app.go new file mode 100644 index 0000000000000..56361d07613a8 --- /dev/null +++ b/lib/teleterm/gateway/app.go @@ -0,0 +1,87 @@ +// Teleport +// Copyright (C) 2024 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 gateway + +import ( + "context" + "net" + "net/url" + "strings" + + "github.com/gravitational/trace" + + "github.com/gravitational/teleport/api/types" + "github.com/gravitational/teleport/lib/srv/alpnproxy" + alpncommon "github.com/gravitational/teleport/lib/srv/alpnproxy/common" +) + +type app struct { + *base +} + +// LocalProxyURL returns the URL of the local proxy. +func (a *app) LocalProxyURL() string { + proxyURL := url.URL{ + Scheme: strings.ToLower(a.Protocol()), + Host: a.LocalAddress() + ":" + a.LocalPort(), + } + return proxyURL.String() +} + +func makeAppGateway(cfg Config) (Gateway, error) { + base, err := newBase(cfg) + if err != nil { + return nil, trace.Wrap(err) + } + + a := &app{base} + + listener, err := a.cfg.makeListener() + if err != nil { + return nil, trace.Wrap(err) + } + + lp, err := alpnproxy.NewLocalProxy( + makeBasicLocalProxyConfig(a.closeContext, a.cfg, listener), + alpnproxy.WithALPNProtocol(alpnProtocolForApp(a.cfg.Protocol)), + alpnproxy.WithClientCerts(a.cfg.Cert), + alpnproxy.WithClusterCAsIfConnUpgrade(a.closeContext, a.cfg.RootClusterCACertPoolFunc), + ) + if err != nil { + return nil, trace.NewAggregate(err, listener.Close()) + } + + a.localProxy = lp + return a, nil +} + +func makeBasicLocalProxyConfig(ctx context.Context, cfg *Config, listener net.Listener) alpnproxy.LocalProxyConfig { + return alpnproxy.LocalProxyConfig{ + RemoteProxyAddr: cfg.WebProxyAddr, + InsecureSkipVerify: cfg.Insecure, + ParentContext: ctx, + Listener: listener, + ALPNConnUpgradeRequired: cfg.TLSRoutingConnUpgradeRequired, + } +} + +func alpnProtocolForApp(protocol string) alpncommon.Protocol { + if protocol == types.ApplicationProtocolTCP { + return alpncommon.ProtocolTCP + } + return alpncommon.ProtocolHTTP +} diff --git a/lib/teleterm/gateway/base.go b/lib/teleterm/gateway/base.go index 88e8e176ea28a..f9874405843d9 100644 --- a/lib/teleterm/gateway/base.go +++ b/lib/teleterm/gateway/base.go @@ -47,6 +47,10 @@ func New(cfg Config) (Gateway, error) { gateway, err := makeKubeGateway(cfg) return gateway, trace.Wrap(err) + case cfg.TargetURI.IsApp(): + gateway, err := makeAppGateway(cfg) + return gateway, trace.Wrap(err) + default: return nil, trace.NotImplemented("gateway not supported for %v", cfg.TargetURI) } diff --git a/lib/teleterm/gateway/config.go b/lib/teleterm/gateway/config.go index 95ca1a1a0b511..872fe679066ed 100644 --- a/lib/teleterm/gateway/config.go +++ b/lib/teleterm/gateway/config.go @@ -135,6 +135,18 @@ func (c *Config) CheckAndSetDefaults() error { if c.CertPath != "" { return trace.BadParameter("cert path must not be passed for kube gateways") } + case c.TargetURI.IsApp(): + if len(c.Cert.Certificate) == 0 { + return trace.BadParameter("missing cert") + } + + if c.KeyPath != "" { + return trace.BadParameter("key path must not be passed for app gateways") + } + + if c.CertPath != "" { + return trace.BadParameter("cert path must not be passed for app gateways") + } default: return trace.BadParameter("unsupported gateway target %v", c.TargetURI) } diff --git a/lib/teleterm/gateway/interfaces.go b/lib/teleterm/gateway/interfaces.go index 3529be5b7bd5e..73b03e69938ba 100644 --- a/lib/teleterm/gateway/interfaces.go +++ b/lib/teleterm/gateway/interfaces.go @@ -66,6 +66,14 @@ func AsKube(g Gateway) (Kube, error) { return nil, trace.BadParameter("expecting kube gateway but got %T", g) } +// AsApp converts provided gateway to a kube gateway. +func AsApp(g Gateway) (App, error) { + if app, ok := g.(App); ok { + return app, nil + } + return nil, trace.BadParameter("expecting app gateway but got %T", g) +} + // Database defines a database gateway. type Database interface { Gateway @@ -85,3 +93,11 @@ type Kube interface { // local proxy. KubeconfigPath() string } + +// App defines an app gateway. +type App interface { + Gateway + + // LocalProxyURL returns the URL of the local proxy. + LocalProxyURL() string +} diff --git a/web/packages/teleterm/src/services/tshd/gateway.ts b/web/packages/teleterm/src/services/tshd/gateway.ts index a1f731d553dd2..ed2532b149f0a 100644 --- a/web/packages/teleterm/src/services/tshd/gateway.ts +++ b/web/packages/teleterm/src/services/tshd/gateway.ts @@ -16,6 +16,8 @@ * along with this program. If not, see . */ +import { routing, GatewayTargetUri } from 'teleterm/ui/uri'; + import { GatewayCLICommand } from './types'; /** @@ -49,3 +51,22 @@ export function getCliCommandEnv( cliCommand.envList.map(nameEqualsValue => nameEqualsValue.split('=')) ); } + +/** + * getTargetNameFromUri extracts the name of the gateway target from the target URI. + * + * Defaults to the target URI itself if the target URI doesn't seem to match any of the supported + * URI types. + * + * If possible, the target name should be acquired from the gateway object itself. + * getTargetNameFromUri is reserved for situations where a gateway is not available, but we still + * want to display a pretty name in the UI. + */ +export function getTargetNameFromUri(targetUri: GatewayTargetUri): string { + return ( + routing.parseDbUri(targetUri)?.params['dbId'] || + routing.parseKubeUri(targetUri)?.params['kubeId'] || + routing.parseAppUri(targetUri)?.params['appId'] || + targetUri + ); +} diff --git a/web/packages/teleterm/src/services/tshd/testHelpers.ts b/web/packages/teleterm/src/services/tshd/testHelpers.ts index b6243ed8172cf..29f02a47f30e5 100644 --- a/web/packages/teleterm/src/services/tshd/testHelpers.ts +++ b/web/packages/teleterm/src/services/tshd/testHelpers.ts @@ -243,3 +243,23 @@ export const makeKubeGateway = ( targetSubresourceName: '', ...props, }); + +export const makeAppGateway = ( + props: Partial = {} +): tsh.Gateway => ({ + uri: '/gateways/bar', + targetName: 'sales-production', + targetUri: '/clusters/bar/apps/foo', + localAddress: 'localhost', + localPort: '1337', + targetSubresourceName: 'bar', + gatewayCliCommand: { + path: '', + preview: 'curl http://localhost:1337', + envList: [], + argsList: [], + }, + targetUser: '', + protocol: 'HTTP', + ...props, +}); diff --git a/web/packages/teleterm/src/ui/ClusterConnect/ClusterLogin/ClusterLogin.tsx b/web/packages/teleterm/src/ui/ClusterConnect/ClusterLogin/ClusterLogin.tsx index 3974c287ebc16..7bc6e79aba5ad 100644 --- a/web/packages/teleterm/src/ui/ClusterConnect/ClusterLogin/ClusterLogin.tsx +++ b/web/packages/teleterm/src/ui/ClusterConnect/ClusterLogin/ClusterLogin.tsx @@ -25,7 +25,7 @@ import { PrimaryAuthType } from 'shared/services'; import { AuthSettings } from 'teleterm/ui/services/clusters/types'; import { ClusterConnectReason } from 'teleterm/ui/services/modals'; -import { routing } from 'teleterm/ui/uri'; +import { getTargetNameFromUri } from 'teleterm/services/tshd/gateway'; import LoginForm from './FormLogin'; import useClusterLogin, { State, Props } from './useClusterLogin'; @@ -130,17 +130,7 @@ function Reason({ reason }: { reason: ClusterConnectReason }) { ); } else { - const targetName = routing.parseDbUri(targetUri)?.params['dbId']; - - if (targetName) { - $targetDesc = {targetName}; - } else { - $targetDesc = ( - <> - a database server under {targetUri} - - ); - } + $targetDesc = {getTargetNameFromUri(targetUri)}; } return ( diff --git a/web/packages/teleterm/src/ui/DocumentCluster/UnifiedResources.tsx b/web/packages/teleterm/src/ui/DocumentCluster/UnifiedResources.tsx index 881f1f6d1c5c5..8346b4ad940ce 100644 --- a/web/packages/teleterm/src/ui/DocumentCluster/UnifiedResources.tsx +++ b/web/packages/teleterm/src/ui/DocumentCluster/UnifiedResources.tsx @@ -332,7 +332,7 @@ const mapToSharedResource = ( samlApp: app.samlApp, }, ui: { - ActionButton: , + ActionButton: , }, }; } diff --git a/web/packages/teleterm/src/ui/DocumentCluster/actionButtons.tsx b/web/packages/teleterm/src/ui/DocumentCluster/actionButtons.tsx index d8d4185ff3897..3099c5a7ad5d1 100644 --- a/web/packages/teleterm/src/ui/DocumentCluster/actionButtons.tsx +++ b/web/packages/teleterm/src/ui/DocumentCluster/actionButtons.tsx @@ -33,6 +33,7 @@ import { Kube, GatewayProtocol, Database, + App, } from 'teleterm/services/tshd/types'; import { DatabaseUri } from 'teleterm/ui/uri'; @@ -96,9 +97,11 @@ export function ConnectKubeActionButton(props: { ); } -export function ConnectAppActionButton(): React.JSX.Element { +export function ConnectAppActionButton(props: { app: App }): React.JSX.Element { + const appContext = useAppContext(); + function connect(): void { - connectToApp(); + connectToApp(appContext, props.app, { origin: 'resource_table' }); } return ( diff --git a/web/packages/teleterm/src/ui/DocumentGateway/CliCommand.tsx b/web/packages/teleterm/src/ui/DocumentGateway/CliCommand.tsx index da071d5aa7923..bc66ac94e200c 100644 --- a/web/packages/teleterm/src/ui/DocumentGateway/CliCommand.tsx +++ b/web/packages/teleterm/src/ui/DocumentGateway/CliCommand.tsx @@ -22,11 +22,17 @@ import { fade } from 'design/theme/utils/colorManipulator'; interface CliCommandProps { cliCommand: string; - onRun(): void; + onButtonClick(): void; isLoading: boolean; + buttonText?: string; } -export function CliCommand({ cliCommand, onRun, isLoading }: CliCommandProps) { +export function CliCommand({ + cliCommand, + onButtonClick, + isLoading, + buttonText = 'Run', +}: CliCommandProps) { const [shouldDisplayIsLoading, setShouldDisplayIsLoading] = useState(false); useEffect(() => { @@ -83,7 +89,7 @@ export function CliCommand({ cliCommand, onRun, isLoading }: CliCommandProps) { )} - Run + {buttonText} ); diff --git a/web/packages/teleterm/src/ui/DocumentGateway/OnlineDocumentGateway.tsx b/web/packages/teleterm/src/ui/DocumentGateway/OnlineDocumentGateway.tsx index c46224c7c20a9..3e4ebb85a2b88 100644 --- a/web/packages/teleterm/src/ui/DocumentGateway/OnlineDocumentGateway.tsx +++ b/web/packages/teleterm/src/ui/DocumentGateway/OnlineDocumentGateway.tsx @@ -110,7 +110,7 @@ export function OnlineDocumentGateway(props: OnlineDocumentGatewayProps) { {$errors} diff --git a/web/packages/teleterm/src/ui/DocumentGateway/useDocumentGateway.ts b/web/packages/teleterm/src/ui/DocumentGateway/useDocumentGateway.ts index a8dd975d0d059..683e1625c1e3c 100644 --- a/web/packages/teleterm/src/ui/DocumentGateway/useDocumentGateway.ts +++ b/web/packages/teleterm/src/ui/DocumentGateway/useDocumentGateway.ts @@ -26,8 +26,9 @@ import { useWorkspaceContext } from 'teleterm/ui/Documents'; import { retryWithRelogin } from 'teleterm/ui/utils'; import * as tshdGateway from 'teleterm/services/tshd/gateway'; import { Gateway } from 'teleterm/services/tshd/types'; +import { isDatabaseUri, isAppUri } from 'teleterm/ui/uri'; -export function useDocumentGateway(doc: types.DocumentGateway) { +export function useGateway(doc: types.DocumentGateway) { const ctx = useAppContext(); const { documentsService: workspaceDocumentsService } = useWorkspaceContext(); // The port to show as default in the input field in case creating a gateway fails. @@ -69,7 +70,12 @@ export function useDocumentGateway(doc: types.DocumentGateway) { port: gw.localPort, status: 'connected', }); - ctx.usageService.captureProtocolUse(doc.targetUri, 'db', doc.origin); + if (isDatabaseUri(doc.targetUri)) { + ctx.usageService.captureProtocolUse(doc.targetUri, 'db', doc.origin); + } + if (isAppUri(doc.targetUri)) { + ctx.usageService.captureProtocolUse(doc.targetUri, 'app', doc.origin); + } }); const [disconnectAttempt, disconnect] = useAsync(async () => { @@ -77,17 +83,18 @@ export function useDocumentGateway(doc: types.DocumentGateway) { workspaceDocumentsService.close(doc.uri); }); - const [changeDbNameAttempt, changeDbName] = useAsync(async (name: string) => { - const updatedGateway = - await ctx.clustersService.setGatewayTargetSubresourceName( - doc.gatewayUri, - name - ); + const [changeTargetSubresourceNameAttempt, changeTargetSubresourceName] = + useAsync(async (name: string) => { + const updatedGateway = + await ctx.clustersService.setGatewayTargetSubresourceName( + doc.gatewayUri, + name + ); - workspaceDocumentsService.update(doc.uri, { - targetSubresourceName: updatedGateway.targetSubresourceName, + workspaceDocumentsService.update(doc.uri, { + targetSubresourceName: updatedGateway.targetSubresourceName, + }); }); - }); const [changePortAttempt, changePort] = useAsync(async (port: string) => { const updatedGateway = await ctx.clustersService.setGatewayLocalPort( @@ -101,21 +108,6 @@ export function useDocumentGateway(doc: types.DocumentGateway) { }); }); - const runCliCommand = () => { - const command = tshdGateway.getCliCommandArgv0(gateway.gatewayCliCommand); - const title = `${command} · ${doc.targetUser}@${doc.targetName}`; - - const cliDoc = workspaceDocumentsService.createGatewayCliDocument({ - title, - targetUri: doc.targetUri, - targetUser: doc.targetUser, - targetName: doc.targetName, - targetProtocol: gateway.protocol, - }); - workspaceDocumentsService.add(cliDoc); - workspaceDocumentsService.setLocation(cliDoc.uri); - }; - useEffect( function createGatewayOnMount() { // Since the user can close DocumentGateway without shutting down the gateway, it's possible @@ -136,12 +128,64 @@ export function useDocumentGateway(doc: types.DocumentGateway) { connected, reconnect: createGateway, connectAttempt, + disconnectAttempt, + changeTargetSubresourceName, + changeTargetSubresourceNameAttempt, + changePort, + changePortAttempt, + }; +} + +//TODO(gzdunek): Refactor DocumentGateway so the hook below is no longer needed. +// We should move away from using one big hook per component. +export function useDocumentGateway(doc: types.DocumentGateway) { + const { documentsService: workspaceDocumentsService } = useWorkspaceContext(); + + const { + gateway, + reconnect, + connectAttempt, + disconnectAttempt, + disconnect, + connected, + changePort, + changePortAttempt, + changeTargetSubresourceNameAttempt, + changeTargetSubresourceName, + defaultPort, + } = useGateway(doc); + + const runCliCommand = () => { + if (!isDatabaseUri(doc.targetUri)) { + return; + } + const command = tshdGateway.getCliCommandArgv0(gateway.gatewayCliCommand); + const title = `${command} · ${doc.targetUser}@${doc.targetName}`; + + const cliDoc = workspaceDocumentsService.createGatewayCliDocument({ + title, + targetUri: doc.targetUri, + targetUser: doc.targetUser, + targetName: doc.targetName, + targetProtocol: gateway.protocol, + }); + workspaceDocumentsService.add(cliDoc); + workspaceDocumentsService.setLocation(cliDoc.uri); + }; + + return { + reconnect, + connectAttempt, // TODO(ravicious): Show disconnectAttempt errors in UI. disconnectAttempt, - runCliCommand, - changeDbName, - changeDbNameAttempt, + disconnect, + connected, + gateway, + changeDbNameAttempt: changeTargetSubresourceNameAttempt, changePort, changePortAttempt, + changeDbName: changeTargetSubresourceName, + defaultPort, + runCliCommand, }; } diff --git a/web/packages/teleterm/src/ui/DocumentGatewayApp/AppGateway.tsx b/web/packages/teleterm/src/ui/DocumentGatewayApp/AppGateway.tsx new file mode 100644 index 0000000000000..6023461ee15c2 --- /dev/null +++ b/web/packages/teleterm/src/ui/DocumentGatewayApp/AppGateway.tsx @@ -0,0 +1,110 @@ +/** + * Teleport + * Copyright (C) 2024 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 . + */ + +import { useMemo, useRef } from 'react'; + +import { Flex, Text, ButtonSecondary, Link, Box, Alert } from 'design'; + +import Validation from 'shared/components/Validation'; +import { Attempt } from 'shared/hooks/useAsync'; +import { debounce } from 'shared/utils/highbar'; + +import { Gateway } from 'teleterm/services/tshd/types'; +import { CliCommand } from 'teleterm/ui/DocumentGateway/CliCommand'; + +import { PortFieldInput } from '../components/FieldInputs'; + +export function AppGateway(props: { + gateway: Gateway; + disconnectAttempt: Attempt; + changePort(port: string): void; + changePortAttempt: Attempt; + disconnect(): void; + copyCliCommandToClipboard(): void; +}) { + const formRef = useRef(); + const cliCommandPreview = props.gateway.gatewayCliCommand.preview; + + const { changePort } = props; + const handleChangePort = useMemo(() => { + return debounce((value: string) => { + if (formRef.current.reportValidity()) { + changePort(value); + } + }, 1000); + }, [changePort]); + + return ( + + + App Connection + {props.disconnectAttempt.status === 'error' && ( + + Could not close the connection: {props.disconnectAttempt.statusText} + + )} + + Close Connection + + + + + handleChangePort(e.target.value)} + mb={2} + /> + + + {cliCommandPreview && ( + + )} + {props.changePortAttempt.status === 'error' && ( + + Could not change the port number: {props.changePortAttempt.statusText} + + )} + + Access the app at{' '} + + {props.gateway.protocol.toLowerCase()}://{props.gateway.localAddress}: + {props.gateway.localPort} + + . + + + The connection is made through an authenticated proxy so no extra + credentials are necessary. See{' '} + {/*TODO(gzdunek): Replace with Teleport Connect App access docs.*/} + + the documentation + {' '} + for more details. + + + ); +} diff --git a/web/packages/teleterm/src/ui/DocumentGatewayApp/DocumentGatewayApp.story.tsx b/web/packages/teleterm/src/ui/DocumentGatewayApp/DocumentGatewayApp.story.tsx new file mode 100644 index 0000000000000..6d1f7c21d2230 --- /dev/null +++ b/web/packages/teleterm/src/ui/DocumentGatewayApp/DocumentGatewayApp.story.tsx @@ -0,0 +1,97 @@ +/** + * Teleport + * Copyright (C) 2024 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 . + */ + +import React from 'react'; + +import { DocumentGatewayApp } from 'teleterm/ui/DocumentGatewayApp/DocumentGatewayApp'; +import * as types from 'teleterm/ui/services/workspacesService'; +import { MockAppContext } from 'teleterm/ui/fixtures/mocks'; +import { MockAppContextProvider } from 'teleterm/ui/fixtures/MockAppContextProvider'; +import { MockWorkspaceContextProvider } from 'teleterm/ui/fixtures/MockWorkspaceContextProvider'; +import { makeAppGateway } from 'teleterm/services/tshd/testHelpers'; + +export default { + title: 'Teleterm/DocumentGatewayApp', +}; + +const gateway = makeAppGateway(); + +const documentGateway: types.DocumentGateway = { + kind: 'doc.gateway', + targetUri: '/clusters/bar/apps/quux', + origin: 'resource_table', + gatewayUri: gateway.uri, + uri: '/docs/123', + title: 'quux', + targetUser: '', + status: '', + targetName: 'quux', +}; + +const rootClusterUri = '/clusters/bar'; + +export function Offline() { + const offlineDocumentGateway = { ...documentGateway, gatewayUri: undefined }; + const appContext = new MockAppContext(); + appContext.clustersService.createGateway = () => + Promise.reject(new Error('failed to create gateway')); + + appContext.workspacesService.setState(draftState => { + draftState.rootClusterUri = rootClusterUri; + draftState.workspaces[rootClusterUri] = { + localClusterUri: rootClusterUri, + documents: [offlineDocumentGateway], + location: offlineDocumentGateway.uri, + accessRequests: undefined, + }; + }); + + return ( + + + + + + ); +} + +export function Online() { + const appContext = new MockAppContext(); + appContext.clustersService.createGateway = () => Promise.resolve(gateway); + appContext.clustersService.setState(draftState => { + draftState.gateways.set(gateway.uri, gateway); + }); + + appContext.workspacesService.setState(draftState => { + draftState.rootClusterUri = rootClusterUri; + draftState.workspaces[rootClusterUri] = { + localClusterUri: rootClusterUri, + documents: [documentGateway], + location: documentGateway.uri, + accessRequests: undefined, + }; + }); + + return ( + + + + + + ); +} diff --git a/web/packages/teleterm/src/ui/DocumentGatewayApp/DocumentGatewayApp.tsx b/web/packages/teleterm/src/ui/DocumentGatewayApp/DocumentGatewayApp.tsx new file mode 100644 index 0000000000000..6adaac743d41e --- /dev/null +++ b/web/packages/teleterm/src/ui/DocumentGatewayApp/DocumentGatewayApp.tsx @@ -0,0 +1,76 @@ +/** + * Teleport + * Copyright (C) 2024 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 . + */ + +import { copyToClipboard } from 'design/utils/copyToClipboard'; + +import { useAppContext } from 'teleterm/ui/appContextProvider'; +import { DocumentGateway } from 'teleterm/ui/services/workspacesService'; +import Document from 'teleterm/ui/Document'; + +import { useDocumentGateway } from '../DocumentGateway/useDocumentGateway'; + +import { OfflineGateway } from '../components/OfflineGateway'; + +import { AppGateway } from './AppGateway'; + +export function DocumentGatewayApp(props: { + doc: DocumentGateway; + visible: boolean; +}) { + const ctx = useAppContext(); + const { + gateway, + changePort, + changePortAttempt, + connected, + connectAttempt, + disconnect, + disconnectAttempt, + reconnect, + } = useDocumentGateway(props.doc); + + ctx.clustersService.useState(); + + async function copyCliCommandToClipboard(): Promise { + await copyToClipboard(gateway.gatewayCliCommand.preview); + ctx.notificationsService.notifyInfo('Copied to clipboard'); + } + + return ( + + {!connected ? ( + + ) : ( + + )} + + ); +} diff --git a/web/packages/teleterm/src/ui/DocumentGatewayApp/index.ts b/web/packages/teleterm/src/ui/DocumentGatewayApp/index.ts new file mode 100644 index 0000000000000..179c84e7641f4 --- /dev/null +++ b/web/packages/teleterm/src/ui/DocumentGatewayApp/index.ts @@ -0,0 +1,19 @@ +/** + * Teleport + * Copyright (C) 2024 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 . + */ + +export * from './DocumentGatewayApp'; diff --git a/web/packages/teleterm/src/ui/Documents/DocumentsRenderer.tsx b/web/packages/teleterm/src/ui/Documents/DocumentsRenderer.tsx index 348e1351c4be1..30459113806d0 100644 --- a/web/packages/teleterm/src/ui/Documents/DocumentsRenderer.tsx +++ b/web/packages/teleterm/src/ui/Documents/DocumentsRenderer.tsx @@ -16,10 +16,12 @@ * along with this program. If not, see . */ -import React, { useMemo } from 'react'; +import { useMemo } from 'react'; import { createPortal } from 'react-dom'; import styled from 'styled-components'; +import { Text } from 'design'; + /* eslint-disable @typescript-eslint/ban-ts-comment*/ // @ts-ignore import { DocumentAccessRequests } from 'e-teleterm/ui/DocumentAccessRequests/DocumentAccessRequests'; @@ -41,9 +43,10 @@ import { ConnectMyComputerNavigationMenu, } from 'teleterm/ui/ConnectMyComputer'; import { DocumentGatewayKube } from 'teleterm/ui/DocumentGatewayKube'; +import { DocumentGatewayApp } from 'teleterm/ui/DocumentGatewayApp'; import Document from 'teleterm/ui/Document'; -import { RootClusterUri } from 'teleterm/ui/uri'; +import { RootClusterUri, isDatabaseUri, isAppUri } from 'teleterm/ui/uri'; import { ResourcesContextProvider } from '../DocumentCluster/resourcesContext'; @@ -119,12 +122,31 @@ const DocumentsContainer = styled.div` function MemoizedDocument(props: { doc: types.Document; visible: boolean }) { const { doc, visible } = props; - return React.useMemo(() => { + + return useMemo(() => { switch (doc.kind) { case 'doc.cluster': return ; - case 'doc.gateway': - return ; + case 'doc.gateway': { + //TODO(gzdunek): Reorganize the code related to gateways. + // We should have a parent DocumentGateway component that + // would render DocumentGatewayDatabase and DocumentGatewayApp. + if (isDatabaseUri(doc.targetUri)) { + return ; + } + if (isAppUri(doc.targetUri)) { + return ; + } + return ( + + + Cannot create a gateway for the target "{doc.targetUri}". +
+ Only database, kube, and app targets are supported. +
+
+ ); + } case 'doc.gateway_cli_client': return ; case 'doc.gateway_kube': @@ -142,7 +164,9 @@ function MemoizedDocument(props: { doc: types.Document; visible: boolean }) { default: return ( - Document kind "{doc.kind}" is not supported + + Document kind "{doc.kind}" is not supported. + ); } diff --git a/web/packages/teleterm/src/ui/TopBar/Connections/ConnectionsFilterableList/ConnectionItem.tsx b/web/packages/teleterm/src/ui/TopBar/Connections/ConnectionsFilterableList/ConnectionItem.tsx index 064b5036d249c..5a44b4eed0694 100644 --- a/web/packages/teleterm/src/ui/TopBar/Connections/ConnectionsFilterableList/ConnectionItem.tsx +++ b/web/packages/teleterm/src/ui/TopBar/Connections/ConnectionsFilterableList/ConnectionItem.tsx @@ -22,9 +22,9 @@ import { Trash, Unlink } from 'design/Icon'; import { ExtendedTrackedConnection } from 'teleterm/ui/services/connectionTracker'; import { ListItem } from 'teleterm/ui/components/ListItem'; -import { assertUnreachable } from 'teleterm/ui/utils'; import { useKeyboardArrowsNavigation } from 'teleterm/ui/components/KeyboardArrowsNavigation'; +import { isAppUri, isDatabaseUri } from 'teleterm/ui/uri'; import { ConnectionStatusIndicator } from './ConnectionStatusIndicator'; @@ -107,7 +107,7 @@ export function ConnectionItem(props: ConnectionItemProps) { border-radius: 4px; `} > - {getKindName(props.item.kind)} + {getKindName(props.item)} } } + // DELETE IN 15.0.0 (gzdunek), + // since we will no longer have to support old kube connections. + // See call in `trackedConnectionOperationsFactory.ts` for more details. async removeKubeGateway(kubeUri: uri.KubeUri) { const gateway = this.findGatewayByConnectionParams(kubeUri, ''); if (gateway) { diff --git a/web/packages/teleterm/src/ui/services/connectionTracker/trackedConnectionOperationsFactory.ts b/web/packages/teleterm/src/ui/services/connectionTracker/trackedConnectionOperationsFactory.ts index 00572c663d577..eb0ac1c58c50f 100644 --- a/web/packages/teleterm/src/ui/services/connectionTracker/trackedConnectionOperationsFactory.ts +++ b/web/packages/teleterm/src/ui/services/connectionTracker/trackedConnectionOperationsFactory.ts @@ -103,7 +103,7 @@ export class TrackedConnectionOperationsFactory { private getConnectionGatewayOperations( connection: TrackedGatewayConnection ): TrackedConnectionOperations { - const { rootClusterId, leafClusterId } = routing.parseDbUri( + const { rootClusterId, leafClusterId } = routing.parseClusterUri( connection.targetUri ).params; const { rootClusterUri, leafClusterUri } = this.getClusterUris({ @@ -186,25 +186,31 @@ export class TrackedConnectionOperationsFactory { documentsService.open(gwDoc.uri); }, disconnect: async () => { - return this._clustersService - .removeKubeGateway(connection.kubeUri) - .then(() => { - documentsService - .getDocuments() - .filter(getGatewayKubeDocumentByConnection(connection)) - .forEach(document => { - documentsService.close(document.uri); - }); - - // Remove deprecated doc.terminal_tsh_kube documents. - // DELETE IN 15.0.0. See DocumentGatewayKube for more details. - documentsService - .getDocuments() - .filter(getKubeDocumentByConnection(connection)) - .forEach(document => { - documentsService.close(document.uri); - }); - }); + return ( + this._clustersService + // We have to use `removeKubeGateway` instead of `removeGateway`, + // because we need to support both the old kube connections + // (which don't have gatewayUri and an underlying gateway) + // and new ones (which do have a gateway). + .removeKubeGateway(connection.kubeUri) + .then(() => { + documentsService + .getDocuments() + .filter(getGatewayKubeDocumentByConnection(connection)) + .forEach(document => { + documentsService.close(document.uri); + }); + + // Remove deprecated doc.terminal_tsh_kube documents. + // DELETE IN 15.0.0. See DocumentGatewayKube for more details. + documentsService + .getDocuments() + .filter(getKubeDocumentByConnection(connection)) + .forEach(document => { + documentsService.close(document.uri); + }); + }) + ); }, remove: async () => {}, }; diff --git a/web/packages/teleterm/src/ui/services/connectionTracker/types.ts b/web/packages/teleterm/src/ui/services/connectionTracker/types.ts index 245af15a70c5d..0991ebff5de40 100644 --- a/web/packages/teleterm/src/ui/services/connectionTracker/types.ts +++ b/web/packages/teleterm/src/ui/services/connectionTracker/types.ts @@ -16,7 +16,13 @@ * along with this program. If not, see . */ -import { DatabaseUri, GatewayUri, KubeUri, ServerUri } from 'teleterm/ui/uri'; +import { + DatabaseUri, + GatewayUri, + KubeUri, + ServerUri, + AppUri, +} from 'teleterm/ui/uri'; type TrackedConnectionBase = { connected: boolean; @@ -33,7 +39,7 @@ export interface TrackedServerConnection extends TrackedConnectionBase { export interface TrackedGatewayConnection extends TrackedConnectionBase { kind: 'connection.gateway'; - targetUri: DatabaseUri; + targetUri: DatabaseUri | AppUri; targetName: string; targetUser?: string; port?: string; diff --git a/web/packages/teleterm/src/ui/services/modals/modalsService.ts b/web/packages/teleterm/src/ui/services/modals/modalsService.ts index d89372d40032b..74996fe606458 100644 --- a/web/packages/teleterm/src/ui/services/modals/modalsService.ts +++ b/web/packages/teleterm/src/ui/services/modals/modalsService.ts @@ -143,7 +143,7 @@ export interface DialogClusterConnect { export interface ClusterConnectReasonGatewayCertExpired { kind: 'reason.gateway-cert-expired'; - targetUri: string; + targetUri: uri.GatewayTargetUri; // The original RPC message passes gatewayUri but we might not always be able to resolve it to a // gateway, hence the use of undefined. gateway: types.Gateway | undefined; diff --git a/web/packages/teleterm/src/ui/services/tshdNotifications/tshdNotificationService.ts b/web/packages/teleterm/src/ui/services/tshdNotifications/tshdNotificationService.ts index abb7569bd130b..c32467ab9381a 100644 --- a/web/packages/teleterm/src/ui/services/tshdNotifications/tshdNotificationService.ts +++ b/web/packages/teleterm/src/ui/services/tshdNotifications/tshdNotificationService.ts @@ -16,6 +16,7 @@ * along with this program. If not, see . */ +import { getTargetNameFromUri } from 'teleterm/services/tshd/gateway'; import { SendNotificationRequest } from 'teleterm/services/tshdEvents'; import { ClustersService } from 'teleterm/ui/services/clusters'; import { NotificationsService } from 'teleterm/ui/services/notifications'; @@ -37,16 +38,11 @@ export class TshdNotificationsService { let targetUser: string; let targetDesc: string; - // Try to get target name and user from gateway object. if (gateway) { targetName = gateway.targetName; targetUser = gateway.targetUser; } else { - // Try to get target name from target URI. - targetName = - routing.parseDbUri(targetUri)?.params['dbId'] || - routing.parseKubeUri(targetUri)?.params['kubeId'] || - targetUri; + targetName = getTargetNameFromUri(targetUri); } if (targetUser) { diff --git a/web/packages/teleterm/src/ui/services/usage/usageService.ts b/web/packages/teleterm/src/ui/services/usage/usageService.ts index c505bdc2a4cb2..be4c1e1b4526b 100644 --- a/web/packages/teleterm/src/ui/services/usage/usageService.ts +++ b/web/packages/teleterm/src/ui/services/usage/usageService.ts @@ -73,7 +73,7 @@ export class UsageService { captureProtocolUse( uri: ClusterOrResourceUri, - protocol: 'ssh' | 'kube' | 'db', + protocol: 'ssh' | 'kube' | 'db' | 'app', origin: DocumentOrigin ): void { const clusterProperties = this.getClusterProperties(uri); diff --git a/web/packages/teleterm/src/ui/services/workspacesService/documentsService/connectToApp.ts b/web/packages/teleterm/src/ui/services/workspacesService/documentsService/connectToApp.ts index 4751ded6e3801..5268d5fd72ea0 100644 --- a/web/packages/teleterm/src/ui/services/workspacesService/documentsService/connectToApp.ts +++ b/web/packages/teleterm/src/ui/services/workspacesService/documentsService/connectToApp.ts @@ -16,6 +16,55 @@ * along with this program. If not, see . */ -export async function connectToApp(): Promise { - alert('Not implemented'); +import { routing } from 'teleterm/ui/uri'; +import { IAppContext } from 'teleterm/ui/types'; + +import { App } from 'teleterm/services/tshd/types'; + +import { DocumentOrigin } from './types'; + +export async function connectToApp( + ctx: IAppContext, + target: App, + telemetry: { origin: DocumentOrigin } +): Promise { + //TODO(gzdunek): Add regular dialogs for connecting to unsupported apps (non HTTP/TCP) + // that will explain that the user can connect via tsh/Web UI to them. + // These dialogs should provide instructions, just like those in the Web UI for database access. + if (target.samlApp) { + alert('SAML apps are supported only in Web UI.'); + return; + } + + if (target.awsConsole) { + alert('AWS apps are supported in Web UI and tsh.'); + return; + } + + if (target.endpointUri.startsWith('cloud://')) { + alert('Cloud apps are supported only in tsh.'); + return; + } + + const rootClusterUri = routing.ensureRootClusterUri(target.uri); + const documentsService = + ctx.workspacesService.getWorkspaceDocumentService(rootClusterUri); + const doc = documentsService.createGatewayDocument({ + targetUri: target.uri, + origin: telemetry.origin, + targetName: routing.parseAppUri(target.uri).params.appId, + targetUser: '', + }); + + const connectionToReuse = ctx.connectionTracker.findConnectionByDocument(doc); + + if (connectionToReuse) { + await ctx.connectionTracker.activateItem(connectionToReuse.id, { + origin: telemetry.origin, + }); + } else { + await ctx.workspacesService.setActiveWorkspace(rootClusterUri); + documentsService.add(doc); + documentsService.open(doc.uri); + } } diff --git a/web/packages/teleterm/src/ui/services/workspacesService/documentsService/documentsService.ts b/web/packages/teleterm/src/ui/services/workspacesService/documentsService/documentsService.ts index 605f4c2772c83..142ea17adec0a 100644 --- a/web/packages/teleterm/src/ui/services/workspacesService/documentsService/documentsService.ts +++ b/web/packages/teleterm/src/ui/services/workspacesService/documentsService/documentsService.ts @@ -157,7 +157,7 @@ export class DocumentsService { origin, } = opts; const uri = routing.getDocUri({ docId: unique() }); - const title = `${targetUser}@${targetName}`; + const title = targetUser ? `${targetUser}@${targetName}` : targetName; return { uri, diff --git a/web/packages/teleterm/src/ui/services/workspacesService/documentsService/types.ts b/web/packages/teleterm/src/ui/services/workspacesService/documentsService/types.ts index 3ca88754bb529..e8a289c213965 100644 --- a/web/packages/teleterm/src/ui/services/workspacesService/documentsService/types.ts +++ b/web/packages/teleterm/src/ui/services/workspacesService/documentsService/types.ts @@ -104,7 +104,7 @@ export interface DocumentGateway extends DocumentBase { // status is used merely to show a progress bar when the gateway is being set up. status: '' | 'connecting' | 'connected' | 'error'; gatewayUri?: uri.GatewayUri; - targetUri: uri.DatabaseUri; + targetUri: uri.DatabaseUri | uri.AppUri; targetUser: string; targetName: string; targetSubresourceName?: string; @@ -247,7 +247,7 @@ export function isDocumentTshNodeWithServerId( export type CreateGatewayDocumentOpts = { gatewayUri?: uri.GatewayUri; - targetUri: uri.DatabaseUri; + targetUri: uri.DatabaseUri | uri.AppUri; targetName: string; targetUser: string; targetSubresourceName?: string; diff --git a/web/packages/teleterm/src/ui/uri.ts b/web/packages/teleterm/src/ui/uri.ts index 5250cba793f33..acad96bbd1ae9 100644 --- a/web/packages/teleterm/src/ui/uri.ts +++ b/web/packages/teleterm/src/ui/uri.ts @@ -229,6 +229,22 @@ export const routing = { }, }; +export function isAppUri(uri: string): uri is AppUri { + return !!routing.parseAppUri(uri); +} + +export function isDatabaseUri(uri: string): uri is DatabaseUri { + return !!routing.parseDbUri(uri); +} + +export function isServerUri(uri: string): uri is ServerUri { + return !!routing.parseServerUri(uri); +} + +export function isKubeUri(uri: string): uri is KubeUri { + return !!routing.parseKubeUri(uri); +} + export type Params = { rootClusterId?: string; leafClusterId?: string;