Skip to content

feat: support --hostname-suffix flag on coder ssh #17279

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 2 commits into from
Apr 7, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions cli/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
43 changes: 39 additions & 4 deletions cli/ssh.go
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ func (r *RootCmd) ssh() *serpent.Command {
var (
stdio bool
hostPrefix string
hostnameSuffix string
forwardAgent bool
forwardGPG bool
identityAgent string
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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.
//
Expand Down
120 changes: 69 additions & 51 deletions cli/ssh_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
})
}
})
}

Expand Down
5 changes: 5 additions & 0 deletions cli/testdata/coder_ssh_--help.golden
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
9 changes: 9 additions & 0 deletions docs/reference/cli/ssh.md

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading