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:
+131
-34
@@ -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":
|
||||
|
||||
Reference in New Issue
Block a user