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:
@@ -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))))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user