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;