Ctrl K
ComunidadGuías…

cli/configssh.go

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
package cli

import (
	"bufio"
	"bytes"
	"context"
	"errors"
	"fmt"
	"io"
	"io/fs"
	"os"
	"path/filepath"
	"regexp"
	"runtime"
	"slices"
	"strconv"
	"strings"
	"sync"

	"github.com/cli/safeexec"
	"github.com/google/uuid"
	"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
	// hostNames holds the names to write individual host entries for when
	// noWildcard is true. It is not persisted to the SSH config header.
	hostNames []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, host := range o.hostNames {
				if i > 0 {
					_, _ = buf.WriteString("\n")
				}
				_, _ = fmt.Fprintf(buf, "Host %s%s\n", o.userHostPrefix, host)
				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 _, host := range o.hostNames {
			hostname := host + "." + 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 {
				return xerrors.Errorf("fetch coderd config failed: %w", err)
			}

			configOptions, err := mergeSSHOptions(sshConfigOpts, coderdConfig, string(root), coderBinary)
			if err != nil {
				return err
			}

			var workspaceResources map[uuid.UUID][]codersdk.WorkspaceResource
			var workspaces []codersdk.Workspace
			if configOptions.noWildcard {
				// Fetch all workspaces to generate individual host entries.
				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)
					}
					workspaces = append(workspaces, res.Workspaces...)
					if len(res.Workspaces) < pageSize {
						break
					}
					offset += pageSize
				}
				workspaceResources = workspaceAgentResources(ctx, inv.Stderr, client, workspaces)
				configOptions.hostNames = noWildcardHostNames(workspaces, workspaceResources)
			}

			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)
			}

			if !configOptions.noWildcard {
				res, err := client.Workspaces(ctx, codersdk.WorkspaceFilter{
					Owner: codersdk.Me,
					Limit: 1,
				})
				if err != nil {
					return xerrors.Errorf("fetch workspaces failed: %w", err)
				}
				workspaces = res.Workspaces
			}

			if len(workspaces) > 0 {
				_, _ = fmt.Fprintln(out, "You should now be able to ssh into your workspace.")
				if configOptions.hostnameSuffix != "" || configOptions.userHostPrefix != "" {
					ws := workspaces[0]
					host := ws.Name
					agents := startAgentNames(ws.LatestBuild.Resources)
					if len(agents) == 0 {
						// Reuse the resources fetched for --no-wildcard, including
						// failed fetches, so the hint does not retry them.
						if configOptions.noWildcard {
							agents = startAgentNames(workspaceResources[ws.ID])
						} else if resources, err := previousStartResources(ctx, client, ws); err == nil {
							agents = startAgentNames(resources)
						}
					}
					if len(agents) > 1 {
						// A bare workspace hostname is ambiguous with multiple
						// agents, so name the agent explicitly.
						host = agents[0] + "." + ws.Name + "." + ws.OwnerName
					}
					if configOptions.hostnameSuffix != "" {
						host += "." + configOptions.hostnameSuffix
					} else {
						host = configOptions.userHostPrefix + host
					}
					_, _ = fmt.Fprintf(out, "For example, try running:\n\n\t$ ssh %s\n", host)
				}
			} 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 and its known agents 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
}

// noWildcardHostNames returns the names that --no-wildcard writes a host entry
// for: each workspace name, followed by an <agent>.<workspace>.<owner> name for
// each of its agents. The agent names match the hostnames shown in the
// dashboard and served by Coder Connect. While a workspace runs, its latest
// build also lists devcontainer sub-agents, so they get entries too. A
// workspace whose latest build has no agents, such as a stopped one, uses the
// resources of its most recent successful start build instead. Agent entries
// are only written when every name in them is valid, so a server-supplied value
// can never be written into ssh_config as syntax.
func noWildcardHostNames(workspaces []codersdk.Workspace, workspaceResources map[uuid.UUID][]codersdk.WorkspaceResource) []string {
	var names []string
	for _, ws := range workspaces {
		names = append(names, ws.Name)
		if codersdk.NameValid(ws.Name) != nil || codersdk.NameValid(ws.OwnerName) != nil {
			continue
		}
		agents := startAgentNames(ws.LatestBuild.Resources)
		if len(agents) == 0 {
			agents = startAgentNames(workspaceResources[ws.ID])
		}
		for _, agent := range agents {
			names = append(names, agent+"."+ws.Name+"."+ws.OwnerName)
		}
	}
	return names
}

// Precreated devcontainer agents retain Terraform labels, including underscores.
// Hostname components must exclude SSH syntax and doubled-hyphen delimiters.
var sshAgentNameRegex = regexp.MustCompile(`^[a-zA-Z0-9_][a-zA-Z0-9_-]*$`)

// startAgentNames returns sorted, SSH-config-safe agent names from start resources.
func startAgentNames(resources []codersdk.WorkspaceResource) []string {
	var names []string
	for _, resource := range resources {
		if resource.Transition != codersdk.WorkspaceTransitionStart {
			continue
		}
		for _, agent := range resource.Agents {
			if sshAgentNameRegex.MatchString(agent.Name) && !strings.Contains(agent.Name, "--") {
				names = append(names, agent.Name)
			}
		}
	}
	// Agents come back in database order.
	slices.Sort(names)
	return names
}

// workspaceAgentResources fetches each agentless workspace's last start resources.
// Import resources cannot account for the workspace's chosen parameters.
// Failed lookups leave the workspace's bare host entry available.
func workspaceAgentResources(ctx context.Context, stderr io.Writer, client *codersdk.Client, workspaces []codersdk.Workspace) map[uuid.UUID][]codersdk.WorkspaceResource {
	var (
		mu        sync.Mutex
		resources = make(map[uuid.UUID][]codersdk.WorkspaceResource)
		wg        sync.WaitGroup
		limit     = make(chan struct{}, 8)
	)
	for _, ws := range workspaces {
		if len(startAgentNames(ws.LatestBuild.Resources)) != 0 {
			continue
		}
		limit <- struct{}{}
		wg.Go(func() {
			defer func() { <-limit }()
			res, err := previousStartResources(ctx, client, ws)
			mu.Lock()
			defer mu.Unlock()
			if err != nil {
				cliui.Warnf(stderr, "Skipping agent host entries for %s: fetch previous start resources: %v", ws.Name, err)
				return
			}
			resources[ws.ID] = res
		})
	}
	wg.Wait()
	return resources
}

// previousStartResources returns the latest successful start's resources, provided
// it used the workspace's current template version. Older versions and template
// imports cannot reliably describe the agents that will exist when it restarts.
func previousStartResources(ctx context.Context, client *codersdk.Client, ws codersdk.Workspace) ([]codersdk.WorkspaceResource, error) {
	const pageSize = 20
	for offset := 0; ; offset += pageSize {
		builds, err := client.WorkspaceBuilds(ctx, codersdk.WorkspaceBuildsRequest{
			WorkspaceID: ws.ID,
			Pagination:  codersdk.Pagination{Offset: offset, Limit: pageSize},
		})
		if err != nil {
			return nil, xerrors.Errorf("fetch workspace build history: %w", err)
		}
		for _, build := range builds {
			// Ignore builds created after the workspace snapshot used for the config.
			if build.BuildNumber > ws.LatestBuild.BuildNumber || build.Transition != codersdk.WorkspaceTransitionStart || build.Job.Status != codersdk.ProvisionerJobSucceeded {
				continue
			}
			if build.TemplateVersionID != ws.LatestBuild.TemplateVersionID {
				return nil, nil
			}
			return build.Resources, nil
		}
		if len(builds) < pageSize {
			return nil, nil
		}
	}
}