From 01fadd42b665f212dd8440ae56ea64eec42172b8 Mon Sep 17 00:00:00 2001 From: Spike Curtis Date: Mon, 7 Apr 2025 12:39:09 +0000 Subject: [PATCH 1/2] feat: support --hostname-suffix flag on coder ssh --- cli/server.go | 9 ++ cli/ssh.go | 43 +++++++++- cli/ssh_test.go | 120 +++++++++++++++------------ cli/testdata/coder_ssh_--help.golden | 5 ++ docs/reference/cli/ssh.md | 9 ++ 5 files changed, 131 insertions(+), 55 deletions(-) diff --git a/cli/server.go b/cli/server.go index 98a7739412afa..ea6f4d665f4de 100644 --- a/cli/server.go +++ b/cli/server.go @@ -620,6 +620,15 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd. return xerrors.Errorf("parse ssh config options %q: %w", vals.SSHConfig.SSHConfigOptions.String(), err) } + // The workspace hostname suffix is always interpreted as implicitly beginning with a single dot, so it is + // a config error to explicitly include the dot. This ensures that we always interpret the suffix as a + // separate DNS label, and not just an ordinary string suffix. E.g. a suffix of 'coder' will match + // 'en.coder' but not 'encoder'. + if strings.HasPrefix(vals.WorkspaceHostnameSuffix.String(), ".") { + return xerrors.Errorf("you must omit any leading . in workspace hostname suffix: %s", + vals.WorkspaceHostnameSuffix.String()) + } + options := &coderd.Options{ AccessURL: vals.AccessURL.Value(), AppHostname: appHostname, diff --git a/cli/ssh.go b/cli/ssh.go index d9c98cd0b48f1..dacc2fa5b963c 100644 --- a/cli/ssh.go +++ b/cli/ssh.go @@ -65,6 +65,7 @@ func (r *RootCmd) ssh() *serpent.Command { var ( stdio bool hostPrefix string + hostnameSuffix string forwardAgent bool forwardGPG bool identityAgent string @@ -202,10 +203,14 @@ func (r *RootCmd) ssh() *serpent.Command { parsedEnv = append(parsedEnv, [2]string{k, v}) } - workspaceInput := strings.TrimPrefix(inv.Args[0], hostPrefix) - // convert workspace name format into owner/workspace.agent - namedWorkspace := normalizeWorkspaceInput(workspaceInput) - workspace, workspaceAgent, err := getWorkspaceAndAgent(ctx, inv, client, !disableAutostart, namedWorkspace) + deploymentSSHConfig := codersdk.SSHConfigResponse{ + HostnamePrefix: hostPrefix, + HostnameSuffix: hostnameSuffix, + } + + workspace, workspaceAgent, err := findWorkspaceAndAgentByHostname( + ctx, inv, client, + inv.Args[0], deploymentSSHConfig, disableAutostart) if err != nil { return err } @@ -564,6 +569,12 @@ func (r *RootCmd) ssh() *serpent.Command { Description: "Strip this prefix from the provided hostname to determine the workspace name. This is useful when used as part of an OpenSSH proxy command.", Value: serpent.StringOf(&hostPrefix), }, + { + Flag: "hostname-suffix", + Env: "CODER_SSH_HOSTNAME_SUFFIX", + Description: "Strip this suffix from the provided hostname to determine the workspace name. This is useful when used as part of an OpenSSH proxy command. The suffix should be specified WITHOUT a leading . character.", + Value: serpent.StringOf(&hostnameSuffix), + }, { Flag: "forward-agent", FlagShorthand: "A", @@ -656,6 +667,30 @@ func (r *RootCmd) ssh() *serpent.Command { return cmd } +// findWorkspaceAndAgentByHostname parses the hostname from the commandline and finds the workspace and agent it +// corresponds to, taking into account any name prefixes or suffixes configured (e.g. myworkspace.coder, or +// vscode-coder--myusername--myworkspace). +func findWorkspaceAndAgentByHostname( + ctx context.Context, inv *serpent.Invocation, client *codersdk.Client, + hostname string, config codersdk.SSHConfigResponse, disableAutostart bool, +) ( + codersdk.Workspace, codersdk.WorkspaceAgent, error, +) { + // for suffixes, we don't explicitly get the . and must add it. This is to ensure that the suffix is always + // interpreted as a dotted label in DNS names, not just any string suffix. That is, a suffix of 'coder' will + // match a hostname like 'en.coder', but not 'encoder'. + qualifiedSuffix := "." + config.HostnameSuffix + + switch { + case config.HostnamePrefix != "" && strings.HasPrefix(hostname, config.HostnamePrefix): + hostname = strings.TrimPrefix(hostname, config.HostnamePrefix) + case config.HostnameSuffix != "" && strings.HasSuffix(hostname, qualifiedSuffix): + hostname = strings.TrimSuffix(hostname, qualifiedSuffix) + } + hostname = normalizeWorkspaceInput(hostname) + return getWorkspaceAndAgent(ctx, inv, client, !disableAutostart, hostname) +} + // watchAndClose ensures closer is called if the context is canceled or // the workspace reaches the stopped state. // diff --git a/cli/ssh_test.go b/cli/ssh_test.go index 75ad88601e9ae..332fbbe219c46 100644 --- a/cli/ssh_test.go +++ b/cli/ssh_test.go @@ -1690,67 +1690,85 @@ func TestSSH(t *testing.T) { } }) - t.Run("SSHHostPrefix", func(t *testing.T) { + t.Run("SSHHost", func(t *testing.T) { t.Parallel() - client, workspace, agentToken := setupWorkspaceForAgent(t) - _, _ = tGoContext(t, func(ctx context.Context) { - // Run this async so the SSH command has to wait for - // the build and agent to connect! - _ = agenttest.New(t, client.URL, agentToken) - <-ctx.Done() - }) - clientOutput, clientInput := io.Pipe() - serverOutput, serverInput := io.Pipe() - defer func() { - for _, c := range []io.Closer{clientOutput, clientInput, serverOutput, serverInput} { - _ = c.Close() - } - }() + testCases := []struct { + name, hostnameFormat string + flags []string + }{ + {"Prefix", "coder.dummy.com--%s--%s", []string{"--ssh-host-prefix", "coder.dummy.com--"}}, + {"Suffix", "%s--%s.coder", []string{"--hostname-suffix", "coder"}}, + {"Both", "%s--%s.coder", []string{"--hostname-suffix", "coder", "--ssh-host-prefix", "coder.dummy.com--"}}, + } + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() - ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) - defer cancel() + client, workspace, agentToken := setupWorkspaceForAgent(t) + _, _ = tGoContext(t, func(ctx context.Context) { + // Run this async so the SSH command has to wait for + // the build and agent to connect! + _ = agenttest.New(t, client.URL, agentToken) + <-ctx.Done() + }) - user, err := client.User(ctx, codersdk.Me) - require.NoError(t, err) + clientOutput, clientInput := io.Pipe() + serverOutput, serverInput := io.Pipe() + defer func() { + for _, c := range []io.Closer{clientOutput, clientInput, serverOutput, serverInput} { + _ = c.Close() + } + }() - inv, root := clitest.New(t, "ssh", "--stdio", "--ssh-host-prefix", "coder.dummy.com--", fmt.Sprintf("coder.dummy.com--%s--%s", user.Username, workspace.Name)) - clitest.SetupConfig(t, client, root) - inv.Stdin = clientOutput - inv.Stdout = serverInput - inv.Stderr = io.Discard + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() - cmdDone := tGo(t, func() { - err := inv.WithContext(ctx).Run() - assert.NoError(t, err) - }) + user, err := client.User(ctx, codersdk.Me) + require.NoError(t, err) - conn, channels, requests, err := ssh.NewClientConn(&stdioConn{ - Reader: serverOutput, - Writer: clientInput, - }, "", &ssh.ClientConfig{ - // #nosec - HostKeyCallback: ssh.InsecureIgnoreHostKey(), - }) - require.NoError(t, err) - defer conn.Close() + args := []string{"ssh", "--stdio"} + args = append(args, tc.flags...) + args = append(args, fmt.Sprintf(tc.hostnameFormat, user.Username, workspace.Name)) + inv, root := clitest.New(t, args...) + clitest.SetupConfig(t, client, root) + inv.Stdin = clientOutput + inv.Stdout = serverInput + inv.Stderr = io.Discard - sshClient := ssh.NewClient(conn, channels, requests) - session, err := sshClient.NewSession() - require.NoError(t, err) - defer session.Close() + cmdDone := tGo(t, func() { + err := inv.WithContext(ctx).Run() + assert.NoError(t, err) + }) - command := "sh -c exit" - if runtime.GOOS == "windows" { - command = "cmd.exe /c exit" - } - err = session.Run(command) - require.NoError(t, err) - err = sshClient.Close() - require.NoError(t, err) - _ = clientOutput.Close() + conn, channels, requests, err := ssh.NewClientConn(&stdioConn{ + Reader: serverOutput, + Writer: clientInput, + }, "", &ssh.ClientConfig{ + // #nosec + HostKeyCallback: ssh.InsecureIgnoreHostKey(), + }) + require.NoError(t, err) + defer conn.Close() - <-cmdDone + sshClient := ssh.NewClient(conn, channels, requests) + session, err := sshClient.NewSession() + require.NoError(t, err) + defer session.Close() + + command := "sh -c exit" + if runtime.GOOS == "windows" { + command = "cmd.exe /c exit" + } + err = session.Run(command) + require.NoError(t, err) + err = sshClient.Close() + require.NoError(t, err) + _ = clientOutput.Close() + + <-cmdDone + }) + } }) } diff --git a/cli/testdata/coder_ssh_--help.golden b/cli/testdata/coder_ssh_--help.golden index 3d2f584727cd9..86c132815a37c 100644 --- a/cli/testdata/coder_ssh_--help.golden +++ b/cli/testdata/coder_ssh_--help.golden @@ -23,6 +23,11 @@ OPTIONS: locally and will not be started for you. If a GPG agent is already running in the workspace, it will be attempted to be killed. + --hostname-suffix string, $CODER_SSH_HOSTNAME_SUFFIX + Strip this suffix from the provided hostname to determine the + workspace name. This is useful when used as part of an OpenSSH proxy + command. The suffix should be specified WITHOUT a leading . character. + --identity-agent string, $CODER_SSH_IDENTITY_AGENT Specifies which identity agent to use (overrides $SSH_AUTH_SOCK), forward agent must also be enabled. diff --git a/docs/reference/cli/ssh.md b/docs/reference/cli/ssh.md index 72d63a1f003af..f604e0a4aa9f2 100644 --- a/docs/reference/cli/ssh.md +++ b/docs/reference/cli/ssh.md @@ -29,6 +29,15 @@ Specifies whether to emit SSH output over stdin/stdout. Strip this prefix from the provided hostname to determine the workspace name. This is useful when used as part of an OpenSSH proxy command. +### --hostname-suffix + +| | | +|-------------|-----------------------------------------| +| Type | string | +| Environment | $CODER_SSH_HOSTNAME_SUFFIX | + +Strip this suffix from the provided hostname to determine the workspace name. This is useful when used as part of an OpenSSH proxy command. The suffix should be specified WITHOUT a leading . character. + ### -A, --forward-agent | | | From c45e4db5018ef5ae49db099cbb78ee2b17977d75 Mon Sep 17 00:00:00 2001 From: Spike Curtis Date: Mon, 7 Apr 2025 16:17:01 +0000 Subject: [PATCH 2/2] code review --- cli/ssh.go | 2 +- cli/testdata/coder_ssh_--help.golden | 2 +- docs/reference/cli/ssh.md | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/cli/ssh.go b/cli/ssh.go index dacc2fa5b963c..e02443e7032c6 100644 --- a/cli/ssh.go +++ b/cli/ssh.go @@ -572,7 +572,7 @@ func (r *RootCmd) ssh() *serpent.Command { { Flag: "hostname-suffix", Env: "CODER_SSH_HOSTNAME_SUFFIX", - Description: "Strip this suffix from the provided hostname to determine the workspace name. This is useful when used as part of an OpenSSH proxy command. The suffix should be specified WITHOUT a leading . character.", + Description: "Strip this suffix from the provided hostname to determine the workspace name. This is useful when used as part of an OpenSSH proxy command. The suffix must be specified without a leading . character.", Value: serpent.StringOf(&hostnameSuffix), }, { diff --git a/cli/testdata/coder_ssh_--help.golden b/cli/testdata/coder_ssh_--help.golden index 86c132815a37c..1f7122dd655a2 100644 --- a/cli/testdata/coder_ssh_--help.golden +++ b/cli/testdata/coder_ssh_--help.golden @@ -26,7 +26,7 @@ OPTIONS: --hostname-suffix string, $CODER_SSH_HOSTNAME_SUFFIX Strip this suffix from the provided hostname to determine the workspace name. This is useful when used as part of an OpenSSH proxy - command. The suffix should be specified WITHOUT a leading . character. + command. The suffix must be specified without a leading . character. --identity-agent string, $CODER_SSH_IDENTITY_AGENT Specifies which identity agent to use (overrides $SSH_AUTH_SOCK), diff --git a/docs/reference/cli/ssh.md b/docs/reference/cli/ssh.md index f604e0a4aa9f2..c5bae755c8419 100644 --- a/docs/reference/cli/ssh.md +++ b/docs/reference/cli/ssh.md @@ -36,7 +36,7 @@ Strip this prefix from the provided hostname to determine the workspace name. Th | Type | string | | Environment | $CODER_SSH_HOSTNAME_SUFFIX | -Strip this suffix from the provided hostname to determine the workspace name. This is useful when used as part of an OpenSSH proxy command. The suffix should be specified WITHOUT a leading . character. +Strip this suffix from the provided hostname to determine the workspace name. This is useful when used as part of an OpenSSH proxy command. The suffix must be specified without a leading . character. ### -A, --forward-agent pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy