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..e02443e7032c6 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 must 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..1f7122dd655a2 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 must 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..c5bae755c8419 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 must be specified without a leading . character.
+
### -A, --forward-agent
| | |