Commit Graph
16 Commits
Author SHA1 Message Date
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
Bobby Ho dcb120d6ab 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)
2026-06-30 13:02:15 -07:00
Cian JohnstonandCopilot Autofix powered by AI a26c46a3bf fix!: validate HostnameSuffix and SSHConfigOptions' (#26154)
- Adds server-side and client-side validation for
CODER_CONFIGSSH_HOSTNAME_SUFFIX and CODER_SSH_CONFIG_OPTIONS.
- **Server-side breaking change:** invalid values for either of these will cause `coderd` to exit with an error.
- Client-side: `coder config-ssh` will exit with an error if it detects invalid config.
- Adds tests for the above

Local smoke-testing: ran `develop.sh --env-file <path to an env file
containing badness>`. Validated that server startup failed as expected.

> 🤖 Generated by Coder Agents with supervision from a human.

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-06-10 15:48:02 +01:00
Thomas KosiewskiandClaude Opus 4.8 f6a4ed309f ci: fix Windows runner PATH casing for mise, not in cli (#25972)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 10:46:40 +00:00
Mathias Fredriksson ed4311b2cb ci: add Git usr/bin to PATH on Windows (#25939)
## Summary

Fixes all 9 Windows CI test failures caused by the mise CI refactor
(`fe257666d7`, PR #25727).

### Root cause

`jdx/mise-action` exports `Path` (Windows convention) via `GITHUB_ENV`.
Bash on Windows maintains its own `PATH`. When Go's `os.Environ()`
returns both, `cmd.exe` subprocesses non-deterministically pick the
MSYS-translated `PATH` (forward slashes), causing Windows executables
(`printf`, `powershell.exe`, `cmd.exe`) to be unresolvable.

These failures only appeared on `main` (where `-count=1` forces real
test execution) and were masked on PRs by Go test cache.

### Fixes applied

**CI (`setup-mise` action)**:
- Write both `Path` and `PATH` to `GITHUB_ENV` with Git usr/bin
prepended

**Code (`cli/root.go`)**:
- Add `appendAndDedupEnv` helper that deduplicates case-insensitive env
vars on Windows, preferring native Windows paths (backslashes) over MSYS
paths

**Code (`cli/configssh_windows.go`)**:
- Use absolute paths for `powershell.exe` and `cmd.exe` in the SSH
config `Match exec` escape function, avoiding PATH resolution entirely

**Tests**:
- Switch `--header-command` tests from `printf` to `echo` (cmd.exe
builtin) for reliable cross-platform execution
- Add env dedup in `Test_sshConfigMatchExecEscape` for subprocess PATH
consistency

Fixes coder/internal#1556, coder/internal#1558, coder/internal#1559

> 🤖 Generated by Coder agent, will be reviewed by @mafredri. 🏂🏻
2026-06-02 11:51:16 +10:00
Steven Masley 0f505aa4da chore: unhide flag to force unix filepaths in config-ssh (#25142)
Docs now include this flag. This flag is now also viewable in linux/mac
despite it effectively being a `no-op`.

Closes https://github.com/coder/coder/issues/24205
2026-05-13 14:59:33 -05:00
Mathias Fredriksson 147df5c971 refactor: replace sort.Strings with slices.Sort (#23457)
The slices package provides type-safe generic replacements for the
old typed sort convenience functions. The codebase already uses
slices.Sort in 43 call sites; this finishes the migration for the
remaining 29.

- sort.Strings(x)          -> slices.Sort(x)
- sort.Float64s(x)         -> slices.Sort(x)
- sort.StringsAreSorted(x) -> slices.IsSorted(x)
2026-03-23 23:19:23 +02:00
Spike Curtis 988ee39b02 test: stop running Test_sshConfigProxyCommandEscape in parallel (#20009)
Fixes https://github.com/coder/internal/issues/1035

Or, at least, closes a remaining race that seems pretty likely.

The tests in question write a file, close the file, then execute the file. Sometimes Linux errors saying "text file busy" which means the file is still open for writing.

What I think is going on is:

1. Test_sshConfigProxyCommandEscape goroutine opens the file and begins writing.
2. Some other, unrelated test execs a command, which causes a `fork()` syscall. The child process now has a copy of the file descriptor to our open file.
3. Test_sshConfigProxyCommandEscape goroutine executes the file and gets "text file busy".
4. The child process calls the `exec` syscall, which closes the file (due to `CLOEXEC` being set).

The race is very tight because 3 has to happen before 4 (and, 3 involves it's own fork/exec), but it's not impossible on a busy system.

c.f. #14233 which was an earlier attempt to fix this. It only prevented the subtests from running in parallel. When the subtests were all running in parallel, the flake was fairly likely because you've got all this fork() activity happening at the same time. But, since the main test was in parallel there is still a chance a totally different test is `fork`'ing at in inopportune time.
2025-09-29 17:01:54 +04:00
ケイラ fae30a00fd chore: remove unnecessary redeclarations in for loops (#18440) 2025-06-20 13:16:55 -06:00
Spike Curtis d47a53da82 fix: handle paths with spaces in Match exec clause of SSH config (#18266)
fixes #18199 

Corrects handling of paths with spaces in the `Match !exec` clause we
use to determine whether Coder Connect is running. This is handled
differently than the ProxyCommand, so we have a different escape
routine, which also varies by OS.

On Windows, we resort to a pretty gnarly hack, but it does work and I
feel the only other option would be to reduce functionality such that we
could not detect the Coder Connect state.
2025-06-06 16:44:25 +04:00
Ethan 73402fc2f7 fix: fix flaking Test_sshConfigExecEscape (#14233)
Fixes #13962.
2024-08-12 18:56:34 +10:00
Mathias Fredriksson 978364bd3e fix(cli): do not overwrite repeated SSH options in config-ssh (#13819)
Fixes #11593
2024-07-09 09:44:56 +03:00
Steven Masley 797e91d4c6 feat: add flag for Windows to create unix compatible filepaths (#8164)
* feat: add flag for Windows to create unix compatible filepaths
2023-06-22 17:08:12 -05:00
Steven Masley fe247c86eb feat: Add deployment side config-ssh options (#6613)
* feat: Allow setting deployment wide ssh config settings
* feat: config-ssh respects deployment ssh config
* The '.' is now configurable
* Move buildinfo into deployment.go
2023-03-16 13:03:37 -05:00
Steven Masley 2384e9c565 fix: Do not try and write to malformed ssh configs (#6000)
Abort writing the ssh config if it has:
- Out of order coder headers
- More than 1 of each coder header
- Missing 1 or the other coder header
2023-02-02 19:23:42 -06:00
Mathias Fredriksson 1dc0485027 fix: Use smarter quoting for ProxyCommand in config-ssh (#3755)
* fix: Use smarter quoting for ProxyCommand in config-ssh

This change takes better into account how OpenSSH executes
`ProxyCommand`s and applies quoting accordingly.

This supercedes #3664, which was reverted.

Fixes #2853

* fix: Ensure `~/.ssh` directory exists
2022-08-30 21:08:20 +03:00