diff --git a/cli/configssh.go b/cli/configssh.go index b81b39041e..7e6049f2ac 100644 --- a/cli/configssh.go +++ b/cli/configssh.go @@ -51,6 +51,7 @@ type sshConfigOptions struct { hostnameSuffix string sshOptions []string disableAutostart bool + noWildcard bool header []string headerCommand string removedKeys map[string]bool @@ -58,6 +59,10 @@ type sshConfigOptions struct { coderBinaryPath string skipProxyCommand bool forceUnixSeparators bool + // workspaceNames is populated when noWildcard is true. It holds the + // workspace names used to generate individual host entries. It is not + // persisted to the SSH config header. + workspaceNames []string } // addOptions expects options in the form of "option=value" or "option value". @@ -107,7 +112,8 @@ func (o sshConfigOptions) equal(other sshConfigOptions) bool { o.userHostPrefix == other.userHostPrefix && o.disableAutostart == other.disableAutostart && o.headerCommand == other.headerCommand && - o.hostnameSuffix == other.hostnameSuffix + o.hostnameSuffix == other.hostnameSuffix && + o.noWildcard == other.noWildcard } func (o sshConfigOptions) writeToBuffer(buf *bytes.Buffer) error { @@ -142,26 +148,51 @@ func (o sshConfigOptions) writeToBuffer(buf *bytes.Buffer) error { flags += " --disable-autostart=true" } + // TODO: this function has grown complex enough that it would benefit from + // being rewritten using text/template rather than manual buf.WriteString + // and fmt.Fprintf calls. + // Prefix block: if o.userHostPrefix != "" { - _, _ = buf.WriteString("Host") + if o.noWildcard { + for i, wsName := range o.workspaceNames { + if i > 0 { + _, _ = buf.WriteString("\n") + } + _, _ = fmt.Fprintf(buf, "Host %s%s\n", o.userHostPrefix, wsName) + for _, v := range o.sshOptions { + _, _ = buf.WriteString("\t") + _, _ = buf.WriteString(v) + _, _ = buf.WriteString("\n") + } + if !o.skipProxyCommand { + _, _ = buf.WriteString("\t") + _, _ = fmt.Fprintf(buf, + "ProxyCommand %s %s ssh --stdio%s --ssh-host-prefix %s %%h", + escapedCoderBinaryProxy, rootFlags, flags, o.userHostPrefix, + ) + _, _ = buf.WriteString("\n") + } + } + } else { + _, _ = buf.WriteString("Host") + _, _ = buf.WriteString(" ") + _, _ = buf.WriteString(o.userHostPrefix) + _, _ = buf.WriteString("*\n") - _, _ = buf.WriteString(" ") - _, _ = buf.WriteString(o.userHostPrefix) - _, _ = buf.WriteString("*\n") - - for _, v := range o.sshOptions { - _, _ = buf.WriteString("\t") - _, _ = buf.WriteString(v) - _, _ = buf.WriteString("\n") - } - if !o.skipProxyCommand && o.userHostPrefix != "" { - _, _ = buf.WriteString("\t") - _, _ = fmt.Fprintf(buf, - "ProxyCommand %s %s ssh --stdio%s --ssh-host-prefix %s %%h", - escapedCoderBinaryProxy, rootFlags, flags, o.userHostPrefix, - ) - _, _ = buf.WriteString("\n") + for _, v := range o.sshOptions { + _, _ = buf.WriteString("\t") + _, _ = buf.WriteString(v) + _, _ = buf.WriteString("\n") + } + if !o.skipProxyCommand { + _, _ = buf.WriteString("\t") + _, _ = fmt.Fprintf(buf, + "ProxyCommand %s %s ssh --stdio%s --ssh-host-prefix %s %%h", + escapedCoderBinaryProxy, rootFlags, flags, o.userHostPrefix, + ) + _, _ = buf.WriteString("\n") + } } } @@ -169,22 +200,46 @@ func (o sshConfigOptions) writeToBuffer(buf *bytes.Buffer) error { if o.hostnameSuffix == "" { return nil } - _, _ = fmt.Fprintf(buf, "\nHost *.%s\n", o.hostnameSuffix) - for _, v := range o.sshOptions { - _, _ = buf.WriteString("\t") - _, _ = buf.WriteString(v) - _, _ = buf.WriteString("\n") - } - // the ^^ options should always apply, but we only want to use the proxy command if Coder Connect is not running. - if !o.skipProxyCommand { - _, _ = fmt.Fprintf(buf, "\nMatch host *.%s !exec \"%s connect exists %%h\"\n", - o.hostnameSuffix, escapedCoderBinaryMatchExec) - _, _ = buf.WriteString("\t") - _, _ = fmt.Fprintf(buf, - "ProxyCommand %s %s ssh --stdio%s --hostname-suffix %s %%h", - escapedCoderBinaryProxy, rootFlags, flags, o.hostnameSuffix, - ) - _, _ = buf.WriteString("\n") + + if o.noWildcard { + for _, wsName := range o.workspaceNames { + hostname := wsName + "." + o.hostnameSuffix + _, _ = fmt.Fprintf(buf, "\nHost %s\n", hostname) + for _, v := range o.sshOptions { + _, _ = buf.WriteString("\t") + _, _ = buf.WriteString(v) + _, _ = buf.WriteString("\n") + } + // Options always apply; only use the proxy command when Coder Connect is not running. + if !o.skipProxyCommand { + _, _ = fmt.Fprintf(buf, "\nMatch host %s !exec \"%s connect exists %%h\"\n", + hostname, escapedCoderBinaryMatchExec) + _, _ = buf.WriteString("\t") + _, _ = fmt.Fprintf(buf, + "ProxyCommand %s %s ssh --stdio%s --hostname-suffix %s %%h", + escapedCoderBinaryProxy, rootFlags, flags, o.hostnameSuffix, + ) + _, _ = buf.WriteString("\n") + } + } + } else { + _, _ = fmt.Fprintf(buf, "\nHost *.%s\n", o.hostnameSuffix) + for _, v := range o.sshOptions { + _, _ = buf.WriteString("\t") + _, _ = buf.WriteString(v) + _, _ = buf.WriteString("\n") + } + // Options above always apply; only use the proxy command when Coder Connect is not running. + if !o.skipProxyCommand { + _, _ = fmt.Fprintf(buf, "\nMatch host *.%s !exec \"%s connect exists %%h\"\n", + o.hostnameSuffix, escapedCoderBinaryMatchExec) + _, _ = buf.WriteString("\t") + _, _ = fmt.Fprintf(buf, + "ProxyCommand %s %s ssh --stdio%s --hostname-suffix %s %%h", + escapedCoderBinaryProxy, rootFlags, flags, o.hostnameSuffix, + ) + _, _ = buf.WriteString("\n") + } } return nil } @@ -214,6 +269,9 @@ func (o sshConfigOptions) asList() (list []string) { if o.disableAutostart { list = append(list, fmt.Sprintf("disable-autostart: %v", o.disableAutostart)) } + if o.noWildcard { + list = append(list, "no-wildcard: true") + } for _, opt := range o.sshOptions { list = append(list, fmt.Sprintf("ssh-option: %s", opt)) } @@ -395,6 +453,32 @@ func (r *RootCmd) configSSH() *serpent.Command { if err != nil { return err } + + if configOptions.noWildcard { + // Fetch all workspaces to generate individual host entries. + var wsNames []string + offset := 0 + const pageSize = 100 + for { + res, err := client.Workspaces(ctx, codersdk.WorkspaceFilter{ + Owner: codersdk.Me, + Offset: offset, + Limit: pageSize, + }) + if err != nil { + return xerrors.Errorf("fetch workspaces: %w", err) + } + for _, ws := range res.Workspaces { + wsNames = append(wsNames, ws.Name) + } + if len(res.Workspaces) < pageSize { + break + } + offset += pageSize + } + configOptions.workspaceNames = wsNames + } + err = configOptions.writeToBuffer(buf) if err != nil { return err @@ -559,6 +643,14 @@ func (r *RootCmd) configSSH() *serpent.Command { Value: serpent.BoolOf(&sshConfigOpts.disableAutostart), Default: "false", }, + { + Flag: "no-wildcard", + Env: "CODER_CONFIGSSH_NO_WILDCARD", + Description: "Generate an individual host entry for each workspace instead of a wildcard host block. " + + "This allows third-party tools and SSH clients to discover workspaces by reading the config file.", + Value: serpent.BoolOf(&sshConfigOpts.noWildcard), + Default: "false", + }, { Flag: "force-unix-filepaths", Env: "CODER_CONFIGSSH_UNIX_FILEPATHS", @@ -657,6 +749,9 @@ func sshConfigWriteSectionHeader(w io.Writer, addNewline bool, o sshConfigOption if o.disableAutostart { _, _ = fmt.Fprintf(&ow, "# :%s=%v\n", "disable-autostart", o.disableAutostart) } + if o.noWildcard { + _, _ = fmt.Fprintf(&ow, "# :%s=%v\n", "no-wildcard", o.noWildcard) + } for _, opt := range o.sshOptions { _, _ = fmt.Fprintf(&ow, "# :%s=%s\n", "ssh-option", opt) } @@ -699,6 +794,8 @@ func sshConfigParseLastOptions(r io.Reader) (o sshConfigOptions) { o.sshOptions = append(o.sshOptions, parts[1]) case "disable-autostart": o.disableAutostart, _ = strconv.ParseBool(parts[1]) + case "no-wildcard": + o.noWildcard, _ = strconv.ParseBool(parts[1]) case "header": o.header = append(o.header, parts[1]) case "header-command": diff --git a/cli/configssh_internal_test.go b/cli/configssh_internal_test.go index cf7a5bff05..3dee74ae34 100644 --- a/cli/configssh_internal_test.go +++ b/cli/configssh_internal_test.go @@ -1,6 +1,7 @@ package cli import ( + "bytes" "os" "os/exec" "path/filepath" @@ -9,6 +10,7 @@ import ( "strings" "testing" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/coder/coder/v2/codersdk" @@ -513,3 +515,181 @@ func Test_sshConfigOptions_addOption(t *testing.T) { }) } } + +func TestSSHConfigOptions_writeToBuffer(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + opts sshConfigOptions + want []string // substrings that must appear + notWant []string // substrings that must not appear + }{ + { + name: "wildcard suffix", + opts: sshConfigOptions{ + coderBinaryPath: "/usr/bin/coder", + globalConfigPath: "/tmp/coder", + hostnameSuffix: "coder", + waitEnum: "auto", + }, + want: []string{"Host *.coder\n", "ProxyCommand", "--hostname-suffix coder %h"}, + notWant: []string{"Host workspace"}, + }, + { + name: "wildcard prefix", + opts: sshConfigOptions{ + coderBinaryPath: "/usr/bin/coder", + globalConfigPath: "/tmp/coder", + userHostPrefix: "coder.", + waitEnum: "auto", + }, + want: []string{"Host coder.*\n", "ProxyCommand", "--ssh-host-prefix coder. %h"}, + notWant: []string{"Host coder.workspace"}, + }, + { + name: "no-wildcard suffix with workspaces", + opts: sshConfigOptions{ + coderBinaryPath: "/usr/bin/coder", + globalConfigPath: "/tmp/coder", + hostnameSuffix: "coder", + noWildcard: true, + workspaceNames: []string{"workspace1", "workspace2"}, + waitEnum: "auto", + }, + want: []string{ + "Host workspace1.coder\n", + "Host workspace2.coder\n", + "Match host workspace1.coder !exec", + "Match host workspace2.coder !exec", + "--hostname-suffix coder %h", + }, + notWant: []string{"Host *.coder", "Match host *.coder"}, + }, + { + name: "no-wildcard suffix with zero workspaces produces no host entries", + opts: sshConfigOptions{ + coderBinaryPath: "/usr/bin/coder", + globalConfigPath: "/tmp/coder", + hostnameSuffix: "coder", + noWildcard: true, + workspaceNames: nil, + waitEnum: "auto", + }, + notWant: []string{"Host", "ProxyCommand", "Match"}, + }, + { + name: "no-wildcard prefix with workspaces", + opts: sshConfigOptions{ + coderBinaryPath: "/usr/bin/coder", + globalConfigPath: "/tmp/coder", + userHostPrefix: "coder.", + noWildcard: true, + workspaceNames: []string{"workspace1", "workspace2"}, + waitEnum: "auto", + }, + want: []string{ + "Host coder.workspace1\n", + "Host coder.workspace2\n", + "--ssh-host-prefix coder. %h", + }, + notWant: []string{"Host coder.*"}, + }, + { + name: "no-wildcard suffix skips proxy command when skipProxyCommand is set", + opts: sshConfigOptions{ + coderBinaryPath: "/usr/bin/coder", + globalConfigPath: "/tmp/coder", + hostnameSuffix: "coder", + noWildcard: true, + workspaceNames: []string{"workspace1"}, + skipProxyCommand: true, + waitEnum: "auto", + }, + want: []string{"Host workspace1.coder\n"}, + notWant: []string{"ProxyCommand", "Match host", "Host *.coder"}, + }, + { + name: "no-wildcard prefix skips proxy command when skipProxyCommand is set", + opts: sshConfigOptions{ + coderBinaryPath: "/usr/bin/coder", + globalConfigPath: "/tmp/coder", + userHostPrefix: "coder.", + noWildcard: true, + workspaceNames: []string{"workspace1"}, + skipProxyCommand: true, + waitEnum: "auto", + }, + want: []string{"Host coder.workspace1\n"}, + notWant: []string{"ProxyCommand", "Host coder.*"}, + }, + { + name: "no-wildcard suffix SSH options appear in every workspace entry", + opts: sshConfigOptions{ + coderBinaryPath: "/usr/bin/coder", + globalConfigPath: "/tmp/coder", + hostnameSuffix: "coder", + noWildcard: true, + workspaceNames: []string{"workspace1", "workspace2"}, + sshOptions: []string{"ForwardAgent=yes", "LogLevel=DEBUG"}, + waitEnum: "auto", + }, + want: []string{ + "Host workspace1.coder\n", + "\tForwardAgent=yes\n", + "\tLogLevel=DEBUG\n", + "Host workspace2.coder\n", + }, + }, + { + name: "wildcard suffix SSH options appear in host block", + opts: sshConfigOptions{ + coderBinaryPath: "/usr/bin/coder", + globalConfigPath: "/tmp/coder", + hostnameSuffix: "coder", + sshOptions: []string{"ForwardAgent=yes"}, + waitEnum: "auto", + }, + want: []string{ + "Host *.coder\n", + "\tForwardAgent=yes\n", + }, + }, + { + name: "no-wildcard with both prefix and suffix generates entries for both", + opts: sshConfigOptions{ + coderBinaryPath: "/usr/bin/coder", + globalConfigPath: "/tmp/coder", + userHostPrefix: "coder.", + hostnameSuffix: "testy", + noWildcard: true, + workspaceNames: []string{"workspace1"}, + waitEnum: "auto", + }, + want: []string{ + "Host coder.workspace1\n", + "Host workspace1.testy\n", + "Match host workspace1.testy !exec", + }, + notWant: []string{"Host coder.*", "Host *.testy"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + var buf bytes.Buffer + err := tt.opts.writeToBuffer(&buf) + require.NoError(t, err) + + got := buf.String() + for _, w := range tt.want { + assert.Contains(t, got, w, "expected substring not found") + } + for _, nw := range tt.notWant { + assert.NotContains(t, got, nw, "unexpected substring found") + } + }) + } +} diff --git a/cli/configssh_test.go b/cli/configssh_test.go index b381c508a5..2f68ec8956 100644 --- a/cli/configssh_test.go +++ b/cli/configssh_test.go @@ -14,6 +14,7 @@ import ( "sync" "testing" + "github.com/google/go-cmp/cmp" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -24,6 +25,7 @@ import ( "github.com/coder/coder/v2/coderd/database/dbfake" "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/codersdk/workspacesdk" + sdkproto "github.com/coder/coder/v2/provisionersdk/proto" "github.com/coder/coder/v2/testutil" "github.com/coder/coder/v2/testutil/expecter" ) @@ -554,6 +556,45 @@ func TestConfigSSH_FileWriteAndOptionsFlow(t *testing.T) { "--header-command", "echo h1=v1 h2=\"v2\" h3='v3'", }, }, + { + name: "Serialize no-wildcard flag", + wantConfig: wantConfig{ + ssh: []string{ + strings.Join([]string{ + headerStart, + "# Last config-ssh options:", + "# :hostname-suffix=coder-suffix", + "# :no-wildcard=true", + "#", + }, "\n"), + strings.Join([]string{ + headerEnd, + "", + }, "\n"), + }, + }, + args: []string{ + "--yes", + "--hostname-suffix", "coder-suffix", + "--no-wildcard", + }, + }, + { + name: "No wildcard generates per-workspace entries", + args: []string{ + "--yes", + "--hostname-suffix", "coder", + "--no-wildcard", + }, + hasAgent: true, + wantConfig: wantConfig{ + ssh: []string{ + "# :hostname-suffix=coder", + "# :no-wildcard=true", + }, + regexMatch: `Host [a-z0-9_-]+\.coder`, + }, + }, { name: "Do not prompt for new options when prev opts flag is set", writeConfig: writeConfig{ @@ -811,3 +852,91 @@ func TestConfigSSH_FileWriteAndOptionsFlow(t *testing.T) { }) } } + +func TestConfigSSH_NoWildcard(t *testing.T) { + t.Parallel() + + if runtime.GOOS == "windows" { + t.Skip("See coder/internal#117") + } + + ctx := testutil.Context(t, testutil.WaitMedium) + client, db := coderdtest.NewWithDatabase(t, nil) + user := coderdtest.CreateFirstUser(t, client) + + // Create two workspaces with names in reverse lexical order so that we can + // verify the SSH config entries are sorted by name, not by creation order. + // ws1 sorts after ws2 alphabetically. + ws1 := dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{ + OrganizationID: user.OrganizationID, + OwnerID: user.UserID, + Name: "ws-beta", + }).WithAgent(func(a []*sdkproto.Agent) []*sdkproto.Agent { + a[0].Name = "agent-beta" + return a + }).Do() + ws2 := dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{ + OrganizationID: user.OrganizationID, + OwnerID: user.UserID, + Name: "ws-alpha", + }).WithAgent(func(a []*sdkproto.Agent) []*sdkproto.Agent { + a[0].Name = "agent-alpha" + return a + }).Do() + + sshConfigPath := sshConfigFileName(t) + + runConfigSSH := func() { + inv, root := clitest.New(t, + "config-ssh", + "--ssh-config-file", sshConfigPath, + "--hostname-suffix", "coder", + "--no-wildcard", + "--yes", + ) + //nolint:gocritic // This has always ran with the admin user. + clitest.SetupConfig(t, client, root) + err := inv.WithContext(ctx).Run() + require.NoError(t, err) + } + + // hostLines extracts lines beginning with "Host " from the SSH config. + // ProxyCommand lines embed a per-invocation temp path and are excluded so + // that two runs with different global-config dirs can still be compared. + hostLines := func(s string) []string { + var out []string + for line := range strings.SplitSeq(s, "\n") { + if strings.HasPrefix(line, "Host ") { + out = append(out, line) + } + } + return out + } + + runConfigSSH() + config := sshConfigFileRead(t, sshConfigPath) + + // The server always injects a "coder." hostname prefix in addition to the + // user-supplied "--hostname-suffix coder" entries. With stable workspace + // names we can assert the complete, ordered host-entry list exactly. + // ws-alpha sorts before ws-beta even though ws-alpha was created second. + wantHosts := []string{ + "Host coder." + ws2.Workspace.Name, // coder.ws-alpha + "Host coder." + ws1.Workspace.Name, // coder.ws-beta + "Host " + ws2.Workspace.Name + ".coder", // ws-alpha.coder + "Host " + ws1.Workspace.Name + ".coder", // ws-beta.coder + } + require.Empty(t, cmp.Diff(wantHosts, hostLines(config))) + + // No wildcard entries must appear in the Coder section. + require.NotContains(t, config, "Host *.coder") + require.NotContains(t, config, "Host *.") + + // The no-wildcard option must be persisted in the header. + require.Contains(t, config, "# :no-wildcard=true") + + // Running the command again must yield identical host entries, confirming + // that the ordering is stable across runs. + runConfigSSH() + require.Empty(t, cmp.Diff(wantHosts, hostLines(sshConfigFileRead(t, sshConfigPath)))) +} diff --git a/cli/testdata/coder_config-ssh_--help.golden b/cli/testdata/coder_config-ssh_--help.golden index 411e7607ff..5527125205 100644 --- a/cli/testdata/coder_config-ssh_--help.golden +++ b/cli/testdata/coder_config-ssh_--help.golden @@ -36,6 +36,11 @@ OPTIONS: --hostname-suffix string, $CODER_CONFIGSSH_HOSTNAME_SUFFIX Override the default hostname suffix. + --no-wildcard bool, $CODER_CONFIGSSH_NO_WILDCARD (default: false) + Generate an individual host entry for each workspace instead of a + wildcard host block. This allows third-party tools and SSH clients to + discover workspaces by reading the config file. + --ssh-config-file string, $CODER_SSH_CONFIG_FILE (default: ~/.ssh/config) Specifies the path to an SSH config. diff --git a/docs/reference/cli/config-ssh.md b/docs/reference/cli/config-ssh.md index 96dd6858e2..a169c9fe30 100644 --- a/docs/reference/cli/config-ssh.md +++ b/docs/reference/cli/config-ssh.md @@ -108,6 +108,16 @@ Specifies whether or not to wait for the startup script to finish executing. Aut Disable starting the workspace automatically when connecting via SSH. +### --no-wildcard + +| | | +|-------------|-------------------------------------------| +| Type | bool | +| Environment | $CODER_CONFIGSSH_NO_WILDCARD | +| Default | false | + +Generate an individual host entry for each workspace instead of a wildcard host block. This allows third-party tools and SSH clients to discover workspaces by reading the config file. + ### --force-unix-filepaths | | | diff --git a/docs/user-guides/workspace-access/index.md b/docs/user-guides/workspace-access/index.md index ee1bd9aa5c..c78cd8e662 100644 --- a/docs/user-guides/workspace-access/index.md +++ b/docs/user-guides/workspace-access/index.md @@ -80,6 +80,13 @@ successful, you'll see the following message: Your workspace is now accessible via `ssh coder.` (for example, `ssh coder.myEnv` if your workspace is named `myEnv`). +> [!TIP] +> If you use a third-party SSH client that discovers hosts by parsing +> `~/.ssh/config` (such as the VS Code Remote-SSH sidebar or scripts that +> enumerate known hosts), run `coder config-ssh --no-wildcard` instead. This +> generates an individual `Host` entry per workspace rather than a single +> wildcard block, making your workspaces visible to those tools. + ## Visual Studio Code You can develop in your Coder workspace remotely with diff --git a/docs/user-guides/workspace-access/jetbrains/gateway.md b/docs/user-guides/workspace-access/jetbrains/gateway.md index 930e97c083..5ef42a37f2 100644 --- a/docs/user-guides/workspace-access/jetbrains/gateway.md +++ b/docs/user-guides/workspace-access/jetbrains/gateway.md @@ -166,6 +166,12 @@ This is in lieu of using Coder's Gateway plugin which automatically performs the 1. Make sure the checkbox for **Parse config file ~/.ssh/config** is checked. + > [!TIP] + > Gateway discovers hosts by parsing `~/.ssh/config`. If your workspaces + > do not appear in Gateway's host list, re-run `coder config-ssh + > --no-wildcard` to generate an individual `Host` entry per workspace + > instead of a wildcard block. + 1. Click **Test Connection** to validate these settings. 1. Click **OK**: