Skip to content

Commit 34de3bc

Browse files
committed
feat: support --hostname-suffix flag on coder ssh
1 parent a2314ad commit 34de3bc

File tree

5 files changed

+134
-55
lines changed

5 files changed

+134
-55
lines changed

cli/server.go

+9
Original file line numberDiff line numberDiff line change
@@ -620,6 +620,15 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd.
620620
return xerrors.Errorf("parse ssh config options %q: %w", vals.SSHConfig.SSHConfigOptions.String(), err)
621621
}
622622

623+
// The workspace hostname suffix is always interpreted as implicitly beginning with a single dot, so it is
624+
// a config error to explicitly include the dot. This ensures that we always interpret the suffix as a
625+
// separate DNS label, and not just an ordinary string suffix. E.g. a suffix of 'coder' will match
626+
// 'en.coder' but not 'encoder'.
627+
if strings.HasPrefix(vals.WorkspaceHostnameSuffix.String(), ".") {
628+
return xerrors.Errorf("you must omit any leading . in workspace hostname suffix: %s",
629+
vals.WorkspaceHostnameSuffix.String())
630+
}
631+
623632
options := &coderd.Options{
624633
AccessURL: vals.AccessURL.Value(),
625634
AppHostname: appHostname,

cli/ssh.go

+42-4
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ func (r *RootCmd) ssh() *serpent.Command {
6363
var (
6464
stdio bool
6565
hostPrefix string
66+
hostnameSuffix string
6667
forwardAgent bool
6768
forwardGPG bool
6869
identityAgent string
@@ -200,11 +201,14 @@ func (r *RootCmd) ssh() *serpent.Command {
200201
parsedEnv = append(parsedEnv, [2]string{k, v})
201202
}
202203

203-
namedWorkspace := strings.TrimPrefix(inv.Args[0], hostPrefix)
204-
// Support "--" as a delimiter between owner and workspace name
205-
namedWorkspace = strings.Replace(namedWorkspace, "--", "/", 1)
204+
deploymentSSHConfig := codersdk.SSHConfigResponse{
205+
HostnamePrefix: hostPrefix,
206+
HostnameSuffix: hostnameSuffix,
207+
}
206208

207-
workspace, workspaceAgent, err := getWorkspaceAndAgent(ctx, inv, client, !disableAutostart, namedWorkspace)
209+
workspace, workspaceAgent, err := findWorkspaceAndAgentByHostname(
210+
ctx, inv, client,
211+
inv.Args[0], deploymentSSHConfig, disableAutostart)
208212
if err != nil {
209213
return err
210214
}
@@ -563,6 +567,12 @@ func (r *RootCmd) ssh() *serpent.Command {
563567
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.",
564568
Value: serpent.StringOf(&hostPrefix),
565569
},
570+
{
571+
Flag: "hostname-suffix",
572+
Env: "CODER_SSH_HOSTNAME_SUFFIX",
573+
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.",
574+
Value: serpent.StringOf(&hostnameSuffix),
575+
},
566576
{
567577
Flag: "forward-agent",
568578
FlagShorthand: "A",
@@ -655,6 +665,34 @@ func (r *RootCmd) ssh() *serpent.Command {
655665
return cmd
656666
}
657667

668+
// findWorkspaceAndAgentByHostname parses the hostname from the commandline and finds the workspace and agent it
669+
// corresponds to, taking into account any name prefixes or suffixes configured (e.g. myworkspace.coder, or
670+
// vscode-coder--myusername--myworkspace).
671+
func findWorkspaceAndAgentByHostname(
672+
ctx context.Context, inv *serpent.Invocation, client *codersdk.Client,
673+
hostname string, config codersdk.SSHConfigResponse, disableAutostart bool,
674+
) (
675+
codersdk.Workspace, codersdk.WorkspaceAgent, error,
676+
) {
677+
// for suffixes, we don't explicitly get the . and must add it. This is to ensure that the suffix is always
678+
// interpreted as a dotted label in DNS names, not just any string suffix. That is, a suffix of 'coder' will
679+
// match a hostname like 'en.coder', but not 'encoder'.
680+
qualifiedSuffix := "." + config.HostnameSuffix
681+
682+
switch {
683+
case config.HostnamePrefix != "" && strings.HasPrefix(hostname, config.HostnamePrefix):
684+
hostname = strings.TrimPrefix(hostname, config.HostnamePrefix)
685+
// Support "--" as a delimiter between owner and workspace name
686+
hostname = strings.Replace(hostname, "--", "/", 1)
687+
case config.HostnameSuffix != "" && strings.HasSuffix(hostname, qualifiedSuffix):
688+
hostname = strings.TrimSuffix(hostname, qualifiedSuffix)
689+
// Support "--" as a delimiter between owner and workspace name
690+
hostname = strings.Replace(hostname, "--", "/", 1)
691+
}
692+
693+
return getWorkspaceAndAgent(ctx, inv, client, !disableAutostart, hostname)
694+
}
695+
658696
// watchAndClose ensures closer is called if the context is canceled or
659697
// the workspace reaches the stopped state.
660698
//

cli/ssh_test.go

+69-51
Original file line numberDiff line numberDiff line change
@@ -1647,67 +1647,85 @@ func TestSSH(t *testing.T) {
16471647
}
16481648
})
16491649

1650-
t.Run("SSHHostPrefix", func(t *testing.T) {
1650+
t.Run("SSHHost", func(t *testing.T) {
16511651
t.Parallel()
1652-
client, workspace, agentToken := setupWorkspaceForAgent(t)
1653-
_, _ = tGoContext(t, func(ctx context.Context) {
1654-
// Run this async so the SSH command has to wait for
1655-
// the build and agent to connect!
1656-
_ = agenttest.New(t, client.URL, agentToken)
1657-
<-ctx.Done()
1658-
})
16591652

1660-
clientOutput, clientInput := io.Pipe()
1661-
serverOutput, serverInput := io.Pipe()
1662-
defer func() {
1663-
for _, c := range []io.Closer{clientOutput, clientInput, serverOutput, serverInput} {
1664-
_ = c.Close()
1665-
}
1666-
}()
1653+
testCases := []struct {
1654+
name, hostnameFormat string
1655+
flags []string
1656+
}{
1657+
{"Prefix", "coder.dummy.com--%s--%s", []string{"--ssh-host-prefix", "coder.dummy.com--"}},
1658+
{"Suffix", "%s--%s.coder", []string{"--hostname-suffix", "coder"}},
1659+
{"Both", "%s--%s.coder", []string{"--hostname-suffix", "coder", "--ssh-host-prefix", "coder.dummy.com--"}},
1660+
}
1661+
for _, tc := range testCases {
1662+
t.Run(tc.name, func(t *testing.T) {
1663+
t.Parallel()
16671664

1668-
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
1669-
defer cancel()
1665+
client, workspace, agentToken := setupWorkspaceForAgent(t)
1666+
_, _ = tGoContext(t, func(ctx context.Context) {
1667+
// Run this async so the SSH command has to wait for
1668+
// the build and agent to connect!
1669+
_ = agenttest.New(t, client.URL, agentToken)
1670+
<-ctx.Done()
1671+
})
16701672

1671-
user, err := client.User(ctx, codersdk.Me)
1672-
require.NoError(t, err)
1673+
clientOutput, clientInput := io.Pipe()
1674+
serverOutput, serverInput := io.Pipe()
1675+
defer func() {
1676+
for _, c := range []io.Closer{clientOutput, clientInput, serverOutput, serverInput} {
1677+
_ = c.Close()
1678+
}
1679+
}()
16731680

1674-
inv, root := clitest.New(t, "ssh", "--stdio", "--ssh-host-prefix", "coder.dummy.com--", fmt.Sprintf("coder.dummy.com--%s--%s", user.Username, workspace.Name))
1675-
clitest.SetupConfig(t, client, root)
1676-
inv.Stdin = clientOutput
1677-
inv.Stdout = serverInput
1678-
inv.Stderr = io.Discard
1681+
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
1682+
defer cancel()
16791683

1680-
cmdDone := tGo(t, func() {
1681-
err := inv.WithContext(ctx).Run()
1682-
assert.NoError(t, err)
1683-
})
1684+
user, err := client.User(ctx, codersdk.Me)
1685+
require.NoError(t, err)
16841686

1685-
conn, channels, requests, err := ssh.NewClientConn(&stdioConn{
1686-
Reader: serverOutput,
1687-
Writer: clientInput,
1688-
}, "", &ssh.ClientConfig{
1689-
// #nosec
1690-
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
1691-
})
1692-
require.NoError(t, err)
1693-
defer conn.Close()
1687+
args := []string{"ssh", "--stdio"}
1688+
args = append(args, tc.flags...)
1689+
args = append(args, fmt.Sprintf(tc.hostnameFormat, user.Username, workspace.Name))
1690+
inv, root := clitest.New(t, args...)
1691+
clitest.SetupConfig(t, client, root)
1692+
inv.Stdin = clientOutput
1693+
inv.Stdout = serverInput
1694+
inv.Stderr = io.Discard
16941695

1695-
sshClient := ssh.NewClient(conn, channels, requests)
1696-
session, err := sshClient.NewSession()
1697-
require.NoError(t, err)
1698-
defer session.Close()
1696+
cmdDone := tGo(t, func() {
1697+
err := inv.WithContext(ctx).Run()
1698+
assert.NoError(t, err)
1699+
})
16991700

1700-
command := "sh -c exit"
1701-
if runtime.GOOS == "windows" {
1702-
command = "cmd.exe /c exit"
1703-
}
1704-
err = session.Run(command)
1705-
require.NoError(t, err)
1706-
err = sshClient.Close()
1707-
require.NoError(t, err)
1708-
_ = clientOutput.Close()
1701+
conn, channels, requests, err := ssh.NewClientConn(&stdioConn{
1702+
Reader: serverOutput,
1703+
Writer: clientInput,
1704+
}, "", &ssh.ClientConfig{
1705+
// #nosec
1706+
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
1707+
})
1708+
require.NoError(t, err)
1709+
defer conn.Close()
17091710

1710-
<-cmdDone
1711+
sshClient := ssh.NewClient(conn, channels, requests)
1712+
session, err := sshClient.NewSession()
1713+
require.NoError(t, err)
1714+
defer session.Close()
1715+
1716+
command := "sh -c exit"
1717+
if runtime.GOOS == "windows" {
1718+
command = "cmd.exe /c exit"
1719+
}
1720+
err = session.Run(command)
1721+
require.NoError(t, err)
1722+
err = sshClient.Close()
1723+
require.NoError(t, err)
1724+
_ = clientOutput.Close()
1725+
1726+
<-cmdDone
1727+
})
1728+
}
17111729
})
17121730
}
17131731

cli/testdata/coder_ssh_--help.golden

+5
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,11 @@ OPTIONS:
2323
locally and will not be started for you. If a GPG agent is already
2424
running in the workspace, it will be attempted to be killed.
2525

26+
--hostname-suffix string, $CODER_SSH_HOSTNAME_SUFFIX
27+
Strip this suffix from the provided hostname to determine the
28+
workspace name. This is useful when used as part of an OpenSSH proxy
29+
command. The suffix should be specified WITHOUT a leading . character.
30+
2631
--identity-agent string, $CODER_SSH_IDENTITY_AGENT
2732
Specifies which identity agent to use (overrides $SSH_AUTH_SOCK),
2833
forward agent must also be enabled.

docs/reference/cli/ssh.md

+9
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)