diff --git a/cli/configssh.go b/cli/configssh.go
index b81b39041e..7e6049f2ac 100644
--- a/cli/configssh.go
+++ b/cli/configssh.go
@@ -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":
diff --git a/cli/configssh_internal_test.go b/cli/configssh_internal_test.go
index cf7a5bff05..3dee74ae34 100644
--- a/cli/configssh_internal_test.go
+++ b/cli/configssh_internal_test.go
@@ -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")
+ }
+ })
+ }
+}
diff --git a/cli/configssh_test.go b/cli/configssh_test.go
index b381c508a5..2f68ec8956 100644
--- a/cli/configssh_test.go
+++ b/cli/configssh_test.go
@@ -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))))
+}
diff --git a/cli/testdata/coder_config-ssh_--help.golden b/cli/testdata/coder_config-ssh_--help.golden
index 411e7607ff..5527125205 100644
--- a/cli/testdata/coder_config-ssh_--help.golden
+++ b/cli/testdata/coder_config-ssh_--help.golden
@@ -36,6 +36,11 @@ OPTIONS:
--hostname-suffix string, $CODER_CONFIGSSH_HOSTNAME_SUFFIX
Override the default hostname suffix.
+ --no-wildcard bool, $CODER_CONFIGSSH_NO_WILDCARD (default: false)
+ 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.
+
--ssh-config-file string, $CODER_SSH_CONFIG_FILE (default: ~/.ssh/config)
Specifies the path to an SSH config.
diff --git a/docs/reference/cli/config-ssh.md b/docs/reference/cli/config-ssh.md
index 96dd6858e2..a169c9fe30 100644
--- a/docs/reference/cli/config-ssh.md
+++ b/docs/reference/cli/config-ssh.md
@@ -108,6 +108,16 @@ Specifies whether or not to wait for the startup script to finish executing. Aut
Disable starting the workspace automatically when connecting via SSH.
+### --no-wildcard
+
+| | |
+|-------------|-------------------------------------------|
+| Type | bool |
+| Environment | $CODER_CONFIGSSH_NO_WILDCARD |
+| Default | false |
+
+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.
+
### --force-unix-filepaths
| | |
diff --git a/docs/user-guides/workspace-access/index.md b/docs/user-guides/workspace-access/index.md
index ee1bd9aa5c..c78cd8e662 100644
--- a/docs/user-guides/workspace-access/index.md
+++ b/docs/user-guides/workspace-access/index.md
@@ -80,6 +80,13 @@ successful, you'll see the following message:
Your workspace is now accessible via `ssh coder.`
(for example, `ssh coder.myEnv` if your workspace is named `myEnv`).
+> [!TIP]
+> If you use a third-party SSH client that discovers hosts by parsing
+> `~/.ssh/config` (such as the VS Code Remote-SSH sidebar or scripts that
+> enumerate known hosts), run `coder config-ssh --no-wildcard` instead. This
+> generates an individual `Host` entry per workspace rather than a single
+> wildcard block, making your workspaces visible to those tools.
+
## Visual Studio Code
You can develop in your Coder workspace remotely with
diff --git a/docs/user-guides/workspace-access/jetbrains/gateway.md b/docs/user-guides/workspace-access/jetbrains/gateway.md
index 930e97c083..5ef42a37f2 100644
--- a/docs/user-guides/workspace-access/jetbrains/gateway.md
+++ b/docs/user-guides/workspace-access/jetbrains/gateway.md
@@ -166,6 +166,12 @@ This is in lieu of using Coder's Gateway plugin which automatically performs the
1. Make sure the checkbox for **Parse config file ~/.ssh/config** is checked.
+ > [!TIP]
+ > Gateway discovers hosts by parsing `~/.ssh/config`. If your workspaces
+ > do not appear in Gateway's host list, re-run `coder config-ssh
+ > --no-wildcard` to generate an individual `Host` entry per workspace
+ > instead of a wildcard block.
+
1. Click **Test Connection** to validate these settings.
1. Click **OK**: