mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
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:
@@ -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")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user