Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Commit d312e82

Browse files
authored
feat: support --hostname-suffix flag on coder ssh (#17279)
Adds `hostname-suffix` flag to `coder ssh` command for use in SSH Config ProxyCommands. Also enforces that Coder server doesn't start the suffix with a dot. part of: #16828
1 parent aa0a63a commit d312e82

File tree

5 files changed

+131
-55
lines changed

5 files changed

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

+39-4
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ func (r *RootCmd) ssh() *serpent.Command {
6565
var (
6666
stdio bool
6767
hostPrefix string
68+
hostnameSuffix string
6869
forwardAgent bool
6970
forwardGPG bool
7071
identityAgent string
@@ -202,10 +203,14 @@ func (r *RootCmd) ssh() *serpent.Command {
202203
parsedEnv = append(parsedEnv, [2]string{k, v})
203204
}
204205

205-
workspaceInput := strings.TrimPrefix(inv.Args[0], hostPrefix)
206-
// convert workspace name format into owner/workspace.agent
207-
namedWorkspace := normalizeWorkspaceInput(workspaceInput)
208-
workspace, workspaceAgent, err := getWorkspaceAndAgent(ctx, inv, client, !disableAutostart, namedWorkspace)
206+
deploymentSSHConfig := codersdk.SSHConfigResponse{
207+
HostnamePrefix: hostPrefix,
208+
HostnameSuffix: hostnameSuffix,
209+
}
210+
211+
workspace, workspaceAgent, err := findWorkspaceAndAgentByHostname(
212+
ctx, inv, client,
213+
inv.Args[0], deploymentSSHConfig, disableAutostart)
209214
if err != nil {
210215
return err
211216
}
@@ -564,6 +569,12 @@ func (r *RootCmd) ssh() *serpent.Command {
564569
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.",
565570
Value: serpent.StringOf(&hostPrefix),
566571
},
572+
{
573+
Flag: "hostname-suffix",
574+
Env: "CODER_SSH_HOSTNAME_SUFFIX",
575+
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.",
576+
Value: serpent.StringOf(&hostnameSuffix),
577+
},
567578
{
568579
Flag: "forward-agent",
569580
FlagShorthand: "A",
@@ -656,6 +667,30 @@ func (r *RootCmd) ssh() *serpent.Command {
656667
return cmd
657668
}
658669

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

cli/ssh_test.go

+69-51
Original file line numberDiff line numberDiff line change
@@ -1690,67 +1690,85 @@ func TestSSH(t *testing.T) {
16901690
}
16911691
})
16921692

1693-
t.Run("SSHHostPrefix", func(t *testing.T) {
1693+
t.Run("SSHHost", func(t *testing.T) {
16941694
t.Parallel()
1695-
client, workspace, agentToken := setupWorkspaceForAgent(t)
1696-
_, _ = tGoContext(t, func(ctx context.Context) {
1697-
// Run this async so the SSH command has to wait for
1698-
// the build and agent to connect!
1699-
_ = agenttest.New(t, client.URL, agentToken)
1700-
<-ctx.Done()
1701-
})
17021695

1703-
clientOutput, clientInput := io.Pipe()
1704-
serverOutput, serverInput := io.Pipe()
1705-
defer func() {
1706-
for _, c := range []io.Closer{clientOutput, clientInput, serverOutput, serverInput} {
1707-
_ = c.Close()
1708-
}
1709-
}()
1696+
testCases := []struct {
1697+
name, hostnameFormat string
1698+
flags []string
1699+
}{
1700+
{"Prefix", "coder.dummy.com--%s--%s", []string{"--ssh-host-prefix", "coder.dummy.com--"}},
1701+
{"Suffix", "%s--%s.coder", []string{"--hostname-suffix", "coder"}},
1702+
{"Both", "%s--%s.coder", []string{"--hostname-suffix", "coder", "--ssh-host-prefix", "coder.dummy.com--"}},
1703+
}
1704+
for _, tc := range testCases {
1705+
t.Run(tc.name, func(t *testing.T) {
1706+
t.Parallel()
17101707

1711-
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
1712-
defer cancel()
1708+
client, workspace, agentToken := setupWorkspaceForAgent(t)
1709+
_, _ = tGoContext(t, func(ctx context.Context) {
1710+
// Run this async so the SSH command has to wait for
1711+
// the build and agent to connect!
1712+
_ = agenttest.New(t, client.URL, agentToken)
1713+
<-ctx.Done()
1714+
})
17131715

1714-
user, err := client.User(ctx, codersdk.Me)
1715-
require.NoError(t, err)
1716+
clientOutput, clientInput := io.Pipe()
1717+
serverOutput, serverInput := io.Pipe()
1718+
defer func() {
1719+
for _, c := range []io.Closer{clientOutput, clientInput, serverOutput, serverInput} {
1720+
_ = c.Close()
1721+
}
1722+
}()
17161723

1717-
inv, root := clitest.New(t, "ssh", "--stdio", "--ssh-host-prefix", "coder.dummy.com--", fmt.Sprintf("coder.dummy.com--%s--%s", user.Username, workspace.Name))
1718-
clitest.SetupConfig(t, client, root)
1719-
inv.Stdin = clientOutput
1720-
inv.Stdout = serverInput
1721-
inv.Stderr = io.Discard
1724+
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
1725+
defer cancel()
17221726

1723-
cmdDone := tGo(t, func() {
1724-
err := inv.WithContext(ctx).Run()
1725-
assert.NoError(t, err)
1726-
})
1727+
user, err := client.User(ctx, codersdk.Me)
1728+
require.NoError(t, err)
17271729

1728-
conn, channels, requests, err := ssh.NewClientConn(&stdioConn{
1729-
Reader: serverOutput,
1730-
Writer: clientInput,
1731-
}, "", &ssh.ClientConfig{
1732-
// #nosec
1733-
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
1734-
})
1735-
require.NoError(t, err)
1736-
defer conn.Close()
1730+
args := []string{"ssh", "--stdio"}
1731+
args = append(args, tc.flags...)
1732+
args = append(args, fmt.Sprintf(tc.hostnameFormat, user.Username, workspace.Name))
1733+
inv, root := clitest.New(t, args...)
1734+
clitest.SetupConfig(t, client, root)
1735+
inv.Stdin = clientOutput
1736+
inv.Stdout = serverInput
1737+
inv.Stderr = io.Discard
17371738

1738-
sshClient := ssh.NewClient(conn, channels, requests)
1739-
session, err := sshClient.NewSession()
1740-
require.NoError(t, err)
1741-
defer session.Close()
1739+
cmdDone := tGo(t, func() {
1740+
err := inv.WithContext(ctx).Run()
1741+
assert.NoError(t, err)
1742+
})
17421743

1743-
command := "sh -c exit"
1744-
if runtime.GOOS == "windows" {
1745-
command = "cmd.exe /c exit"
1746-
}
1747-
err = session.Run(command)
1748-
require.NoError(t, err)
1749-
err = sshClient.Close()
1750-
require.NoError(t, err)
1751-
_ = clientOutput.Close()
1744+
conn, channels, requests, err := ssh.NewClientConn(&stdioConn{
1745+
Reader: serverOutput,
1746+
Writer: clientInput,
1747+
}, "", &ssh.ClientConfig{
1748+
// #nosec
1749+
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
1750+
})
1751+
require.NoError(t, err)
1752+
defer conn.Close()
17521753

1753-
<-cmdDone
1754+
sshClient := ssh.NewClient(conn, channels, requests)
1755+
session, err := sshClient.NewSession()
1756+
require.NoError(t, err)
1757+
defer session.Close()
1758+
1759+
command := "sh -c exit"
1760+
if runtime.GOOS == "windows" {
1761+
command = "cmd.exe /c exit"
1762+
}
1763+
err = session.Run(command)
1764+
require.NoError(t, err)
1765+
err = sshClient.Close()
1766+
require.NoError(t, err)
1767+
_ = clientOutput.Close()
1768+
1769+
<-cmdDone
1770+
})
1771+
}
17541772
})
17551773
}
17561774

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