mirror of
https://github.com/coder/coder.git
synced 2026-09-21 20:51:01 +08:00
## 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.
757 lines
20 KiB
Go
757 lines
20 KiB
Go
package cli
|
|
|
|
import (
|
|
"bytes"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"runtime"
|
|
"slices"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
|
|
"github.com/coder/coder/v2/codersdk"
|
|
)
|
|
|
|
func Test_sshConfigSplitOnCoderSection(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
testCases := []struct {
|
|
Name string
|
|
Input string
|
|
Before string
|
|
Section string
|
|
After string
|
|
Err bool
|
|
}{
|
|
{
|
|
Name: "Empty",
|
|
Input: "",
|
|
Before: "",
|
|
Section: "",
|
|
After: "",
|
|
Err: false,
|
|
},
|
|
{
|
|
Name: "JustSection",
|
|
Input: strings.Join([]string{sshStartToken, sshEndToken}, "\n"),
|
|
Before: "",
|
|
Section: strings.Join([]string{sshStartToken, sshEndToken}, "\n"),
|
|
After: "",
|
|
Err: false,
|
|
},
|
|
{
|
|
Name: "NoSection",
|
|
Input: strings.Join([]string{"# Some content"}, "\n"),
|
|
Before: "# Some content",
|
|
Section: "",
|
|
After: "",
|
|
Err: false,
|
|
},
|
|
{
|
|
Name: "Normal",
|
|
Input: strings.Join([]string{
|
|
"# Content before the section",
|
|
sshStartToken,
|
|
sshEndToken,
|
|
"# Content after the section",
|
|
}, "\n"),
|
|
Before: "# Content before the section",
|
|
Section: strings.Join([]string{"", sshStartToken, sshEndToken, ""}, "\n"),
|
|
After: "# Content after the section",
|
|
Err: false,
|
|
},
|
|
{
|
|
Name: "OutOfOrder",
|
|
Input: strings.Join([]string{
|
|
"# Content before the section",
|
|
sshEndToken,
|
|
sshStartToken,
|
|
"# Content after the section",
|
|
}, "\n"),
|
|
Err: true,
|
|
},
|
|
{
|
|
Name: "MissingStart",
|
|
Input: strings.Join([]string{
|
|
"# Content before the section",
|
|
sshEndToken,
|
|
"# Content after the section",
|
|
}, "\n"),
|
|
Err: true,
|
|
},
|
|
{
|
|
Name: "MissingEnd",
|
|
Input: strings.Join([]string{
|
|
"# Content before the section",
|
|
sshEndToken,
|
|
"# Content after the section",
|
|
}, "\n"),
|
|
Err: true,
|
|
},
|
|
{
|
|
Name: "ExtraStart",
|
|
Input: strings.Join([]string{
|
|
"# Content before the section",
|
|
sshStartToken,
|
|
sshEndToken,
|
|
sshStartToken,
|
|
"# Content after the section",
|
|
}, "\n"),
|
|
Err: true,
|
|
},
|
|
{
|
|
Name: "ExtraEnd",
|
|
Input: strings.Join([]string{
|
|
"# Content before the section",
|
|
sshStartToken,
|
|
sshEndToken,
|
|
sshEndToken,
|
|
"# Content after the section",
|
|
}, "\n"),
|
|
Err: true,
|
|
},
|
|
}
|
|
|
|
for _, tc := range testCases {
|
|
t.Run(tc.Name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
before, section, after, err := sshConfigSplitOnCoderSection([]byte(tc.Input))
|
|
if tc.Err {
|
|
require.Error(t, err)
|
|
return
|
|
}
|
|
|
|
require.NoError(t, err)
|
|
require.Equal(t, tc.Before, string(before), "before")
|
|
require.Equal(t, tc.Section, string(section), "section")
|
|
require.Equal(t, tc.After, string(after), "after")
|
|
})
|
|
}
|
|
}
|
|
|
|
// This test tries to mimic the behavior of OpenSSH when executing e.g. a ProxyCommand.
|
|
// nolint:paralleltest
|
|
func Test_sshConfigProxyCommandEscape(t *testing.T) {
|
|
// Don't run this test, or any of its subtests in parallel. The test works by writing a file and then immediately
|
|
// executing it. Other tests might also exec a subprocess, and if they do in parallel, there is a small race
|
|
// condition where our file is open when they fork, and remains open while we attempt to execute it, causing
|
|
// a "text file busy" error.
|
|
|
|
tests := []struct {
|
|
name string
|
|
path string
|
|
wantErr bool
|
|
}{
|
|
{"windows path", `C:\Program Files\Coder\bin\coder.exe`, false},
|
|
{"no spaces", "simple", false},
|
|
{"spaces", "path with spaces", false},
|
|
{"quotes", "path with \"quotes\"", false},
|
|
{"backslashes", "path with \\backslashes", false},
|
|
{"tabs", "path with \ttabs", false},
|
|
{"newline fails", "path with \nnewline", true},
|
|
}
|
|
// nolint:paralleltest // Fixes a flake
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
if runtime.GOOS == "windows" {
|
|
t.Skip("Windows doesn't typically execute via /bin/sh or cmd.exe, so this test is not applicable.")
|
|
}
|
|
|
|
dir := filepath.Join(t.TempDir(), tt.path)
|
|
err := os.MkdirAll(dir, 0o755)
|
|
require.NoError(t, err)
|
|
bin := filepath.Join(dir, "coder")
|
|
contents := []byte("#!/bin/sh\necho yay\n")
|
|
err = os.WriteFile(bin, contents, 0o755) //nolint:gosec
|
|
require.NoError(t, err)
|
|
|
|
escaped, err := sshConfigProxyCommandEscape(bin, false)
|
|
if tt.wantErr {
|
|
require.Error(t, err)
|
|
return
|
|
}
|
|
require.NoError(t, err)
|
|
|
|
b, err := exec.Command("/bin/sh", "-c", escaped).CombinedOutput() //nolint:gosec
|
|
require.NoError(t, err)
|
|
got := strings.TrimSpace(string(b))
|
|
require.Equal(t, "yay", got)
|
|
})
|
|
}
|
|
}
|
|
|
|
// This test tries to mimic the behavior of OpenSSH
|
|
// when executing e.g. a match exec command.
|
|
// nolint:tparallel
|
|
func Test_sshConfigMatchExecEscape(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
tests := []struct {
|
|
name string
|
|
path string
|
|
wantErrOther bool
|
|
wantErrWindows bool
|
|
}{
|
|
{"no spaces", "simple", false, false},
|
|
{"spaces", "path with spaces", false, false},
|
|
{"quotes", "path with \"quotes\"", true, true},
|
|
{"backslashes", "path with\\backslashes", false, false},
|
|
{"tabs", "path with \ttabs", false, true},
|
|
{"newline fails", "path with \nnewline", true, true},
|
|
}
|
|
// nolint:paralleltest // Fixes a flake
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
cmd := "/bin/sh"
|
|
arg := "-c"
|
|
contents := []byte("#!/bin/sh\necho yay\n")
|
|
if runtime.GOOS == "windows" {
|
|
cmd = "cmd.exe"
|
|
arg = "/c"
|
|
contents = []byte("@echo yay\n")
|
|
}
|
|
|
|
dir := filepath.Join(t.TempDir(), tt.path)
|
|
bin := filepath.Join(dir, "coder.bat") // Windows will treat it as batch, Linux doesn't care
|
|
escaped, err := sshConfigMatchExecEscape(bin)
|
|
if (runtime.GOOS == "windows" && tt.wantErrWindows) || (runtime.GOOS != "windows" && tt.wantErrOther) {
|
|
require.Error(t, err)
|
|
return
|
|
}
|
|
require.NoError(t, err)
|
|
|
|
err = os.MkdirAll(dir, 0o755)
|
|
require.NoError(t, err)
|
|
|
|
err = os.WriteFile(bin, contents, 0o755) //nolint:gosec
|
|
require.NoError(t, err)
|
|
|
|
// OpenSSH processes %% escape sequences into %
|
|
escaped = strings.ReplaceAll(escaped, "%%", "%")
|
|
b, err := exec.Command(cmd, arg, escaped).CombinedOutput() //nolint:gosec
|
|
require.NoError(t, err)
|
|
got := strings.TrimSpace(string(b))
|
|
require.Equal(t, "yay", got)
|
|
})
|
|
}
|
|
}
|
|
|
|
func Test_sshConfigExecEscapeSeparatorForce(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
tests := []struct {
|
|
name string
|
|
path string
|
|
// Behavior is different on Windows
|
|
expWindowsPath string
|
|
expOtherPath string
|
|
forceUnix bool
|
|
wantErr bool
|
|
}{
|
|
{
|
|
name: "windows_keep_forward_slashes_with_spaces",
|
|
// Has a space, expect quotes
|
|
path: `C:\Program Files\Coder\bin\coder.exe`,
|
|
expWindowsPath: `"C:\Program Files\Coder\bin\coder.exe"`,
|
|
expOtherPath: `"C:\Program Files\Coder\bin\coder.exe"`,
|
|
forceUnix: false,
|
|
wantErr: false,
|
|
},
|
|
{
|
|
name: "windows_keep_forward_slashes",
|
|
path: `C:\ProgramFiles\Coder\bin\coder.exe`,
|
|
expWindowsPath: `C:\ProgramFiles\Coder\bin\coder.exe`,
|
|
expOtherPath: `C:\ProgramFiles\Coder\bin\coder.exe`,
|
|
forceUnix: false,
|
|
wantErr: false,
|
|
},
|
|
{
|
|
name: "windows_force_unix_with_spaces",
|
|
path: `C:\Program Files\Coder\bin\coder.exe`,
|
|
expWindowsPath: `"C:/Program Files/Coder/bin/coder.exe"`,
|
|
expOtherPath: `"C:\Program Files\Coder\bin\coder.exe"`,
|
|
forceUnix: true,
|
|
wantErr: false,
|
|
},
|
|
{
|
|
name: "windows_force_unix",
|
|
path: `C:\ProgramFiles\Coder\bin\coder.exe`,
|
|
expWindowsPath: `C:/ProgramFiles/Coder/bin/coder.exe`,
|
|
expOtherPath: `C:\ProgramFiles\Coder\bin\coder.exe`,
|
|
forceUnix: true,
|
|
wantErr: false,
|
|
},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
found, err := sshConfigProxyCommandEscape(tt.path, tt.forceUnix)
|
|
if tt.wantErr {
|
|
require.Error(t, err)
|
|
return
|
|
}
|
|
require.NoError(t, err)
|
|
if runtime.GOOS == "windows" {
|
|
require.Equal(t, tt.expWindowsPath, found, "(Windows) expected path")
|
|
} else {
|
|
// this is a noop on non-windows!
|
|
require.Equal(t, tt.expOtherPath, found, "(Non-Windows) expected path")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func Test_mergeSSHOptions_RejectsUnsafeServerConfig(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
testCases := []struct {
|
|
name string
|
|
coderd codersdk.SSHConfigResponse
|
|
wantErr string
|
|
}{
|
|
{
|
|
name: "HostnameSuffix",
|
|
coderd: codersdk.SSHConfigResponse{
|
|
HostnameSuffix: "coder\nHost *",
|
|
},
|
|
wantErr: "workspace hostname suffix",
|
|
},
|
|
{
|
|
name: "HostnamePrefix",
|
|
coderd: codersdk.SSHConfigResponse{
|
|
HostnamePrefix: "coder.\nHost *",
|
|
},
|
|
wantErr: "workspace hostname prefix",
|
|
},
|
|
{
|
|
name: "ProxyCommand",
|
|
coderd: codersdk.SSHConfigResponse{
|
|
SSHConfigOptions: map[string]string{"ProxyCommand": "ssh -W %h:%p bastion"},
|
|
},
|
|
wantErr: `ssh config option "ProxyCommand" is not allowed`,
|
|
},
|
|
{
|
|
name: "PermitLocalCommand",
|
|
coderd: codersdk.SSHConfigResponse{
|
|
SSHConfigOptions: map[string]string{"PermitLocalCommand": "yes"},
|
|
},
|
|
wantErr: `ssh config option "PermitLocalCommand" is not allowed`,
|
|
},
|
|
{
|
|
name: "KnownHostsCommand",
|
|
coderd: codersdk.SSHConfigResponse{
|
|
SSHConfigOptions: map[string]string{"KnownHostsCommand": "echo key"},
|
|
},
|
|
wantErr: `ssh config option "KnownHostsCommand" is not allowed`,
|
|
},
|
|
{
|
|
name: "PKCS11Provider",
|
|
coderd: codersdk.SSHConfigResponse{
|
|
SSHConfigOptions: map[string]string{"PKCS11Provider": "/tmp/evil.so"},
|
|
},
|
|
wantErr: `ssh config option "PKCS11Provider" is not allowed`,
|
|
},
|
|
{
|
|
name: "NewlineInValue",
|
|
coderd: codersdk.SSHConfigResponse{
|
|
SSHConfigOptions: map[string]string{"UserKnownHostsFile": "/tmp/known_hosts\nHost *"},
|
|
},
|
|
wantErr: `ssh config option "UserKnownHostsFile" must not contain carriage return, newline, or NUL characters`,
|
|
},
|
|
{
|
|
name: "SmartcardDevice",
|
|
coderd: codersdk.SSHConfigResponse{
|
|
SSHConfigOptions: map[string]string{"SmartcardDevice": "/path/to/lib"},
|
|
},
|
|
wantErr: `not allowed`,
|
|
},
|
|
{
|
|
name: "XAuthLocation",
|
|
coderd: codersdk.SSHConfigResponse{
|
|
SSHConfigOptions: map[string]string{"XAuthLocation": "/usr/bin/xauth"},
|
|
},
|
|
wantErr: `not allowed`,
|
|
},
|
|
{
|
|
name: "ProxyJump",
|
|
coderd: codersdk.SSHConfigResponse{
|
|
SSHConfigOptions: map[string]string{"ProxyJump": "bastion.example.com"},
|
|
},
|
|
wantErr: `conflicts with`,
|
|
},
|
|
{
|
|
name: "HostnameSuffixGlob",
|
|
coderd: codersdk.SSHConfigResponse{
|
|
HostnameSuffix: "*",
|
|
},
|
|
wantErr: `glob`,
|
|
},
|
|
}
|
|
|
|
for _, tt := range testCases {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
_, err := mergeSSHOptions(sshConfigOptions{}, tt.coderd, t.TempDir(), "/tmp/coder")
|
|
require.ErrorContains(t, err, tt.wantErr)
|
|
})
|
|
}
|
|
}
|
|
|
|
func Test_mergeSSHOptions_UserOptionsOverrideServerConfig(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
user := sshConfigOptions{
|
|
userHostPrefix: "dev.",
|
|
hostnameSuffix: "local",
|
|
userHostPrefixExplicit: true,
|
|
hostnameSuffixExplicit: true,
|
|
}
|
|
got, err := mergeSSHOptions(user, codersdk.SSHConfigResponse{
|
|
HostnamePrefix: "coder.",
|
|
HostnameSuffix: "coder",
|
|
}, t.TempDir(), "/tmp/coder")
|
|
require.NoError(t, err)
|
|
require.Equal(t, "dev.", got.userHostPrefix)
|
|
require.Equal(t, "local", got.hostnameSuffix)
|
|
}
|
|
|
|
func Test_mergeSSHOptions_ExplicitEmptyNotOverridden(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
testCases := []struct {
|
|
name string
|
|
userHostPrefixSet bool
|
|
hostnameSuffixSet bool
|
|
wantUserHostPrefix string
|
|
wantHostnameSuffix string
|
|
}{
|
|
{
|
|
name: "PrefixExplicitlyEmpty",
|
|
userHostPrefixSet: true,
|
|
hostnameSuffixSet: false,
|
|
wantUserHostPrefix: "",
|
|
wantHostnameSuffix: "coder",
|
|
},
|
|
{
|
|
name: "SuffixExplicitlyEmpty",
|
|
userHostPrefixSet: false,
|
|
hostnameSuffixSet: true,
|
|
wantUserHostPrefix: "coder.",
|
|
wantHostnameSuffix: "",
|
|
},
|
|
{
|
|
name: "BothExplicitlyEmpty",
|
|
userHostPrefixSet: true,
|
|
hostnameSuffixSet: true,
|
|
wantUserHostPrefix: "",
|
|
wantHostnameSuffix: "",
|
|
},
|
|
{
|
|
name: "NeitherSet",
|
|
userHostPrefixSet: false,
|
|
hostnameSuffixSet: false,
|
|
wantUserHostPrefix: "coder.",
|
|
wantHostnameSuffix: "coder",
|
|
},
|
|
}
|
|
|
|
for _, tt := range testCases {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
user := sshConfigOptions{
|
|
userHostPrefixExplicit: tt.userHostPrefixSet,
|
|
hostnameSuffixExplicit: tt.hostnameSuffixSet,
|
|
}
|
|
got, err := mergeSSHOptions(user, codersdk.SSHConfigResponse{
|
|
HostnamePrefix: "coder.",
|
|
HostnameSuffix: "coder",
|
|
}, t.TempDir(), "/tmp/coder")
|
|
require.NoError(t, err)
|
|
require.Equal(t, tt.wantUserHostPrefix, got.userHostPrefix)
|
|
require.Equal(t, tt.wantHostnameSuffix, got.hostnameSuffix)
|
|
})
|
|
}
|
|
}
|
|
|
|
func Test_mergeSSHOptions_AllowsSafeServerConfig(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
got, err := mergeSSHOptions(sshConfigOptions{}, codersdk.SSHConfigResponse{
|
|
HostnamePrefix: "coder.",
|
|
HostnameSuffix: "coder",
|
|
SSHConfigOptions: map[string]string{
|
|
"HostName": "example.com",
|
|
"User": "coder",
|
|
"Port": "22",
|
|
"SetEnv": "FOO=bar BAZ=qux",
|
|
"UserKnownHostsFile": "/tmp/coder_known_hosts",
|
|
},
|
|
}, t.TempDir(), "/tmp/coder")
|
|
require.NoError(t, err)
|
|
require.Equal(t, "coder.", got.userHostPrefix)
|
|
require.Equal(t, "coder", got.hostnameSuffix)
|
|
require.Contains(t, got.sshOptions, "HostName example.com")
|
|
require.Contains(t, got.sshOptions, "SetEnv FOO=bar BAZ=qux")
|
|
}
|
|
|
|
func Test_sshConfigOptions_addOption(t *testing.T) {
|
|
t.Parallel()
|
|
testCases := []struct {
|
|
Name string
|
|
Start []string
|
|
Add []string
|
|
Expect []string
|
|
ExpectError bool
|
|
}{
|
|
{
|
|
Name: "Empty",
|
|
},
|
|
{
|
|
Name: "AddOne",
|
|
Add: []string{"foo bar"},
|
|
Expect: []string{
|
|
"foo bar",
|
|
},
|
|
},
|
|
{
|
|
Name: "AddTwo",
|
|
Start: []string{
|
|
"foo bar",
|
|
},
|
|
Add: []string{"Foo baz"},
|
|
Expect: []string{
|
|
"foo bar",
|
|
"Foo baz",
|
|
},
|
|
},
|
|
{
|
|
Name: "AddAndRemove",
|
|
Start: []string{
|
|
"foo bar",
|
|
"buzz bazz",
|
|
},
|
|
Add: []string{
|
|
"b c",
|
|
"a ", // Empty value, means remove all following entries that start with "a", i.e. next line.
|
|
"A hello",
|
|
"hello world",
|
|
},
|
|
Expect: []string{
|
|
"foo bar",
|
|
"buzz bazz",
|
|
"b c",
|
|
"hello world",
|
|
},
|
|
},
|
|
{
|
|
Name: "Error",
|
|
Add: []string{"novalue"},
|
|
ExpectError: true,
|
|
},
|
|
}
|
|
|
|
for _, tt := range testCases {
|
|
t.Run(tt.Name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
o := sshConfigOptions{
|
|
sshOptions: tt.Start,
|
|
}
|
|
err := o.addOptions(tt.Add...)
|
|
if tt.ExpectError {
|
|
require.Error(t, err)
|
|
return
|
|
}
|
|
require.NoError(t, err)
|
|
slices.Sort(tt.Expect)
|
|
slices.Sort(o.sshOptions)
|
|
require.Equal(t, tt.Expect, o.sshOptions)
|
|
})
|
|
}
|
|
}
|
|
|
|
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")
|
|
}
|
|
})
|
|
}
|
|
}
|