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 | | |