mirror of
https://github.com/coder/coder.git
synced 2025-07-12 00:14:10 +00:00
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
This commit is contained in:
@ -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)
|
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{
|
options := &coderd.Options{
|
||||||
AccessURL: vals.AccessURL.Value(),
|
AccessURL: vals.AccessURL.Value(),
|
||||||
AppHostname: appHostname,
|
AppHostname: appHostname,
|
||||||
|
43
cli/ssh.go
43
cli/ssh.go
@ -65,6 +65,7 @@ func (r *RootCmd) ssh() *serpent.Command {
|
|||||||
var (
|
var (
|
||||||
stdio bool
|
stdio bool
|
||||||
hostPrefix string
|
hostPrefix string
|
||||||
|
hostnameSuffix string
|
||||||
forwardAgent bool
|
forwardAgent bool
|
||||||
forwardGPG bool
|
forwardGPG bool
|
||||||
identityAgent string
|
identityAgent string
|
||||||
@ -202,10 +203,14 @@ func (r *RootCmd) ssh() *serpent.Command {
|
|||||||
parsedEnv = append(parsedEnv, [2]string{k, v})
|
parsedEnv = append(parsedEnv, [2]string{k, v})
|
||||||
}
|
}
|
||||||
|
|
||||||
workspaceInput := strings.TrimPrefix(inv.Args[0], hostPrefix)
|
deploymentSSHConfig := codersdk.SSHConfigResponse{
|
||||||
// convert workspace name format into owner/workspace.agent
|
HostnamePrefix: hostPrefix,
|
||||||
namedWorkspace := normalizeWorkspaceInput(workspaceInput)
|
HostnameSuffix: hostnameSuffix,
|
||||||
workspace, workspaceAgent, err := getWorkspaceAndAgent(ctx, inv, client, !disableAutostart, namedWorkspace)
|
}
|
||||||
|
|
||||||
|
workspace, workspaceAgent, err := findWorkspaceAndAgentByHostname(
|
||||||
|
ctx, inv, client,
|
||||||
|
inv.Args[0], deploymentSSHConfig, disableAutostart)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
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.",
|
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),
|
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",
|
Flag: "forward-agent",
|
||||||
FlagShorthand: "A",
|
FlagShorthand: "A",
|
||||||
@ -656,6 +667,30 @@ func (r *RootCmd) ssh() *serpent.Command {
|
|||||||
return cmd
|
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
|
// watchAndClose ensures closer is called if the context is canceled or
|
||||||
// the workspace reaches the stopped state.
|
// the workspace reaches the stopped state.
|
||||||
//
|
//
|
||||||
|
132
cli/ssh_test.go
132
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()
|
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()
|
testCases := []struct {
|
||||||
serverOutput, serverInput := io.Pipe()
|
name, hostnameFormat string
|
||||||
defer func() {
|
flags []string
|
||||||
for _, c := range []io.Closer{clientOutput, clientInput, serverOutput, serverInput} {
|
}{
|
||||||
_ = c.Close()
|
{"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--"}},
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
user, err := client.User(ctx, codersdk.Me)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
cmdDone := tGo(t, func() {
|
|
||||||
err := inv.WithContext(ctx).Run()
|
|
||||||
assert.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()
|
|
||||||
|
|
||||||
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)
|
for _, tc := range testCases {
|
||||||
require.NoError(t, err)
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
err = sshClient.Close()
|
t.Parallel()
|
||||||
require.NoError(t, err)
|
|
||||||
_ = clientOutput.Close()
|
|
||||||
|
|
||||||
<-cmdDone
|
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()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
user, err := client.User(ctx, codersdk.Me)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
cmdDone := tGo(t, func() {
|
||||||
|
err := inv.WithContext(ctx).Run()
|
||||||
|
assert.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()
|
||||||
|
|
||||||
|
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
|
||||||
|
})
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
5
cli/testdata/coder_ssh_--help.golden
vendored
5
cli/testdata/coder_ssh_--help.golden
vendored
@ -23,6 +23,11 @@ OPTIONS:
|
|||||||
locally and will not be started for you. If a GPG agent is already
|
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.
|
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
|
--identity-agent string, $CODER_SSH_IDENTITY_AGENT
|
||||||
Specifies which identity agent to use (overrides $SSH_AUTH_SOCK),
|
Specifies which identity agent to use (overrides $SSH_AUTH_SOCK),
|
||||||
forward agent must also be enabled.
|
forward agent must also be enabled.
|
||||||
|
9
docs/reference/cli/ssh.md
generated
9
docs/reference/cli/ssh.md
generated
@ -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.
|
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 | <code>string</code> |
|
||||||
|
| Environment | <code>$CODER_SSH_HOSTNAME_SUFFIX</code> |
|
||||||
|
|
||||||
|
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
|
### -A, --forward-agent
|
||||||
|
|
||||||
| | |
|
| | |
|
||||||
|
Reference in New Issue
Block a user