Skip to content

Commit

Permalink
[v17] tsh proxy app: Add support for multi-port TCP apps (#50691)
Browse files Browse the repository at this point in the history
* `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.
  • Loading branch information
ravicious authored Jan 8, 2025
1 parent fd41db9 commit 40ec85f
Show file tree
Hide file tree
Showing 14 changed files with 433 additions and 73 deletions.
31 changes: 31 additions & 0 deletions lib/client/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
69 changes: 69 additions & 0 deletions lib/client/api_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
})
}
}
2 changes: 1 addition & 1 deletion tool/tsh/common/access_request_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
27 changes: 19 additions & 8 deletions tool/tsh/common/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}

Expand All @@ -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)
}

Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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}}
Expand Down Expand Up @@ -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) {
Expand Down
11 changes: 8 additions & 3 deletions tool/tsh/common/app_aws.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down
12 changes: 8 additions & 4 deletions tool/tsh/common/app_azure.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -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.
Expand Down
14 changes: 9 additions & 5 deletions tool/tsh/common/app_gcp.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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.
Expand All @@ -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 {
Expand Down Expand Up @@ -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)
}
Expand All @@ -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
Expand Down
Loading

0 comments on commit 40ec85f

Please sign in to comment.