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.
1024 lines
33 KiB
Go
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
|
|
}
|