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

Lines changed: 9 additions & 0 deletions
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

Lines changed: 39 additions & 4 deletions
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

Lines changed: 69 additions & 51 deletions
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

Lines changed: 5 additions & 0 deletions
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

Lines changed: 9 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)
pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy