Files
coder/cli/configssh.go
T
Bobby Ho 66b42650ae fix(cli): respect empty --ssh-host-prefix/--hostname-suffix flags (#27084)
## Problem

`coder config-ssh --ssh-host-prefix=""` (or the matching env var,
`CODER_CONFIGSSH_SSH_HOST_PREFIX=`) was silently ignored, and the
deprecated `Host coder.*` block was written to the SSH config anyway.
The
merge logic that decides whether to fall back to the server's default
prefix checked `user.userHostPrefix == ""`, which is true both when the
flag was never passed and when it was explicitly set to empty, so there
was no way to distinguish the two. The same issue applied to
`--hostname-suffix`.

## How this affects users

Anyone who wants to opt out of the legacy prefix-based SSH aliases
(`ssh coder.myworkspace`) in favor of the newer suffix-based ones
(`ssh myworkspace.coder`) had no way to do so, the `Host coder.*`
wildcard
block kept reappearing on every `config-ssh` run regardless of the flag.
Because that wildcard matches any hostname starting with `coder.`, not
just Coder workspaces, it can silently intercept SSH connections to
unrelated hosts that happen to share that prefix.

It got worse on top of that: even after passing `--ssh-host-prefix=""`,
running `config-ssh --use-previous-options` in a later session, a normal
way to refresh local config without retyping every flag, would silently
bring the block back, because the empty choice was never persisted to
the
file in the first place.

## Solution

Track whether each option (`--ssh-host-prefix`, `--hostname-suffix`) was
explicitly set by the user, as opposed to left at its zero value, and
only
fall back to the server default (or skip persisting the option) when it
was genuinely never set.

## How it works

Two new fields on `sshConfigOptions`, `userHostPrefixExplicit` and
`hostnameSuffixExplicit`, carry this information:

- **Live invocation**: they're set from `userSetOption(inv, ...)`, which
  inspects serpent's `Option.ValueSource` for the flag, right after
  `header`/`headerCommand` are set in the `Handler`, before any
  `--use-previous-options`/prompt logic can replace the struct wholesale
  from a prior run's saved options.
- **Persistence**: `sshConfigWriteSectionHeader` now writes the
`# :ssh-host-prefix=` comment line even when the value is empty, as long
as it was explicit, and `sshConfigParseLastOptions` sets the field back
  to `true` whenever it parses that line on a later run, regardless of
  value.

`mergeSSHOptions`'s fallback condition changed from
`user.userHostPrefix == ""` to
`user.userHostPrefix == "" && !user.userHostPrefixExplicit` (and the
mirror for suffix). `equal()` and `asList()` were extended to include
the
two new fields so the `--dry-run` diff and "options differ, use new
ones?"
prompt stay accurate.

## Why implemented this way

- Reuses `userSetOption` (`cli/util.go`), an existing helper already
used
for this exact "distinguish zero value from unset" problem elsewhere in
  the CLI (`cli/templateedit.go`), instead of inventing new machinery.
- Storing the "explicit" bit as a plain field on `sshConfigOptions`,
rather
  than as extra parameters to `mergeSSHOptions`, keeps that function
  dependency-free (still plain data in, plain data out, no
`serpent.Invocation` coupling), while letting the same bit flow
naturally
through the SSH config's persisted-options comment, solving the
live-flag
  case and the `--use-previous-options` persistence case with one
  mechanism instead of two.
- A sentinel-value approach was considered and rejected: a self-tracking
  custom `serpent.Value` doesn't work because serpent applies a flag's
  default through the same `Value.Set()` call used for real input, so it
  can't tell the two apart; a plain sentinel string would work but leak
  into several other code paths (equality checks, diff/prompt text, the
  persisted comment) that would all need to filter it out.

Closes https://github.com/coder/internal/issues/1208

## Manual verification

Every step below was run against a local dev server
(`./scripts/develop.sh`
+ `./scripts/coder-dev.sh`), pointed at a throwaway `--ssh-config-file`,
never a real `~/.ssh/config`.

### 1. Baseline: unchanged behavior with no flags

```sh
./scripts/coder-dev.sh config-ssh --yes --ssh-config-file "$TEST_SSH_CONFIG"
cat "$TEST_SSH_CONFIG"
```

Both `Host coder.*` and `Host *.coder` are written, unchanged from
before this fix (both server defaults are non-empty out of the box).

<details>
<summary>Output</summary>

```text
Updated "/tmp/tmp.9Y7VIeuQoY"
You should now be able to ssh into your workspace.
For example, try running:

	$ ssh myworkspace.coder
# ------------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".
#
Host coder.*
	ConnectTimeout=0
	StrictHostKeyChecking=no
	UserKnownHostsFile=/dev/null
	LogLevel ERROR
	ProxyCommand .../coder-slim ... ssh --stdio --ssh-host-prefix coder. %h

Host *.coder
	ConnectTimeout=0
	StrictHostKeyChecking=no
	UserKnownHostsFile=/dev/null
	LogLevel ERROR

Match host *.coder !exec ".../coder-slim connect exists %h"
	ProxyCommand .../coder-slim ... ssh --stdio --hostname-suffix coder %h
# ------------END-CODER------------
```

</details>

### 2. Explicit empty `--ssh-host-prefix` omits the legacy block (the
core fix)

```sh
./scripts/coder-dev.sh config-ssh --yes --ssh-config-file "$TEST_SSH_CONFIG" --ssh-host-prefix ""
cat "$TEST_SSH_CONFIG"
```

`Host coder.*` is gone, only `Host *.coder` remains. The choice is now
also persisted (`# :ssh-host-prefix=`).

<details>
<summary>Output</summary>

```text
Updated "/tmp/tmp.9Y7VIeuQoY"
You should now be able to ssh into your workspace.
For example, try running:

	$ ssh myworkspace.coder
# ------------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:
# :ssh-host-prefix=
#

Host *.coder
	ConnectTimeout=0
	StrictHostKeyChecking=no
	UserKnownHostsFile=/dev/null
	LogLevel ERROR

Match host *.coder !exec ".../coder-slim connect exists %h"
	ProxyCommand .../coder-slim ... ssh --stdio --hostname-suffix coder %h
# ------------END-CODER------------
```

</details>

### 3. Same, via the environment variable instead of the flag

```sh
CODER_CONFIGSSH_SSH_HOST_PREFIX="" ./scripts/coder-dev.sh config-ssh --yes --ssh-config-file "$TEST_SSH_CONFIG"
grep -c "Host coder" "$TEST_SSH_CONFIG"
```

Confirms the fix isn't flag-only, `userSetOption` checks `ValueSource`,
set the same way for `ValueSourceFlag` and `ValueSourceEnv`.

<details>
<summary>Output</summary>

```text
Updated "/tmp/tmp.9Y7VIeuQoY"
You should now be able to ssh into your workspace.
For example, try running:

	$ ssh myworkspace.coder
0
```

</details>

### 4. Explicit empty prefix combined with an explicit suffix

```sh
./scripts/coder-dev.sh config-ssh --yes --ssh-config-file "$TEST_SSH_CONFIG" --ssh-host-prefix "" --hostname-suffix mytest
cat "$TEST_SSH_CONFIG"
```

Only `Host *.mytest` is written. Both options are correctly recorded in
the persisted comment.

<details>
<summary>Output</summary>

```text
Updated "/tmp/tmp.9Y7VIeuQoY"
You should now be able to ssh into your workspace.
For example, try running:

	$ ssh myworkspace.mytest
# ------------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:
# :ssh-host-prefix=
# :hostname-suffix=mytest
#

Host *.mytest
	ConnectTimeout=0
	StrictHostKeyChecking=no
	UserKnownHostsFile=/dev/null
	LogLevel ERROR

Match host *.mytest !exec ".../coder-slim connect exists %h"
	ProxyCommand .../coder-slim ... ssh --stdio --hostname-suffix mytest %h
# ------------END-CODER------------
```

</details>

### 5. The explicitly-empty choice survives `--use-previous-options`
with no flag repeated

This is the persistence half of the fix: confirms the "omit this block"
choice, once persisted, doesn't get lost on a later run that reuses
previous options without repeating `--ssh-host-prefix`. Before this fix,
this exact sequence would bring `Host coder.*` back.

```sh
./scripts/coder-dev.sh config-ssh --yes --ssh-config-file "$TEST_SSH_CONFIG" --ssh-host-prefix ""
./scripts/coder-dev.sh config-ssh --yes --ssh-config-file "$TEST_SSH_CONFIG" --use-previous-options
cat "$TEST_SSH_CONFIG"
```

<details>
<summary>Output</summary>

```text
Updated "/tmp/tmp.9Y7VIeuQoY"
You should now be able to ssh into your workspace.
For example, try running:

	$ ssh myworkspace.coder
No changes to make.
# ------------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:
# :ssh-host-prefix=
#

Host *.coder
	ConnectTimeout=0
	StrictHostKeyChecking=no
	UserKnownHostsFile=/dev/null
	LogLevel ERROR

Match host *.coder !exec ".../coder-slim connect exists %h"
	ProxyCommand .../coder-slim ... ssh --stdio --hostname-suffix coder %h
# ------------END-CODER------------
```

</details>

The second command printed `No changes to make.`, and critically, `Host
coder.*` did **not** reappear even though that run passed no
`--ssh-host-prefix` flag at all, only `--use-previous-options`.

### 6. `--use-previous-options` still wins over this run's explicit
empty flag (unaffected by this fix)

Confirms this fix didn't change the pre-existing, intentional precedence
of `--use-previous-options`: a previously-saved *non-empty* value still
wins over an explicit empty flag passed on a later run.

```sh
./scripts/coder-dev.sh config-ssh --yes --ssh-config-file "$TEST_SSH_CONFIG" --ssh-host-prefix "custom-test."
./scripts/coder-dev.sh config-ssh --yes --ssh-config-file "$TEST_SSH_CONFIG" --use-previous-options --ssh-host-prefix ""
cat "$TEST_SSH_CONFIG"
```

<details>
<summary>Output</summary>

```text
Updated "/tmp/tmp.9Y7VIeuQoY"
You should now be able to ssh into your workspace.
For example, try running:

	$ ssh myworkspace.coder
No changes to make.
# ------------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:
# :ssh-host-prefix=custom-test.
#
Host custom-test.*
	ConnectTimeout=0
	StrictHostKeyChecking=no
	UserKnownHostsFile=/dev/null
	LogLevel ERROR
	ProxyCommand .../coder-slim ... ssh --stdio --ssh-host-prefix custom-test. %h

Host *.coder
	ConnectTimeout=0
	StrictHostKeyChecking=no
	UserKnownHostsFile=/dev/null
	LogLevel ERROR

Match host *.coder !exec ".../coder-slim connect exists %h"
	ProxyCommand .../coder-slim ... ssh --stdio --hostname-suffix coder %h
# ------------END-CODER------------
```

</details>

`Host custom-test.*` is preserved verbatim, `--use-previous-options`
correctly overrides the explicit empty flag when the saved value is
non-empty, the mirror image of step 5's explicit-empty saved value.

### 7. End-to-end sanity check with a real workspace

```sh
./scripts/coder-dev.sh config-ssh --yes --ssh-config-file "$TEST_SSH_CONFIG" --ssh-host-prefix "" --hostname-suffix mytest
ssh -F "$TEST_SSH_CONFIG" -o ConnectTimeout=15 myworkspace.mytest echo ok
```

<details>
<summary>Output</summary>

```text
Updated "/tmp/tmp.9Y7VIeuQoY"
You should now be able to ssh into your workspace.
For example, try running:

	$ ssh myworkspace.mytest
ok
```

</details>

`ok` came back from a real, running workspace, confirming the
ProxyCommand and Match/exec wiring generated by the suffix-only config
actually establishes a working SSH session end-to-end, not just a
text-generation check.
2026-07-09 07:40:42 -07:00

1024 lines
33 KiB
Go

package cli
import (
"bufio"
"bytes"
"errors"
"fmt"
"io"
"io/fs"
"net/http"
"os"
"path/filepath"
"runtime"
"slices"
"strconv"
"strings"
"github.com/cli/safeexec"
"github.com/natefinch/atomic"
"github.com/pkg/diff"
"github.com/pkg/diff/write"
"golang.org/x/exp/constraints"
"golang.org/x/xerrors"
"github.com/coder/coder/v2/cli/cliui"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/serpent"
)
const (
sshDefaultConfigFileName = "~/.ssh/config"
sshStartToken = "# ------------START-CODER-----------"
sshEndToken = "# ------------END-CODER------------"
sshConfigSectionHeader = "# This section is managed by coder. DO NOT EDIT."
sshConfigDocsHeader = `
#
# You should not hand-edit this section unless you are removing it, all
# changes will be lost when running "coder config-ssh".
`
sshConfigOptionsHeader = `#
# Last config-ssh options:
`
)
// sshConfigOptions represents options that can be stored and read
// from the coder config in ~/.ssh/coder.
type sshConfigOptions struct {
waitEnum string
// Deprecated: moving away from prefix to hostnameSuffix
userHostPrefix string
hostnameSuffix string
// userHostPrefixExplicit and hostnameSuffixExplicit distinguish an
// intentional empty value from "unset" (which falls back to the
// server default). Persisted across --use-previous-options runs.
userHostPrefixExplicit bool
hostnameSuffixExplicit bool
sshOptions []string
disableAutostart bool
noWildcard bool
header []string
headerCommand string
removedKeys map[string]bool
globalConfigPath string
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".
// It will override any existing option with the same key to prevent duplicates.
// Invalid options will return an error.
func (o *sshConfigOptions) addOptions(options ...string) error {
for _, option := range options {
err := o.addOption(option)
if err != nil {
return err
}
}
return nil
}
func (o *sshConfigOptions) addOption(option string) error {
key, value, err := codersdk.ParseSSHConfigOption(option)
if err != nil {
return err
}
lowerKey := strings.ToLower(key)
if o.removedKeys != nil && o.removedKeys[lowerKey] {
// Key marked as removed, skip.
return nil
}
// Only append the option if it is not empty
// (we interpret empty as removal).
if value != "" {
o.sshOptions = append(o.sshOptions, option)
} else {
if o.removedKeys == nil {
o.removedKeys = make(map[string]bool)
}
o.removedKeys[lowerKey] = true
}
return nil
}
func (o sshConfigOptions) equal(other sshConfigOptions) bool {
if !slicesSortedEqual(o.sshOptions, other.sshOptions) {
return false
}
if !slicesSortedEqual(o.header, other.header) {
return false
}
return o.waitEnum == other.waitEnum &&
o.userHostPrefix == other.userHostPrefix &&
o.userHostPrefixExplicit == other.userHostPrefixExplicit &&
o.disableAutostart == other.disableAutostart &&
o.headerCommand == other.headerCommand &&
o.hostnameSuffix == other.hostnameSuffix &&
o.hostnameSuffixExplicit == other.hostnameSuffixExplicit &&
o.noWildcard == other.noWildcard
}
func (o sshConfigOptions) writeToBuffer(buf *bytes.Buffer) error {
escapedCoderBinaryProxy, err := sshConfigProxyCommandEscape(o.coderBinaryPath, o.forceUnixSeparators)
if err != nil {
return xerrors.Errorf("escape coder binary for ProxyCommand failed: %w", err)
}
escapedCoderBinaryMatchExec, err := sshConfigMatchExecEscape(o.coderBinaryPath)
if err != nil {
return xerrors.Errorf("escape coder binary for Match exec failed: %w", err)
}
escapedGlobalConfig, err := sshConfigProxyCommandEscape(o.globalConfigPath, o.forceUnixSeparators)
if err != nil {
return xerrors.Errorf("escape global config for ProxyCommand failed: %w", err)
}
rootFlags := fmt.Sprintf("--global-config %s", escapedGlobalConfig)
for _, h := range o.header {
rootFlags += fmt.Sprintf(" --header %q", h)
}
if o.headerCommand != "" {
rootFlags += fmt.Sprintf(" --header-command %q", o.headerCommand)
}
flags := ""
if o.waitEnum != "auto" {
flags += " --wait=" + o.waitEnum
}
if o.disableAutostart {
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 != "" {
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")
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")
}
}
}
// Suffix block
if o.hostnameSuffix == "" {
return nil
}
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
}
// slicesSortedEqual compares two slices without side-effects or regard to order.
func slicesSortedEqual[S ~[]E, E constraints.Ordered](a, b S) bool {
if len(a) != len(b) {
return false
}
a = slices.Clone(a)
slices.Sort(a)
b = slices.Clone(b)
slices.Sort(b)
return slices.Equal(a, b)
}
func (o sshConfigOptions) asList() (list []string) {
if o.waitEnum != "auto" {
list = append(list, fmt.Sprintf("wait: %s", o.waitEnum))
}
if o.userHostPrefix != "" {
list = append(list, fmt.Sprintf("ssh-host-prefix: %s", o.userHostPrefix))
} else if o.userHostPrefixExplicit {
list = append(list, "ssh-host-prefix: (explicitly empty)")
}
if o.hostnameSuffix != "" {
list = append(list, fmt.Sprintf("hostname-suffix: %s", o.hostnameSuffix))
} else if o.hostnameSuffixExplicit {
list = append(list, "hostname-suffix: (explicitly empty)")
}
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))
}
for _, h := range o.header {
list = append(list, fmt.Sprintf("header: %s", h))
}
if o.headerCommand != "" {
list = append(list, fmt.Sprintf("header-command: %s", o.headerCommand))
}
return list
}
func (r *RootCmd) configSSH() *serpent.Command {
var (
sshConfigFile string
sshConfigOpts sshConfigOptions
usePreviousOpts bool
dryRun bool
coderCliPath string
)
cmd := &serpent.Command{
Annotations: workspaceCommand,
Use: "config-ssh",
Short: "Add an SSH Host entry for your workspaces \"ssh workspace.coder\"",
Long: FormatExamples(
Example{
Description: "You can use -o (or --ssh-option) so set SSH options to be used for all your workspaces",
Command: "coder config-ssh -o ForwardAgent=yes",
},
Example{
Description: "You can use --dry-run (or -n) to see the changes that would be made",
Command: "coder config-ssh --dry-run",
},
),
Middleware: serpent.Chain(
serpent.RequireNArgs(0),
),
Handler: func(inv *serpent.Invocation) error {
client, err := r.InitClient(inv)
if err != nil {
return err
}
ctx := inv.Context()
if sshConfigOpts.waitEnum != "auto" && sshConfigOpts.skipProxyCommand {
// The wait option is applied to the ProxyCommand. If the user
// specifies skip-proxy-command, then wait cannot be applied.
return xerrors.Errorf("cannot specify both --skip-proxy-command and --wait")
}
sshConfigOpts.header = r.header
sshConfigOpts.headerCommand = r.headerCommand
// Record whether the user explicitly set these this run, before
// any --use-previous-options/prompt logic below may replace
// sshConfigOpts wholesale with a prior run's saved options (which
// carry their own explicit bits, parsed back by
// sshConfigParseLastOptions).
sshConfigOpts.userHostPrefixExplicit = userSetOption(inv, "ssh-host-prefix")
sshConfigOpts.hostnameSuffixExplicit = userSetOption(inv, "hostname-suffix")
// Talk to the API early to prevent the version mismatch
// warning from being printed in the middle of a prompt.
// This is needed because the asynchronous requests issued
// by sshPrepareWorkspaceConfigs may otherwise trigger the
// warning at any time.
_, _ = client.BuildInfo(ctx)
out := inv.Stdout
if dryRun {
// Print everything except diff to stderr so
// that it's possible to capture the diff.
out = inv.Stderr
}
coderBinary := coderCliPath
if coderBinary == "" {
coderBinary, err = currentBinPath(out)
if err != nil {
return err
}
}
root := r.createConfig()
homedir, err := os.UserHomeDir()
if err != nil {
return xerrors.Errorf("user home dir failed: %w", err)
}
if strings.HasPrefix(sshConfigFile, "~/") {
sshConfigFile = filepath.Join(homedir, sshConfigFile[2:])
}
// Only allow not-exist errors to avoid trashing
// the users SSH config.
configRaw, err := os.ReadFile(sshConfigFile)
if err != nil && !errors.Is(err, fs.ErrNotExist) {
return xerrors.Errorf("read ssh config failed: %w", err)
}
// Keep track of changes we are making.
var changes []string
// Parse the previous configuration only if config-ssh
// has been run previously.
var lastConfig *sshConfigOptions
section, ok, err := sshConfigGetCoderSection(configRaw)
if err != nil {
return err
}
if ok {
c := sshConfigParseLastOptions(bytes.NewReader(section))
lastConfig = &c
}
// Avoid prompting in diff mode (unexpected behavior)
// or when a previous config does not exist.
if usePreviousOpts && lastConfig != nil {
sshConfigOpts = *lastConfig
} else if lastConfig != nil && !sshConfigOpts.equal(*lastConfig) {
for _, v := range sshConfigOpts.sshOptions {
// If the user passes an invalid option, we should catch
// this early.
if _, _, err := codersdk.ParseSSHConfigOption(v); err != nil {
return xerrors.Errorf("invalid option from flag: %w", err)
}
}
newOpts := sshConfigOpts.asList()
newOptsMsg := "\n\n New options: none"
if len(newOpts) > 0 {
newOptsMsg = fmt.Sprintf("\n\n New options:\n * %s", strings.Join(newOpts, "\n * "))
}
oldOpts := lastConfig.asList()
oldOptsMsg := "\n\n Previous options: none"
if len(oldOpts) > 0 {
oldOptsMsg = fmt.Sprintf("\n\n Previous options:\n * %s", strings.Join(oldOpts, "\n * "))
}
line, err := cliui.Prompt(inv, cliui.PromptOptions{
Text: fmt.Sprintf("New options differ from previous options:%s%s\n\n Use new options?", newOptsMsg, oldOptsMsg),
IsConfirm: true,
})
if err != nil {
if line == "" && xerrors.Is(err, cliui.ErrCanceled) {
return nil
}
// Selecting "no" will use the last config.
sshConfigOpts = *lastConfig
} else {
changes = append(changes, "Use new options")
}
// Only print when prompts are shown.
if yes, _ := inv.ParsedFlags().GetBool("yes"); !yes {
_, _ = fmt.Fprint(out, "\n")
}
}
configModified := configRaw
buf := &bytes.Buffer{}
before, _, after, err := sshConfigSplitOnCoderSection(configModified)
if err != nil {
return err
}
// Write the first half of the users config file to buf.
_, _ = buf.Write(before)
// Write comment and store the provided options as part
// of the config for future (re)use.
newline := len(before) > 0
sshConfigWriteSectionHeader(buf, newline, sshConfigOpts)
coderdConfig, err := client.SSHConfiguration(ctx)
if err != nil {
// If the error is 404, this deployment does not support
// this endpoint yet. Do not error, just assume defaults.
// TODO: Remove this in 2 months (May 31, 2023). Just return the error
// and remove this 404 check.
var sdkErr *codersdk.Error
if !(xerrors.As(err, &sdkErr) && sdkErr.StatusCode() == http.StatusNotFound) {
return xerrors.Errorf("fetch coderd config failed: %w", err)
}
coderdConfig.HostnamePrefix = "coder."
}
configOptions, err := mergeSSHOptions(sshConfigOpts, coderdConfig, string(root), coderBinary)
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
}
sshConfigWriteSectionEnd(buf)
// Write the remainder of the users config file to buf.
_, _ = buf.Write(after)
if !bytes.Equal(configModified, buf.Bytes()) {
changes = append(changes, fmt.Sprintf("Update the coder section in %s", sshConfigFile))
configModified = buf.Bytes()
}
if len(changes) == 0 {
_, _ = fmt.Fprintf(out, "No changes to make.\n")
return nil
}
if dryRun {
_, _ = fmt.Fprintf(out, "Dry run, the following changes would be made to your SSH configuration:\n\n * %s\n\n", strings.Join(changes, "\n * "))
color := isTTYOut(inv)
diff, err := diffBytes(sshConfigFile, configRaw, configModified, color)
if err != nil {
return xerrors.Errorf("diff failed: %w", err)
}
if len(diff) > 0 {
// Write diff to stdout.
_, _ = fmt.Fprintf(inv.Stdout, "%s", diff)
}
return nil
}
if len(changes) > 0 {
_, err = cliui.Prompt(inv, cliui.PromptOptions{
Text: fmt.Sprintf("The following changes will be made to your SSH configuration:\n\n * %s\n\n Continue?", strings.Join(changes, "\n * ")),
IsConfirm: true,
})
if err != nil {
return nil
}
// Only print when prompts are shown.
if yes, _ := inv.ParsedFlags().GetBool("yes"); !yes {
_, _ = fmt.Fprint(out, "\n")
}
}
if !bytes.Equal(configRaw, configModified) {
sshDir := filepath.Dir(sshConfigFile)
if err := os.MkdirAll(sshDir, 0o700); err != nil {
return xerrors.Errorf("failed to create directory %q: %w", sshDir, err)
}
err = atomic.WriteFile(sshConfigFile, bytes.NewReader(configModified))
if err != nil {
return xerrors.Errorf("write ssh config failed: %w", err)
}
_, _ = fmt.Fprintf(out, "Updated %q\n", sshConfigFile)
}
res, err := client.Workspaces(ctx, codersdk.WorkspaceFilter{
Owner: codersdk.Me,
Limit: 1,
})
if err != nil {
return xerrors.Errorf("fetch workspaces failed: %w", err)
}
if len(res.Workspaces) > 0 {
_, _ = fmt.Fprintln(out, "You should now be able to ssh into your workspace.")
if configOptions.hostnameSuffix != "" {
_, _ = fmt.Fprintf(out, "For example, try running:\n\n\t$ ssh %s.%s\n", res.Workspaces[0].Name, configOptions.hostnameSuffix)
} else if configOptions.userHostPrefix != "" {
_, _ = fmt.Fprintf(out, "For example, try running:\n\n\t$ ssh %s%s\n", configOptions.userHostPrefix, res.Workspaces[0].Name)
}
} else {
_, _ = fmt.Fprint(out, "You don't have any workspaces yet, try creating one with:\n\n\t$ coder create <workspace>\n")
}
return nil
},
}
cmd.Options = serpent.OptionSet{
{
Flag: "ssh-config-file",
Env: "CODER_SSH_CONFIG_FILE",
Default: sshDefaultConfigFileName,
Description: "Specifies the path to an SSH config.",
Value: serpent.StringOf(&sshConfigFile),
},
{
Flag: "coder-binary-path",
Env: "CODER_SSH_CONFIG_BINARY_PATH",
Default: "",
Description: "Optionally specify the absolute path to the coder binary used in ProxyCommand. " +
"By default, the binary invoking this command ('config ssh') is used.",
Value: serpent.Validate(serpent.StringOf(&coderCliPath), func(value *serpent.String) error {
if runtime.GOOS == goosWindows {
// For some reason filepath.IsAbs() does not work on windows.
return nil
}
absolute := filepath.IsAbs(value.String())
if !absolute {
return xerrors.Errorf("coder cli path must be an absolute path")
}
return nil
}),
},
{
Flag: "ssh-option",
FlagShorthand: "o",
Env: "CODER_SSH_CONFIG_OPTS",
Description: "Specifies additional SSH options to embed in each host stanza.",
Value: serpent.StringArrayOf(&sshConfigOpts.sshOptions),
},
{
Flag: "dry-run",
FlagShorthand: "n",
Env: "CODER_SSH_DRY_RUN",
Description: "Perform a trial run with no changes made, showing a diff at the end.",
Value: serpent.BoolOf(&dryRun),
},
{
Flag: "skip-proxy-command",
Env: "CODER_SSH_SKIP_PROXY_COMMAND",
Description: "Specifies whether the ProxyCommand option should be skipped. Useful for testing.",
Value: serpent.BoolOf(&sshConfigOpts.skipProxyCommand),
Hidden: true,
},
{
Flag: "use-previous-options",
Env: "CODER_SSH_USE_PREVIOUS_OPTIONS",
Description: "Specifies whether or not to keep options from previous run of config-ssh.",
Value: serpent.BoolOf(&usePreviousOpts),
},
{
Flag: "ssh-host-prefix",
Env: "CODER_CONFIGSSH_SSH_HOST_PREFIX",
Description: "Override the default host prefix.",
Value: serpent.StringOf(&sshConfigOpts.userHostPrefix),
},
{
Flag: "hostname-suffix",
Env: "CODER_CONFIGSSH_HOSTNAME_SUFFIX",
Description: "Override the default hostname suffix.",
Value: serpent.StringOf(&sshConfigOpts.hostnameSuffix),
},
{
Flag: "wait",
Env: "CODER_CONFIGSSH_WAIT", // Not to be mixed with CODER_SSH_WAIT.
Description: "Specifies whether or not to wait for the startup script to finish executing. Auto means that the agent startup script behavior configured in the workspace template is used.",
Default: "auto",
Value: serpent.EnumOf(&sshConfigOpts.waitEnum, "yes", "no", "auto"),
},
{
Flag: "disable-autostart",
Description: "Disable starting the workspace automatically when connecting via SSH.",
Env: "CODER_CONFIGSSH_DISABLE_AUTOSTART",
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",
Description: "By default, 'config-ssh' uses the os path separator when writing the ssh config. " +
"This might be an issue in Windows machine that use a unix-like shell. " +
"This flag forces the use of unix file paths (the forward slash '/').",
Value: serpent.BoolOf(&sshConfigOpts.forceUnixSeparators),
},
cliui.SkipPromptOption(),
}
return cmd
}
func mergeSSHOptions(
user sshConfigOptions, coderd codersdk.SSHConfigResponse, globalConfigPath, coderBinaryPath string,
) (
sshConfigOptions, error,
) {
if err := coderd.Validate(); err != nil {
return sshConfigOptions{}, xerrors.Errorf("invalid ssh config from coderd: %w", err)
}
// Write agent configuration.
defaultOptions := []string{
"ConnectTimeout=0",
"StrictHostKeyChecking=no",
// Without this, the "REMOTE HOST IDENTITY CHANGED"
// message will appear.
"UserKnownHostsFile=/dev/null",
// This disables the "Warning: Permanently added 'hostname' (RSA) to the list of known hosts."
// message from appearing on every SSH. This happens because we ignore the known hosts.
"LogLevel ERROR",
}
// Create a copy of the options so we can modify them.
configOptions := user
configOptions.sshOptions = nil
configOptions.globalConfigPath = globalConfigPath
configOptions.coderBinaryPath = coderBinaryPath
// user config takes precedence, but only fall back to the server default
// when the user never set the option at all. An explicitly empty value
// (e.g. --ssh-host-prefix="") means the user wants that block omitted.
if user.userHostPrefix == "" && !user.userHostPrefixExplicit {
configOptions.userHostPrefix = coderd.HostnamePrefix
}
if user.hostnameSuffix == "" && !user.hostnameSuffixExplicit {
configOptions.hostnameSuffix = coderd.HostnameSuffix
}
// User options first (SSH only uses the first
// option unless it can be given multiple times)
for _, opt := range user.sshOptions {
err := configOptions.addOptions(opt)
if err != nil {
return sshConfigOptions{}, xerrors.Errorf("add flag config option %q: %w", opt, err)
}
}
// Deployment options second, allow them to
// override standard options.
for k, v := range coderd.SSHConfigOptions {
opt := fmt.Sprintf("%s %s", k, v)
err := configOptions.addOptions(opt)
if err != nil {
return sshConfigOptions{}, xerrors.Errorf("add coderd config option %q: %w", opt, err)
}
}
// Finally, add the standard options.
if err := configOptions.addOptions(defaultOptions...); err != nil {
return sshConfigOptions{}, err
}
return configOptions, nil
}
//nolint:revive
func sshConfigWriteSectionHeader(w io.Writer, addNewline bool, o sshConfigOptions) {
nl := "\n"
if !addNewline {
nl = ""
}
_, _ = fmt.Fprint(w, nl+sshStartToken+"\n")
_, _ = fmt.Fprint(w, sshConfigSectionHeader)
_, _ = fmt.Fprint(w, sshConfigDocsHeader)
var ow strings.Builder
if o.waitEnum != "auto" {
_, _ = fmt.Fprintf(&ow, "# :%s=%s\n", "wait", o.waitEnum)
}
if o.userHostPrefix != "" || o.userHostPrefixExplicit {
_, _ = fmt.Fprintf(&ow, "# :%s=%s\n", "ssh-host-prefix", o.userHostPrefix)
}
if o.hostnameSuffix != "" || o.hostnameSuffixExplicit {
_, _ = fmt.Fprintf(&ow, "# :%s=%s\n", "hostname-suffix", o.hostnameSuffix)
}
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)
}
for _, h := range o.header {
_, _ = fmt.Fprintf(&ow, "# :%s=%s\n", "header", h)
}
if o.headerCommand != "" {
_, _ = fmt.Fprintf(&ow, "# :%s=%s\n", "header-command", o.headerCommand)
}
if ow.Len() > 0 {
_, _ = fmt.Fprint(w, sshConfigOptionsHeader)
_, _ = fmt.Fprint(w, ow.String())
}
_, _ = fmt.Fprint(w, "#\n")
}
func sshConfigWriteSectionEnd(w io.Writer) {
_, _ = fmt.Fprint(w, sshEndToken+"\n")
}
func sshConfigParseLastOptions(r io.Reader) (o sshConfigOptions) {
// Default values.
o.waitEnum = "auto"
s := bufio.NewScanner(r)
for s.Scan() {
line := s.Text()
if strings.HasPrefix(line, "# :") {
line = strings.TrimPrefix(line, "# :")
parts := strings.SplitN(line, "=", 2)
switch parts[0] {
case "wait":
o.waitEnum = parts[1]
case "ssh-host-prefix":
o.userHostPrefix = parts[1]
o.userHostPrefixExplicit = true
case "hostname-suffix":
o.hostnameSuffix = parts[1]
o.hostnameSuffixExplicit = true
case "ssh-option":
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":
o.headerCommand = parts[1]
default:
// Unknown option, ignore.
}
}
}
if err := s.Err(); err != nil {
panic(err)
}
return o
}
// sshConfigGetCoderSection is a helper function that only returns the coder
// section of the SSH config and a boolean if it exists.
func sshConfigGetCoderSection(data []byte) (section []byte, ok bool, err error) {
_, section, _, err = sshConfigSplitOnCoderSection(data)
if err != nil {
return nil, false, err
}
return section, len(section) > 0, nil
}
// sshConfigSplitOnCoderSection splits the SSH config into 3 sections.
// All lines before sshStartToken, the coder section, and all lines after
// sshEndToken.
func sshConfigSplitOnCoderSection(data []byte) (before, section []byte, after []byte, err error) {
startCount := bytes.Count(data, []byte(sshStartToken))
endCount := bytes.Count(data, []byte(sshEndToken))
if startCount > 1 || endCount > 1 {
return nil, nil, nil, xerrors.New("Malformed config: ssh config has multiple coder sections, please remove all but one")
}
startIndex := bytes.Index(data, []byte(sshStartToken))
endIndex := bytes.Index(data, []byte(sshEndToken))
if startIndex == -1 && endIndex != -1 {
return nil, nil, nil, xerrors.New("Malformed config: ssh config has end header, but missing start header")
}
if startIndex != -1 && endIndex == -1 {
return nil, nil, nil, xerrors.New("Malformed config: ssh config has start header, but missing end header")
}
if startIndex != -1 && endIndex != -1 {
if startIndex > endIndex {
return nil, nil, nil, xerrors.New("Malformed config: ssh config has coder section, but it is malformed and the END header is before the START header")
}
// We use -1 and +1 here to also include the preceding
// and trailing newline, where applicable.
start := startIndex
if start > 0 {
start--
}
end := endIndex + len(sshEndToken)
if end < len(data) {
end++
}
return data[:start], data[start:end], data[end:], nil
}
return data, nil, nil, nil
}
// sshConfigProxyCommandEscape prepares the path for use in ProxyCommand.
// It quotes the string if it contains spaces, as per
// `man 5 ssh_config`. However, OpenSSH uses exec in the users shell to
// run the command, and as such the formatting/escape requirements
// cannot simply be covered by `fmt.Sprintf("%q", path)`.
//
// Always escaping the path with `fmt.Sprintf("%q", path)` usually works
// on most platforms, but double quotes sometimes break on Windows 10
// (see #2853). This function takes a best-effort approach to improving
// compatibility and covering edge cases.
//
// Given the following ProxyCommand:
//
// ProxyCommand "/path/with space/coder" ssh --stdio work
//
// This is ~what OpenSSH would execute:
//
// /bin/bash -c '"/path/with space/to/coder" ssh --stdio workspace'
//
// However, since it's actually an arg in C, the contents inside the
// single quotes are interpreted as is, e.g. if there was a '\t', it
// would be the literal string '\t', not a tab.
//
// See:
// - https://github.com/coder/coder/issues/2853
// - https://github.com/openssh/openssh-portable/blob/V_9_0_P1/sshconnect.c#L158-L167
// - https://github.com/PowerShell/openssh-portable/blob/v8.1.0.0/sshconnect.c#L231-L293
// - https://github.com/PowerShell/openssh-portable/blob/v8.1.0.0/contrib/win32/win32compat/w32fd.c#L1075-L1100
//
// Additional Windows-specific notes:
//
// In some situations a Windows user could be using a unix-like shell such as
// git bash. In these situations the coder.exe is using the windows filepath
// separator (\), but the shell wants the unix filepath separator (/).
// Trying to determine if the shell is unix-like is difficult, so this function
// takes the argument 'forceUnixPath' to force the filepath to be unix-like.
//
// On actual unix machines, this is **always** a noop. Even if a windows
// path is provided.
//
// Passing a "false" for forceUnixPath will result in the filepath separator
// untouched from the original input.
// ---
// This is a control flag, and that is ok. It is a control flag
// based on the OS of the user. Making this a different file is excessive.
// nolint:revive
func sshConfigProxyCommandEscape(path string, forceUnixPath bool) (string, error) {
if forceUnixPath {
// This is a workaround for #7639, where the filepath separator is
// incorrectly the Windows separator (\) instead of the unix separator (/).
path = filepath.ToSlash(path)
}
// This is unlikely to ever happen, but newlines are allowed on
// certain filesystems, but cannot be used inside ssh config.
if strings.ContainsAny(path, "\n") {
return "", xerrors.Errorf("invalid path: %q", path)
}
// In the unlikely event that a path contains quotes, they must be
// escaped so that they are not interpreted as shell quotes.
if strings.Contains(path, "\"") {
path = strings.ReplaceAll(path, "\"", "\\\"")
}
// A space or a tab requires quoting, but tabs must not be escaped
// (\t) since OpenSSH interprets it as a literal \t, not a tab.
if strings.ContainsAny(path, " \t") {
path = fmt.Sprintf("\"%s\"", path) //nolint:gocritic // We don't want %q here.
}
return path, nil
}
// currentBinPath returns the path to the coder binary suitable for use in ssh
// ProxyCommand.
func currentBinPath(w io.Writer) (string, error) {
exePath, err := os.Executable()
if err != nil {
return "", xerrors.Errorf("get executable path: %w", err)
}
binName := filepath.Base(exePath)
// We use safeexec instead of os/exec because os/exec returns paths in
// the current working directory, which we will run into very often when
// looking for our own path.
pathPath, err := safeexec.LookPath(binName)
// On Windows, the coder-cli executable must be in $PATH for both Msys2/Git
// Bash and OpenSSH for Windows (used by Powershell and VS Code) to function
// correctly. Check if the current executable is in $PATH, and warn the user
// if it isn't.
if err != nil && runtime.GOOS == "windows" {
cliui.Warn(w,
"The current executable is not in $PATH.",
"This may lead to problems connecting to your workspace via SSH.",
fmt.Sprintf("Please move %q to a location in your $PATH (such as System32) and run `%s config-ssh` again.", binName, binName),
)
_, _ = fmt.Fprint(w, "\n")
// Return the exePath so SSH at least works outside of Msys2.
return exePath, nil
}
// Warn the user if the current executable is not the same as the one in
// $PATH.
if filepath.Clean(pathPath) != filepath.Clean(exePath) {
cliui.Warn(w,
"The current executable path does not match the executable path found in $PATH.",
"This may cause issues connecting to your workspace via SSH.",
fmt.Sprintf("\tCurrent executable path: %q", exePath),
fmt.Sprintf("\tExecutable path in $PATH: %q", pathPath),
)
_, _ = fmt.Fprint(w, "\n")
}
return exePath, nil
}
// diffBytes takes two byte slices and diffs them as if they were in a
// file named name.
// nolint: revive // Color is an option, not a control coupling.
func diffBytes(name string, b1, b2 []byte, color bool) ([]byte, error) {
var buf bytes.Buffer
var opts []write.Option
if color {
opts = append(opts, write.TerminalColor())
}
err := diff.Text(name, name, b1, b2, &buf, opts...)
if err != nil {
return nil, err
}
b := buf.Bytes()
// Check if diff only output two lines, if yes, there's no diff.
//
// Example:
// --- /home/user/.ssh/config
// +++ /home/user/.ssh/config
if bytes.Count(b, []byte{'\n'}) == 2 {
b = nil
}
return b, nil
}