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
+129
View File
@@ -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))))
}