feat: add --no-wildcard flag to coder config-ssh (#26753)

Add `--no-wildcard` (`CODER_CONFIGSSH_NO_WILDCARD`) to `coder
config-ssh` that generates an individual `Host` entry per workspace
instead of a single wildcard block (`Host *.coder`).

The wildcard approach cannot be enumerated by third-party SSH clients,
the VS Code Remote-SSH sidebar, or scripts that parse `~/.ssh/config` to
discover hosts. With `--no-wildcard`, each workspace gets its own entry
so those tools work without Coder-specific extensions.

The flag is persisted in the config section header so re-running without
it prompts the user about the option change. Workspaces are fetched with
pagination before writing so the diff shows actual hostnames.

## Manual testing

**Unit tests (no server needed):**

```sh
go test ./cli/ -run TestSSHConfigOptions_writeToBuffer -v
go test ./cli/ -run TestConfigSSH_NoWildcard -v
```

**End-to-end with a dev server:**

1. Build: `go build -o ./coder .`
2. Start dev server in a separate terminal: `./scripts/develop.sh`
3. Log in: `./coder login http://localhost:3000`
4. Create two workspaces
5. Run both variants into temp files:
```sh
./coder config-ssh --no-wildcard --hostname-suffix coder --ssh-config-file /tmp/test-ssh-config --yes
./coder config-ssh --hostname-suffix coder --ssh-config-file /tmp/test-ssh-config-wildcard --yes
diff /tmp/test-ssh-config-wildcard /tmp/test-ssh-config
```

<details>
<summary>Output: <code>--no-wildcard</code></summary>

```
# ------------START-CODER-----------
# This section is managed by coder. DO NOT EDIT.
#
# You should not hand-edit this section unless you are removing it, all
# changes will be lost when running "coder config-ssh".
#
# Last config-ssh options:
# :hostname-suffix=coder
# :no-wildcard=true
#
Host coder.myworkspace
    ConnectTimeout=0
    StrictHostKeyChecking=no
    UserKnownHostsFile=/dev/null
    LogLevel ERROR
    ProxyCommand <coder> --global-config <config> ssh --stdio --ssh-host-prefix coder. %h

Host coder.myworkspace2
    ConnectTimeout=0
    StrictHostKeyChecking=no
    UserKnownHostsFile=/dev/null
    LogLevel ERROR
    ProxyCommand <coder> --global-config <config> ssh --stdio --ssh-host-prefix coder. %h

Host myworkspace.coder
    ConnectTimeout=0
    StrictHostKeyChecking=no
    UserKnownHostsFile=/dev/null
    LogLevel ERROR

Match host myworkspace.coder !exec "<coder> connect exists %h"
    ProxyCommand <coder> --global-config <config> ssh --stdio --hostname-suffix coder %h

Host myworkspace2.coder
    ConnectTimeout=0
    StrictHostKeyChecking=no
    UserKnownHostsFile=/dev/null
    LogLevel ERROR

Match host myworkspace2.coder !exec "<coder> connect exists %h"
    ProxyCommand <coder> --global-config <config> ssh --stdio --hostname-suffix coder %h
# ------------END-CODER------------
```

</details>

<details>
<summary>Output: wildcard (default)</summary>

```
# ------------START-CODER-----------
# This section is managed by coder. DO NOT EDIT.
#
# You should not hand-edit this section unless you are removing it, all
# changes will be lost when running "coder config-ssh".
#
# Last config-ssh options:
# :hostname-suffix=coder
#
Host coder.*
    ConnectTimeout=0
    StrictHostKeyChecking=no
    UserKnownHostsFile=/dev/null
    LogLevel ERROR
    ProxyCommand <coder> --global-config <config> ssh --stdio --ssh-host-prefix coder. %h

Host *.coder
    ConnectTimeout=0
    StrictHostKeyChecking=no
    UserKnownHostsFile=/dev/null
    LogLevel ERROR

Match host *.coder !exec "<coder> connect exists %h"
    ProxyCommand <coder> --global-config <config> ssh --stdio --hostname-suffix coder %h
# ------------END-CODER------------
```

</details>

<details>
<summary>diff wildcard → --no-wildcard</summary>

```diff
8a9
> # :no-wildcard=true
10c11
< Host coder.*
---
> Host coder.myworkspace
17c18
< Host *.coder
---
> Host coder.myworkspace2
21a23
>     ProxyCommand <coder> ssh --stdio --ssh-host-prefix coder. %h
23c25,31
< Match host *.coder !exec "<coder> connect exists %h"
---
> Host myworkspace.coder
>     ConnectTimeout=0
>     StrictHostKeyChecking=no
>     UserKnownHostsFile=/dev/null
>     LogLevel ERROR
>
> Match host myworkspace.coder !exec "<coder> connect exists %h"
```

</details>

Closes https://github.com/coder/coder/issues/17153 (Phase 1: CLI flag)
This commit is contained in:
Bobby Ho
2026-06-30 13:02:15 -07:00
committed by GitHub
parent 7baf0d4a9a
commit dcb120d6ab
7 changed files with 468 additions and 34 deletions
+180
View File
@@ -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")
}
})
}
}