From 40ec85f8170f19537af9aaa4def3296d5841f5f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rafa=C5=82=20Cie=C5=9Blak?= Date: Wed, 8 Jan 2025 14:37:30 +0100 Subject: [PATCH] [v17] `tsh proxy app`: Add support for multi-port TCP apps (#50691) * `tsh proxy app`: Add support for multi-port TCP apps * Pass around PortMapping rather than port as string * Pass target port to local proxy * Validate target port * Accept target-port flag in tsh app login * Don't reuse certs for multi-port apps * Add a test for multi-port tsh proxy app * Use strings.Cut instead of strings.SplitN * Remove appInfo from localProxyApp * Remove unused method appInfo.appLocalCAPath * Use assert instead of require inside require.EventuallyWithT v17 is still on v1.9.0 of testify which expects you to use assert functions. --- lib/client/api.go | 31 ++++ lib/client/api_test.go | 69 +++++++++ tool/tsh/common/access_request_test.go | 2 +- tool/tsh/common/app.go | 27 +++- tool/tsh/common/app_aws.go | 11 +- tool/tsh/common/app_azure.go | 12 +- tool/tsh/common/app_gcp.go | 14 +- tool/tsh/common/app_local_proxy.go | 111 +++++++++++--- tool/tsh/common/db_test.go | 6 +- tool/tsh/common/kube_test.go | 8 +- tool/tsh/common/proxy.go | 29 +++- tool/tsh/common/proxy_test.go | 176 ++++++++++++++++++++-- tool/tsh/common/tsh.go | 8 +- tool/tsh/common/workload_identity_test.go | 2 +- 14 files changed, 433 insertions(+), 73 deletions(-) diff --git a/lib/client/api.go b/lib/client/api.go index 6cc0caae15286..7a283e5dbeb3e 100644 --- a/lib/client/api.go +++ b/lib/client/api.go @@ -4974,6 +4974,37 @@ func ParseDynamicPortForwardSpec(spec []string) (DynamicForwardedPorts, error) { return result, nil } +// PortMapping represents a mapping of LocalPort to TargetPort, e.g., "1337:42". +type PortMapping struct { + LocalPort int + TargetPort int +} + +// ParsePortMapping parses textual form of port mapping (e.g., "1337:42") into a struct. It accepts +// a single port number as well (e.g., "42"). Both numbers must be between 0 and 65535. +func ParsePortMapping(rawPorts string) (PortMapping, error) { + if rawPorts == "" { + return PortMapping{}, nil + } + + rawLocalPort, rawTargetPort, colonFound := strings.Cut(rawPorts, ":") + localPort, err := strconv.ParseUint(rawLocalPort, 10, 16) + if err != nil { + return PortMapping{}, trace.Wrap(err, "parsing local port") + } + + if !colonFound { + return PortMapping{LocalPort: int(localPort)}, nil + } + + targetPort, err := strconv.ParseUint(rawTargetPort, 10, 16) + if err != nil { + return PortMapping{}, trace.Wrap(err, "parsing target port") + } + + return PortMapping{LocalPort: int(localPort), TargetPort: int(targetPort)}, nil +} + // InsecureSkipHostKeyChecking is used when the user passes in // "StrictHostKeyChecking yes". func InsecureSkipHostKeyChecking(host string, remote net.Addr, key ssh.PublicKey) error { diff --git a/lib/client/api_test.go b/lib/client/api_test.go index 136d338b01e39..73742b19011e2 100644 --- a/lib/client/api_test.go +++ b/lib/client/api_test.go @@ -1385,3 +1385,72 @@ Future versions of tsh will fail when incompatible versions are detected. }) } } + +func TestParsePortMapping(t *testing.T) { + tests := []struct { + in string + want PortMapping + wantErr bool + }{ + { + in: "", + want: PortMapping{}, + }, + { + in: "1337", + want: PortMapping{LocalPort: 1337}, + }, + { + in: "1337:42", + want: PortMapping{LocalPort: 1337, TargetPort: 42}, + }, + { + in: "0:0", + want: PortMapping{}, + }, + { + in: "0:42", + want: PortMapping{TargetPort: 42}, + }, + { + in: " ", + wantErr: true, + }, + { + in: "1337:", + wantErr: true, + }, + { + in: ":42", + wantErr: true, + }, + { + in: "13371337", + wantErr: true, + }, + { + in: "42:73317331", + wantErr: true, + }, + { + in: "1337:42:42", + wantErr: true, + }, + { + in: "1337:42:", + wantErr: true, + }, + } + + for _, test := range tests { + t.Run(test.in, func(t *testing.T) { + out, err := ParsePortMapping(test.in) + if test.wantErr { + require.Error(t, err) + } else { + require.NoError(t, err) + require.Equal(t, test.want, out) + } + }) + } +} diff --git a/tool/tsh/common/access_request_test.go b/tool/tsh/common/access_request_test.go index 7f25c5157149b..9b4c5bafff653 100644 --- a/tool/tsh/common/access_request_test.go +++ b/tool/tsh/common/access_request_test.go @@ -200,7 +200,7 @@ func TestAccessRequestSearch(t *testing.T) { tc := tc t.Run(tc.name, func(t *testing.T) { t.Parallel() - homePath, _ := mustLogin(t, s, tc.args.teleportCluster) + homePath, _ := mustLoginLegacy(t, s, tc.args.teleportCluster) captureStdout := new(bytes.Buffer) err := Run( context.Background(), diff --git a/tool/tsh/common/app.go b/tool/tsh/common/app.go index 15e391c3959ec..be4b56cadf74d 100644 --- a/tool/tsh/common/app.go +++ b/tool/tsh/common/app.go @@ -78,14 +78,23 @@ func onAppLogin(cf *CLIConf) error { } defer clusterClient.Close() + if err := validateTargetPort(app, int(cf.TargetPort)); err != nil { + return trace.Wrap(err) + } + rootClient, err := clusterClient.ConnectToRootCluster(cf.Context) if err != nil { return trace.Wrap(err) } + routeToApp := appInfo.RouteToApp + if cf.TargetPort != 0 { + routeToApp.TargetPort = uint32(cf.TargetPort) + } + appCertParams := client.ReissueParams{ RouteToCluster: tc.SiteName, - RouteToApp: appInfo.RouteToApp, + RouteToApp: routeToApp, AccessRequests: appInfo.profile.ActiveRequests.AccessRequests, } @@ -98,7 +107,7 @@ func onAppLogin(cf *CLIConf) error { return trace.Wrap(err) } - if err := printAppCommand(cf, tc, app, appInfo.RouteToApp); err != nil { + if err := printAppCommand(cf, tc, app, routeToApp); err != nil { return trace.Wrap(err) } @@ -169,8 +178,14 @@ func printAppCommand(cf *CLIConf, tc *client.TeleportClient, app types.Applicati }) case app.IsTCP(): + appNameWithOptionalTargetPort := app.GetName() + if routeToApp.TargetPort != 0 { + appNameWithOptionalTargetPort = fmt.Sprintf("%s:%d", app.GetName(), routeToApp.TargetPort) + } + return tcpAppLoginTemplate.Execute(output, map[string]string{ - "appName": app.GetName(), + "appName": app.GetName(), + "appNameWithOptionalTargetPort": appNameWithOptionalTargetPort, }) case localProxyRequiredForApp(tc): @@ -231,7 +246,7 @@ Then connect to the application through this proxy: // tcpAppLoginTemplate is the message that gets printed to a user upon successful // login into a TCP application. var tcpAppLoginTemplate = template.Must(template.New("").Parse( - `Logged into TCP app {{.appName}}. Start the local TCP proxy for it: + `Logged into TCP app {{.appNameWithOptionalTargetPort}}. Start the local TCP proxy for it: tsh proxy app {{.appName}} @@ -608,10 +623,6 @@ type appInfo struct { profile *client.ProfileStatus } -func (a *appInfo) appLocalCAPath(cluster string) string { - return a.profile.AppLocalCAPath(cluster, a.RouteToApp.Name) -} - // GetApp returns the cached app or fetches it using the app route and // caches the result. func (a *appInfo) GetApp(ctx context.Context, clt apiclient.GetResourcesClient) (types.Application, error) { diff --git a/tool/tsh/common/app_aws.go b/tool/tsh/common/app_aws.go index 1a9f395d74172..409de500722bd 100644 --- a/tool/tsh/common/app_aws.go +++ b/tool/tsh/common/app_aws.go @@ -87,15 +87,20 @@ type awsApp struct { // newAWSApp creates a new AWS app. func newAWSApp(tc *client.TeleportClient, cf *CLIConf, appInfo *appInfo) (*awsApp, error) { + localProxyApp, err := newLocalProxyApp(tc, appInfo.profile, appInfo.RouteToApp, cf.LocalProxyPort, cf.InsecureSkipVerify) + if err != nil { + return nil, trace.Wrap(err) + } + return &awsApp{ - localProxyApp: newLocalProxyApp(tc, appInfo, cf.LocalProxyPort, cf.InsecureSkipVerify), + localProxyApp: localProxyApp, cf: cf, }, nil } // GetAppName returns the app name. func (a *awsApp) GetAppName() string { - return a.appInfo.RouteToApp.Name + return a.routeToApp.Name } // StartLocalProxies sets up local proxies for serving AWS clients. @@ -181,7 +186,7 @@ func (a *awsApp) GetEnvVars() (map[string]string, error) { // https://docs.aws.amazon.com/cli/latest/userguide/cli-configure-envvars.html "AWS_ACCESS_KEY_ID": credValues.AccessKeyID, "AWS_SECRET_ACCESS_KEY": credValues.SecretAccessKey, - "AWS_CA_BUNDLE": a.appInfo.appLocalCAPath(a.cf.SiteName), + "AWS_CA_BUNDLE": a.profile.AppLocalCAPath(a.cf.SiteName, a.routeToApp.Name), } // Set proxy settings. diff --git a/tool/tsh/common/app_azure.go b/tool/tsh/common/app_azure.go index 6a31c014f802d..d61ea04a8d072 100644 --- a/tool/tsh/common/app_azure.go +++ b/tool/tsh/common/app_azure.go @@ -82,9 +82,13 @@ func newAzureApp(tc *client.TeleportClient, cf *CLIConf, appInfo *appInfo) (*azu if err != nil { return nil, err } + localProxyApp, err := newLocalProxyApp(tc, appInfo.profile, appInfo.RouteToApp, cf.LocalProxyPort, cf.InsecureSkipVerify) + if err != nil { + return nil, trace.Wrap(err) + } return &azureApp{ - localProxyApp: newLocalProxyApp(tc, appInfo, cf.LocalProxyPort, cf.InsecureSkipVerify), + localProxyApp: localProxyApp, cf: cf, msiSecret: msiSecret, }, nil @@ -132,7 +136,7 @@ func (a *azureApp) StartLocalProxies(ctx context.Context) error { // but at this moment there is no clear advantage over simply issuing a new random identifier. TenantID: uuid.New().String(), ClientID: uuid.New().String(), - Identity: a.appInfo.RouteToApp.AzureIdentity, + Identity: a.routeToApp.AzureIdentity, } // HTTPS proxy mode @@ -160,7 +164,7 @@ func (a *azureApp) GetEnvVars() (map[string]string, error) { // 1. `tsh az login` in one console // 2. `az ...` in another console // without custom config dir the second invocation will hang, attempting to connect to (inaccessible without configuration) MSI. - "AZURE_CONFIG_DIR": filepath.Join(profile.FullProfilePath(a.cf.HomePath), "azure", a.appInfo.RouteToApp.ClusterName, a.appInfo.RouteToApp.Name), + "AZURE_CONFIG_DIR": filepath.Join(profile.FullProfilePath(a.cf.HomePath), "azure", a.routeToApp.ClusterName, a.routeToApp.Name), // setting MSI_ENDPOINT instructs Azure CLI to make managed identity calls on this address. // the requests will be handled by tsh proxy. "MSI_ENDPOINT": "https://" + types.TeleportAzureMSIEndpoint + "/" + a.msiSecret, @@ -169,7 +173,7 @@ func (a *azureApp) GetEnvVars() (map[string]string, error) { // This isn't portable and applications other than az CLI may have to set different env variables, // add the application cert to system root store (not recommended, ultimate fallback) // or use equivalent of --insecure flag. - "REQUESTS_CA_BUNDLE": a.appInfo.appLocalCAPath(a.cf.SiteName), + "REQUESTS_CA_BUNDLE": a.profile.AppLocalCAPath(a.cf.SiteName, a.routeToApp.Name), } // Set proxy settings. diff --git a/tool/tsh/common/app_gcp.go b/tool/tsh/common/app_gcp.go index 237eb207446c5..aa30c445085d4 100644 --- a/tool/tsh/common/app_gcp.go +++ b/tool/tsh/common/app_gcp.go @@ -114,9 +114,13 @@ func newGCPApp(tc *client.TeleportClient, cf *CLIConf, appInfo *appInfo) (*gcpAp h := fnv.New32a() _, _ = h.Write([]byte(secret)) prefix := fmt.Sprintf("%x", h.Sum32()) + localProxyApp, err := newLocalProxyApp(tc, appInfo.profile, appInfo.RouteToApp, cf.LocalProxyPort, cf.InsecureSkipVerify) + if err != nil { + return nil, trace.Wrap(err) + } return &gcpApp{ - localProxyApp: newLocalProxyApp(tc, appInfo, cf.LocalProxyPort, cf.InsecureSkipVerify), + localProxyApp: localProxyApp, cf: cf, secret: secret, prefix: prefix, @@ -162,7 +166,7 @@ func (a *gcpApp) Close() error { } func (a *gcpApp) getGcloudConfigPath() string { - return filepath.Join(profile.FullProfilePath(a.cf.HomePath), "gcp", a.appInfo.RouteToApp.ClusterName, a.appInfo.RouteToApp.Name, "gcloud") + return filepath.Join(profile.FullProfilePath(a.cf.HomePath), "gcp", a.routeToApp.ClusterName, a.routeToApp.Name, "gcloud") } // removeBotoConfig removes config files written by WriteBotoConfig. @@ -175,7 +179,7 @@ func (a *gcpApp) removeBotoConfig() []error { } func (a *gcpApp) getBotoConfigDir() string { - return filepath.Join(profile.FullProfilePath(a.cf.HomePath), "gcp", a.appInfo.RouteToApp.ClusterName, a.appInfo.RouteToApp.Name) + return filepath.Join(profile.FullProfilePath(a.cf.HomePath), "gcp", a.routeToApp.ClusterName, a.routeToApp.Name) } func (a *gcpApp) getBotoConfigPath() string { @@ -224,7 +228,7 @@ func (a *gcpApp) writeBotoConfig() error { // GetEnvVars returns required environment variables to configure the // clients. func (a *gcpApp) GetEnvVars() (map[string]string, error) { - projectID, err := gcp.ProjectIDFromServiceAccountName(a.appInfo.RouteToApp.GCPServiceAccount) + projectID, err := gcp.ProjectIDFromServiceAccountName(a.routeToApp.GCPServiceAccount) if err != nil { return nil, trace.Wrap(err) } @@ -236,7 +240,7 @@ func (a *gcpApp) GetEnvVars() (map[string]string, error) { // Set core.custom_ca_certs_file via env variable, customizing the path to CA certs file. // https://cloud.google.com/sdk/gcloud/reference/config/set#:~:text=custom_ca_certs_file - "CLOUDSDK_CORE_CUSTOM_CA_CERTS_FILE": a.appInfo.appLocalCAPath(a.cf.SiteName), + "CLOUDSDK_CORE_CUSTOM_CA_CERTS_FILE": a.profile.AppLocalCAPath(a.cf.SiteName, a.routeToApp.Name), // We need to set project ID. This is sourced from the account name. // https://cloud.google.com/sdk/gcloud/reference/config#GROUP:~:text=authentication%20to%20gsutil.-,project,-Project%20ID%20of diff --git a/tool/tsh/common/app_local_proxy.go b/tool/tsh/common/app_local_proxy.go index f856cf3d66b51..d613e2750012a 100644 --- a/tool/tsh/common/app_local_proxy.go +++ b/tool/tsh/common/app_local_proxy.go @@ -19,25 +19,32 @@ package common import ( - "cmp" "context" "crypto/tls" "fmt" "net" "net/http" + "strconv" "github.com/gravitational/trace" + "github.com/gravitational/teleport/api/client/proto" + "github.com/gravitational/teleport/api/types" "github.com/gravitational/teleport/lib/client" "github.com/gravitational/teleport/lib/srv/alpnproxy" ) // localProxyApp is a generic app that can start local proxies. type localProxyApp struct { - tc *client.TeleportClient - appInfo *appInfo - insecure bool - port string + tc *client.TeleportClient + // profile is a cached profile status for the current login session. + profile *client.ProfileStatus + // routeToApp is a route to the app without TargetPort being set. + routeToApp proto.RouteToApp + // app is available only when starting a localProxyApp through newLocalProxyAppWithPortMapping. + app types.Application + insecure bool + portMapping client.PortMapping localALPNProxy *alpnproxy.LocalProxy localForwardProxy *alpnproxy.ForwardProxy @@ -45,19 +52,65 @@ type localProxyApp struct { type requestMatcher func(req *http.Request) bool -// newLocalProxyApp creates a new generic app. -func newLocalProxyApp(tc *client.TeleportClient, appInfo *appInfo, port string, insecure bool) *localProxyApp { +// newLocalProxyApp creates a new generic app proxy. +func newLocalProxyApp(tc *client.TeleportClient, profile *client.ProfileStatus, routeToApp proto.RouteToApp, rawLocalPort string, insecure bool) (*localProxyApp, error) { + var portMapping client.PortMapping + if rawLocalPort != "" { + localPort, err := strconv.Atoi(rawLocalPort) + if err != nil { + return nil, trace.Wrap(err, "parsing port") + } + portMapping.LocalPort = localPort + } + return &localProxyApp{ - tc: tc, - appInfo: appInfo, - port: port, - insecure: insecure, + tc: tc, + profile: profile, + routeToApp: routeToApp, + portMapping: portMapping, + insecure: insecure, + }, nil +} + +// newLocalProxyAppWithPortMapping creates a new generic app proxy. Unlike newLocalProxyApp, it +// accepts a specific port mapping as an argument. +func newLocalProxyAppWithPortMapping(ctx context.Context, tc *client.TeleportClient, profile *client.ProfileStatus, routeToApp proto.RouteToApp, app types.Application, portMapping client.PortMapping, insecure bool) (*localProxyApp, error) { + if err := validateTargetPort(app, portMapping.TargetPort); err != nil { + return nil, trace.Wrap(err) + } + return &localProxyApp{ + tc: tc, + profile: profile, + routeToApp: routeToApp, + app: app, + portMapping: portMapping, + insecure: insecure, + }, nil +} + +// validateTargetPort is used in both tsh proxy app and tsh app login. +func validateTargetPort(app types.Application, targetPort int) error { + if targetPort == 0 { + return nil + } + + tcpPorts := app.GetTCPPorts() + if len(tcpPorts) == 0 { + return trace.BadParameter("cannot specify target port %d because app %q does not provide access to multiple ports", + targetPort, app.GetName()) + } + + if !tcpPorts.Contains(targetPort) { + return trace.BadParameter("port %d is not included in target ports of app %q; valid ports: %s", + targetPort, app.GetName(), tcpPorts) } + + return nil } // StartLocalProxy sets up local proxies for serving app clients. func (a *localProxyApp) StartLocalProxy(ctx context.Context, opts ...alpnproxy.LocalProxyConfigOpt) error { - if err := a.startLocalALPNProxy(ctx, a.port, false /*withTLS*/, opts...); err != nil { + if err := a.startLocalALPNProxy(ctx, a.portMapping, false /*withTLS*/, opts...); err != nil { return trace.Wrap(err) } return nil @@ -65,7 +118,7 @@ func (a *localProxyApp) StartLocalProxy(ctx context.Context, opts ...alpnproxy.L // StartLocalProxy sets up local proxies for serving app clients. func (a *localProxyApp) StartLocalProxyWithTLS(ctx context.Context, opts ...alpnproxy.LocalProxyConfigOpt) error { - if err := a.startLocalALPNProxy(ctx, a.port, true /*withTLS*/, opts...); err != nil { + if err := a.startLocalALPNProxy(ctx, a.portMapping, true /*withTLS*/, opts...); err != nil { return trace.Wrap(err) } return nil @@ -73,11 +126,11 @@ func (a *localProxyApp) StartLocalProxyWithTLS(ctx context.Context, opts ...alpn // StartLocalProxy sets up local proxies for serving app clients. func (a *localProxyApp) StartLocalProxyWithForwarder(ctx context.Context, forwardMatcher requestMatcher, opts ...alpnproxy.LocalProxyConfigOpt) error { - if err := a.startLocalALPNProxy(ctx, "", true /*withTLS*/, opts...); err != nil { + if err := a.startLocalALPNProxy(ctx, client.PortMapping{}, true /*withTLS*/, opts...); err != nil { return trace.Wrap(err) } - if err := a.startLocalForwardProxy(ctx, a.port, forwardMatcher); err != nil { + if err := a.startLocalForwardProxy(ctx, a.portMapping.LocalPort, forwardMatcher); err != nil { return trace.Wrap(err) } return nil @@ -96,22 +149,34 @@ func (a *localProxyApp) Close() error { } // startLocalALPNProxy starts the local ALPN proxy. -func (a *localProxyApp) startLocalALPNProxy(ctx context.Context, port string, withTLS bool, opts ...alpnproxy.LocalProxyConfigOpt) error { +func (a *localProxyApp) startLocalALPNProxy(ctx context.Context, portMapping client.PortMapping, withTLS bool, opts ...alpnproxy.LocalProxyConfigOpt) error { + routeToAppWithTargetPort := a.routeToApp + if portMapping.TargetPort != 0 { + routeToAppWithTargetPort.TargetPort = uint32(portMapping.TargetPort) + } // Create an app cert checker to check and reissue app certs for the local app proxy. - appCertChecker := client.NewAppCertChecker(a.tc, a.appInfo.RouteToApp, nil, client.WithTTL(a.tc.KeyTTL)) + appCertChecker := client.NewAppCertChecker(a.tc, routeToAppWithTargetPort, nil, client.WithTTL(a.tc.KeyTTL)) // If a stored cert is found for the app, try using it. // Otherwise, let the checker reissue one as needed. - cert, err := loadAppCertificate(a.tc, a.appInfo.RouteToApp.Name) + cert, err := loadAppCertificate(a.tc, routeToAppWithTargetPort.Name) if err == nil { - appCertChecker.SetCert(cert) + if a.app != nil && len(a.app.GetTCPPorts()) > 0 { + // There are too many cases to cover when dealing with a multi-port app and an existing cert. + // We'd need to consider portMapping passed from argv and TargetPort in the cert. + // As tsh proxy app is not the recommended way to access multi-port apps, let's bail out of + // using the existing cert and instead always generate a new one. + fmt.Println("Warning: Ignoring existing app cert and generating a new one. Connections made through this proxy will be routed according to command arguments.") + } else { + appCertChecker.SetCert(cert) + } } - listenAddr := fmt.Sprintf("localhost:%s", cmp.Or(port, "0")) + listenAddr := fmt.Sprintf("localhost:%d", portMapping.LocalPort) var listener net.Listener if withTLS { - appLocalCAPath := a.appInfo.appLocalCAPath(a.tc.SiteName) + appLocalCAPath := a.profile.AppLocalCAPath(a.tc.SiteName, routeToAppWithTargetPort.Name) localCertGenerator, err := client.NewLocalCertGenerator(ctx, appCertChecker, appLocalCAPath) if err != nil { return trace.Wrap(err) @@ -152,8 +217,8 @@ func (a *localProxyApp) startLocalALPNProxy(ctx context.Context, port string, wi // startLocalForwardProxy starts a local forward proxy that forwards matching requests // to the local ALPN proxy and unmatched requests to their original hosts. -func (a *localProxyApp) startLocalForwardProxy(ctx context.Context, port string, forwardMatcher requestMatcher) error { - listenAddr := fmt.Sprintf("localhost:%s", cmp.Or(port, "0")) +func (a *localProxyApp) startLocalForwardProxy(ctx context.Context, port int, forwardMatcher requestMatcher) error { + listenAddr := fmt.Sprintf("localhost:%d", port) listener, err := net.Listen("tcp", listenAddr) if err != nil { return trace.Wrap(err) diff --git a/tool/tsh/common/db_test.go b/tool/tsh/common/db_test.go index f17db39ed6143..06787491de421 100644 --- a/tool/tsh/common/db_test.go +++ b/tool/tsh/common/db_test.go @@ -231,7 +231,7 @@ func testDatabaseLogin(t *testing.T) { s.user = alice // Log into Teleport cluster. - tmpHomePath, _ := mustLogin(t, s) + tmpHomePath, _ := mustLoginLegacy(t, s) testCases := []struct { // the test name @@ -717,7 +717,7 @@ func testListDatabase(t *testing.T) { }), ) - tshHome, _ := mustLogin(t, s) + tshHome, _ := mustLoginLegacy(t, s) captureStdout := new(bytes.Buffer) err := Run(context.Background(), []string{ @@ -1589,7 +1589,7 @@ func testDatabaseSelection(t *testing.T) { s.user = alice // Log into Teleport cluster. - tmpHomePath, _ := mustLogin(t, s) + tmpHomePath, _ := mustLoginLegacy(t, s) t.Run("GetDatabasesForLogout", func(t *testing.T) { t.Parallel() diff --git a/tool/tsh/common/kube_test.go b/tool/tsh/common/kube_test.go index 6fb399aa5cc72..ae0890e8b8bbe 100644 --- a/tool/tsh/common/kube_test.go +++ b/tool/tsh/common/kube_test.go @@ -174,7 +174,7 @@ func setupKubeTestPack(t *testing.T, withMultiplexMode bool) *kubeTestPack { }), ) - mustLoginSetEnv(t, s) + mustLoginSetEnvLegacy(t, s) return &kubeTestPack{ suite: s, rootClusterName: s.root.Config.Auth.ClusterName.GetClusterName(), @@ -551,7 +551,7 @@ func TestKubeSelection(t *testing.T) { t.Parallel() // login for each parallel test to avoid races when multiple tsh // clients work in the same profile dir. - tshHome, _ := mustLogin(t, s) + tshHome, _ := mustLoginLegacy(t, s) // Set kubeconfig to a non-exist file to avoid loading other things. kubeConfigPath := filepath.Join(tshHome, "kube-config") var cmdRunner func(*exec.Cmd) error @@ -591,7 +591,7 @@ func TestKubeSelection(t *testing.T) { test := test t.Run(test.desc, func(t *testing.T) { t.Parallel() - tshHome, kubeConfigPath := mustLogin(t, s) + tshHome, kubeConfigPath := mustLoginLegacy(t, s) err := Run( context.Background(), append([]string{"kube", "login", "--insecure"}, @@ -676,7 +676,7 @@ func TestKubeSelection(t *testing.T) { t.Run("access request", func(t *testing.T) { t.Parallel() // login as the user. - tshHome, kubeConfig := mustLogin(t, s) + tshHome, kubeConfig := mustLoginLegacy(t, s) // Run the login command in a goroutine so we can check if the access // request was created and approved. diff --git a/tool/tsh/common/proxy.go b/tool/tsh/common/proxy.go index 0bad358ccefe8..4f1ac71e5f1d5 100644 --- a/tool/tsh/common/proxy.go +++ b/tool/tsh/common/proxy.go @@ -466,17 +466,22 @@ func onProxyCommandApp(cf *CLIConf) error { return trace.Wrap(err) } + portMapping, err := libclient.ParsePortMapping(cf.LocalProxyPortMapping) + if err != nil { + return trace.Wrap(err) + } + + profile, err := tc.ProfileStatus() + if err != nil { + return trace.Wrap(err) + } + var ( appInfo *appInfo app types.Application ) if err := libclient.RetryWithRelogin(cf.Context, tc, func() error { var err error - profile, err := tc.ProfileStatus() - if err != nil { - return trace.Wrap(err) - } - clusterClient, err := tc.ConnectToCluster(cf.Context) if err != nil { return trace.Wrap(err) @@ -494,13 +499,21 @@ func onProxyCommandApp(cf *CLIConf) error { return trace.Wrap(err) } - proxyApp := newLocalProxyApp(tc, appInfo, cf.LocalProxyPort, cf.InsecureSkipVerify) + proxyApp, err := newLocalProxyAppWithPortMapping(cf.Context, tc, profile, appInfo.RouteToApp, app, portMapping, cf.InsecureSkipVerify) + if err != nil { + return trace.Wrap(err) + } if err := proxyApp.StartLocalProxy(cf.Context, alpnproxy.WithALPNProtocol(alpnProtocolForApp(app))); err != nil { return trace.Wrap(err) } - fmt.Printf("Proxying connections to %s on %v\n", cf.AppName, proxyApp.GetAddr()) - if cf.LocalProxyPort == "" { + appName := cf.AppName + if portMapping.TargetPort != 0 { + appName = fmt.Sprintf("%s:%d", appName, portMapping.TargetPort) + } + fmt.Printf("Proxying connections to %s on %v\n", appName, proxyApp.GetAddr()) + // If target port is not equal to zero, the user must know about the port flag. + if portMapping.LocalPort == 0 && portMapping.TargetPort == 0 { fmt.Println("To avoid port randomization, you can choose the listening port using the --port flag.") } diff --git a/tool/tsh/common/proxy_test.go b/tool/tsh/common/proxy_test.go index 639edf57c90f1..04c39356f1298 100644 --- a/tool/tsh/common/proxy_test.go +++ b/tool/tsh/common/proxy_test.go @@ -33,6 +33,7 @@ import ( "fmt" "net" "net/http" + "net/url" "os" "os/exec" "os/user" @@ -65,6 +66,7 @@ import ( "github.com/gravitational/teleport/lib/cryptosuites" "github.com/gravitational/teleport/lib/defaults" "github.com/gravitational/teleport/lib/events" + "github.com/gravitational/teleport/lib/service" "github.com/gravitational/teleport/lib/service/servicecfg" "github.com/gravitational/teleport/lib/services" "github.com/gravitational/teleport/lib/teleagent" @@ -125,7 +127,7 @@ func TestSSH(t *testing.T) { } func testRootClusterSSHAccess(t *testing.T, s *suite) { - tshHome, _ := mustLogin(t, s) + tshHome, _ := mustLoginLegacy(t, s) err := Run(context.Background(), []string{ "ssh", s.root.Config.Hostname, @@ -145,7 +147,7 @@ func testRootClusterSSHAccess(t *testing.T, s *suite) { } func testLeafClusterSSHAccess(t *testing.T, s *suite) { - tshHome, _ := mustLogin(t, s, s.leaf.Config.Auth.ClusterName.GetClusterName()) + tshHome, _ := mustLoginLegacy(t, s, s.leaf.Config.Auth.ClusterName.GetClusterName()) require.Eventually(t, func() bool { err := Run(context.Background(), []string{ "ssh", @@ -171,7 +173,7 @@ func testLeafClusterSSHAccess(t *testing.T, s *suite) { func testJumpHostSSHAccess(t *testing.T, s *suite) { // login to root - tshHome, _ := mustLogin(t, s, s.root.Config.Auth.ClusterName.GetClusterName()) + tshHome, _ := mustLoginLegacy(t, s, s.root.Config.Auth.ClusterName.GetClusterName()) // Switch to leaf cluster err := Run(context.Background(), []string{ @@ -251,7 +253,7 @@ func TestWithRsync(t *testing.T) { s := newTestSuite(t) // login and get host info - tshHome, _ := mustLogin(t, s) + tshHome, _ := mustLoginLegacy(t, s) testBin, err := os.Executable() require.NoError(t, err) @@ -534,7 +536,7 @@ func TestProxySSH(t *testing.T) { } // login to Teleport - homePath, kubeConfigPath := mustLogin(t, s) + homePath, kubeConfigPath := mustLoginLegacy(t, s) require.Eventually(t, func() bool { rnodes, _ := s.root.GetAuthServer().GetNodes(context.Background(), "default") @@ -694,7 +696,7 @@ func TestTSHProxyTemplate(t *testing.T) { require.NoError(t, err) s := newTestSuite(t) - tshHome, _ := mustLoginSetEnv(t, s) + tshHome, _ := mustLoginSetEnvLegacy(t, s) // Create proxy template configuration. tshConfigFile := filepath.Join(tshHome, client.TSHConfigPath) @@ -792,7 +794,7 @@ func TestTSHConfigConnectWithOpenSSHClient(t *testing.T) { s := newTestSuite(t, tc.opts...) // Login to the Teleport proxy. - mustLoginSetEnv(t, s) + mustLoginSetEnvLegacy(t, s) // Get SSH config file generated by the 'tsh config' command. sshConfigFile := mustGetOpenSSHConfigFile(t) @@ -952,7 +954,7 @@ func TestList(t *testing.T) { }, } - tshHome, _ := mustLogin(t, s) + tshHome, _ := mustLoginLegacy(t, s) for _, test := range testCases { t.Run(test.description, func(t *testing.T) { stdout := &bytes.Buffer{} @@ -1000,7 +1002,9 @@ func (s *suite) setMockSSOLogin(t *testing.T) CliOption { return setMockSSOLogin(s.root.GetAuthServer(), s.user, s.connector.GetName()) } -func mustLogin(t *testing.T, s *suite, args ...string) (tshHome, kubeConfig string) { +// deprecated: Use mustLogin instead which requires migrating from newTestSuite to +// tools/teleport/testenv.MakeTestServer. +func mustLoginLegacy(t *testing.T, s *suite, args ...string) (tshHome, kubeConfig string) { tshHome = t.TempDir() kubeConfig = filepath.Join(t.TempDir(), teleport.KubeConfigFile) args = append([]string{ @@ -1020,15 +1024,17 @@ func mustLogin(t *testing.T, s *suite, args ...string) (tshHome, kubeConfig stri // login with new temp tshHome and set it in Env. This is useful // when running "ssh" commands with a tsh "ProxyCommand". -func mustLoginSetEnv(t *testing.T, s *suite, args ...string) (tshHome, kubeConfig string) { - tshHome, kubeConfig = mustLogin(t, s, args...) +// deprecated: Create a new helper that depends on mustLogin instead which requires migrating from +// newTestSuite to tools/teleport/testenv.MakeTestServer. +func mustLoginSetEnvLegacy(t *testing.T, s *suite, args ...string) (tshHome, kubeConfig string) { + tshHome, kubeConfig = mustLoginLegacy(t, s, args...) t.Setenv(types.HomeEnvVar, tshHome) return } func mustLoginIdentity(t *testing.T, s *suite) string { identityFile := filepath.Join(t.TempDir(), "identity.pem") - mustLogin(t, s, "--out", identityFile) + mustLoginLegacy(t, s, "--out", identityFile) return identityFile } @@ -1605,3 +1611,149 @@ func TestProxyAppWithIdentity(t *testing.T) { }) require.NoError(t, err, "no proxied app request succeeded") } + +func TestProxyAppMultiPort(t *testing.T) { + disableAgent(t) + // Necessary for self-signed certs to be considered valid. + lib.SetInsecureDevMode(true) + t.Cleanup(func() { lib.SetInsecureDevMode(false) }) + ctx := context.Background() + + const ( + clusterName = "root" + appName = "multi-port-app" + userName = "admin" + accessRoleName = "access" + ) + + fooServerURL := startDummyHTTPServer(t, "foo") + barServerURL := startDummyHTTPServer(t, "bar") + fooServerPort := mustGetPort(t, fooServerURL) + barServerPort := mustGetPort(t, barServerURL) + + user, err := types.NewUser(userName) + user.SetRoles([]string{accessRoleName}) + require.NoError(t, err) + + connector := mockConnector(t) + rootServerOpts := []testserver.TestServerOptFunc{ + testserver.WithBootstrap(connector, user), + testserver.WithClusterName(t, clusterName), + testserver.WithConfig(func(cfg *servicecfg.Config) { + cfg.Auth.NetworkingConfig.SetProxyListenerMode(types.ProxyListenerMode_Multiplex) + cfg.Apps = servicecfg.AppsConfig{ + Enabled: true, + Apps: []servicecfg.App{{ + Name: appName, + URI: "tcp://localhost", + TCPPorts: []servicecfg.PortRange{ + servicecfg.PortRange{Port: fooServerPort}, + servicecfg.PortRange{Port: barServerPort}, + }, + }}, + } + }), + } + process := testserver.MakeTestServer(t, rootServerOpts...) + + tshHome, _ := mustLogin(t, process, user, connector.GetName()) + + // tsh proxy app seems to not handle multiple concurrent invocations well. Because of that, the + // test needs to first set up a local proxy, verify it and only then set up another one. + + fooProxyPort := ports.Pop() + fooTshArgs := []string{ + "--debug", + "--insecure", + "--proxy", process.Config.Proxy.WebAddr.Addr, + "proxy", "app", appName, + "--port", fmt.Sprintf("%s:%d", fooProxyPort, fooServerPort), + } + utils.RunTestBackgroundTask(ctx, t, &utils.TestBackgroundTask{ + Name: "tsh proxy app (foo)", + Task: func(ctx context.Context) error { + return Run(ctx, fooTshArgs, setHomePath(tshHome)) + }, + }) + mustDialLocalAppProxy(t, fooProxyPort, "foo") + + fooNoTargetPortProxyPort := ports.Pop() + fooNoTargetPortTshArgs := []string{ + "--debug", + "--insecure", + "--proxy", process.Config.Proxy.WebAddr.Addr, + "proxy", "app", appName, + "--port", fooNoTargetPortProxyPort, // No target port. + } + utils.RunTestBackgroundTask(ctx, t, &utils.TestBackgroundTask{ + Name: "tsh proxy app (foo no target port)", + Task: func(ctx context.Context) error { + return Run(ctx, fooNoTargetPortTshArgs, setHomePath(tshHome)) + }, + }) + // If there's no target port, the connections should still be routed to the first TCP port. + mustDialLocalAppProxy(t, fooNoTargetPortProxyPort, "foo") + + barProxyPort := ports.Pop() + barTshArgs := []string{ + "--debug", + "--insecure", + "--proxy", process.Config.Proxy.WebAddr.Addr, + "proxy", "app", appName, + "--port", fmt.Sprintf("%s:%d", barProxyPort, barServerPort), + } + utils.RunTestBackgroundTask(ctx, t, &utils.TestBackgroundTask{ + Name: "tsh proxy app (bar)", + Task: func(ctx context.Context) error { + return Run(ctx, barTshArgs, setHomePath(tshHome)) + }, + }) + mustDialLocalAppProxy(t, barProxyPort, "bar") +} + +// mustGetPort extracts the port out of the URL returned by functions such as startDummyHTTPServer. +func mustGetPort(t *testing.T, rawURL string) int { + t.Helper() + + url, err := url.Parse(rawURL) + require.NoError(t, err) + _, portString, err := net.SplitHostPort(url.Host) + require.NoError(t, err) + port, err := strconv.Atoi(portString) + require.NoError(t, err) + return port +} + +func mustLogin(t *testing.T, s *service.TeleportProcess, user types.User, connectorName string, args ...string) (tshHome, kubeConfig string) { + t.Helper() + tshHome = t.TempDir() + kubeConfig = filepath.Join(t.TempDir(), teleport.KubeConfigFile) + args = append([]string{ + "login", + "--insecure", + "--debug", + "--proxy", s.Config.Proxy.WebAddr.String(), + }, args...) + err := Run(context.Background(), args, + setMockSSOLogin(s.GetAuthServer(), user, connectorName), + setHomePath(tshHome), + setKubeConfigPath(kubeConfig), + ) + require.NoError(t, err, trace.DebugReport(err)) + return +} + +// mustDialLocalAppProxy verifies that a local app proxy for an app backed by startDummyHTTPServer +// returns the expected HTTP response. +func mustDialLocalAppProxy(t *testing.T, port string, expectedName string) { + t.Helper() + require.EventuallyWithT(t, func(t *assert.CollectT) { + r, err := http.Get(fmt.Sprintf("http://localhost:%s", port)) + if assert.NoError(t, err) { + defer r.Body.Close() + + assert.Equal(t, 200, r.StatusCode) + assert.Equal(t, expectedName, r.Header.Get("Server"), "the response header \"Server\" does not have the expected value") + } + }, 5*time.Second, 50*time.Millisecond) +} diff --git a/tool/tsh/common/tsh.go b/tool/tsh/common/tsh.go index f5537566d40cf..372db5f659a28 100644 --- a/tool/tsh/common/tsh.go +++ b/tool/tsh/common/tsh.go @@ -426,8 +426,13 @@ type CLIConf struct { // LocalProxyPort is a port used by local proxy listener. LocalProxyPort string + // LocalProxyPortMapping is a listening port and an optional target port used by local proxy + // listener, in the form of "1234" or "1234:5678". + LocalProxyPortMapping string // LocalProxyTunnel specifies whether local proxy will open auth'd tunnel. LocalProxyTunnel bool + // TargetPort is a port used for routing connections to multi-port TCP apps. + TargetPort uint16 // Exec is the command to run via tsh aws. Exec string @@ -885,6 +890,7 @@ func Run(ctx context.Context, args []string, opts ...CliOption) error { appLogin.Flag("aws-role", "(For AWS CLI access only) Amazon IAM role ARN or role name.").StringVar(&cf.AWSRole) appLogin.Flag("azure-identity", "(For Azure CLI access only) Azure managed identity name.").StringVar(&cf.AzureIdentity) appLogin.Flag("gcp-service-account", "(For GCP CLI access only) GCP service account name.").StringVar(&cf.GCPServiceAccount) + appLogin.Flag("target-port", "Port to which connections made using this cert should be routed to. Valid only for multi-port TCP apps.").Uint16Var(&cf.TargetPort) appLogin.Flag("quiet", "Quiet mode").Short('q').BoolVar(&cf.Quiet) appLogout := apps.Command("logout", "Remove app certificate.") appLogout.Arg("app", "App to remove credentials for.").StringVar(&cf.AppName) @@ -929,7 +935,7 @@ func Run(ctx context.Context, args []string, opts ...CliOption) error { proxyApp := proxy.Command("app", "Start local TLS proxy for app connection when using Teleport in single-port mode.") proxyApp.Arg("app", "The name of the application to start local proxy for").Required().StringVar(&cf.AppName) - proxyApp.Flag("port", "Specifies the source port used by by the proxy app listener").Short('p').StringVar(&cf.LocalProxyPort) + proxyApp.Flag("port", "Specifies the listening port used by by the proxy app listener. Accepts an optional target port of a multi-port TCP app after a colon, e.g. \"1234:5678\"").Short('p').StringVar(&cf.LocalProxyPortMapping) proxyApp.Flag("cluster", clusterHelp).Short('c').StringVar(&cf.SiteName) proxyAWS := proxy.Command("aws", "Start local proxy for AWS access.") diff --git a/tool/tsh/common/workload_identity_test.go b/tool/tsh/common/workload_identity_test.go index e9eedeac8ab9f..04e73ce18f7e1 100644 --- a/tool/tsh/common/workload_identity_test.go +++ b/tool/tsh/common/workload_identity_test.go @@ -61,7 +61,7 @@ func TestWorkloadIdentityIssue(t *testing.T) { }), ) - homeDir, _ := mustLogin(t, s) + homeDir, _ := mustLoginLegacy(t, s) temp := t.TempDir() err = Run( ctx,