Ctrl K
ComunidadGuías…

cli/configssh_test.go

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

Archivo original ↗Preguntar a KiBot
package cli_test

import (
	"bytes"
	"context"
	"fmt"
	"io"
	"net"
	"net/http"
	"os"
	"os/exec"
	"path/filepath"
	"runtime"
	"strconv"
	"strings"
	"sync"
	"sync/atomic"
	"testing"

	"github.com/google/go-cmp/cmp"
	"github.com/google/uuid"
	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"

	"github.com/coder/coder/v2/agent/agenttest"
	"github.com/coder/coder/v2/cli/clitest"
	"github.com/coder/coder/v2/coderd/coderdtest"
	"github.com/coder/coder/v2/coderd/database"
	"github.com/coder/coder/v2/coderd/database/dbfake"
	"github.com/coder/coder/v2/codersdk"
	"github.com/coder/coder/v2/codersdk/workspacesdk"
	sdkproto "github.com/coder/coder/v2/provisionersdk/proto"
	"github.com/coder/coder/v2/testutil"
	"github.com/coder/coder/v2/testutil/expecter"
)

func sshConfigFileName(t *testing.T) (sshConfig string) {
	t.Helper()
	tmpdir := t.TempDir()
	dotssh := filepath.Join(tmpdir, ".ssh")
	err := os.Mkdir(dotssh, 0o700)
	require.NoError(t, err)
	n := filepath.Join(dotssh, "config")
	return n
}

func sshConfigFileCreate(t *testing.T, name string, data io.Reader) {
	t.Helper()
	t.Logf("Writing %s", name)
	f, err := os.Create(name)
	require.NoError(t, err)
	n, err := io.Copy(f, data)
	t.Logf("Wrote %d", n)
	require.NoError(t, err)
	err = f.Close()
	require.NoError(t, err)
}

func sshConfigFileRead(t *testing.T, name string) string {
	t.Helper()
	b, err := os.ReadFile(name)
	require.NoError(t, err)
	return string(b)
}

func TestConfigSSH(t *testing.T) {
	t.Parallel()

	if runtime.GOOS == "windows" {
		t.Skip("See coder/internal#117")
	}

	logger := testutil.Logger(t)
	const hostname = "test-coder."
	const expectedKey = "ConnectionAttempts"
	const removeKey = "ConnectTimeout"
	client, db := coderdtest.NewWithDatabase(t, &coderdtest.Options{
		ConfigSSH: codersdk.SSHConfigResponse{
			HostnamePrefix: hostname,
			SSHConfigOptions: map[string]string{
				// Something we can test for
				expectedKey: "3",
				removeKey:   "",
			},
		},
	})
	owner := coderdtest.CreateFirstUser(t, client)
	member, memberUser := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID)
	r := dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{
		OrganizationID: owner.OrganizationID,
		OwnerID:        memberUser.ID,
	}).WithAgent().Do()
	_ = agenttest.New(t, client.URL, r.AgentToken)
	resources := coderdtest.AwaitWorkspaceAgents(t, client, r.Workspace.ID)
	agentConn, err := workspacesdk.New(client).
		DialAgent(context.Background(), resources[0].Agents[0].ID, nil)
	require.NoError(t, err)
	defer agentConn.Close()

	listener, err := net.Listen("tcp", "127.0.0.1:0")
	require.NoError(t, err)
	defer func() {
		_ = listener.Close()
	}()
	copyDone := make(chan struct{})
	go func() {
		defer close(copyDone)
		var wg sync.WaitGroup
		for {
			conn, err := listener.Accept()
			if err != nil {
				break
			}
			ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
			ssh, err := agentConn.SSHTCPConn(ctx)
			cancel()
			assert.NoError(t, err)
			wg.Add(2)
			go func() {
				defer wg.Done()
				_, _ = io.Copy(conn, ssh)
			}()
			go func() {
				defer wg.Done()
				_, _ = io.Copy(ssh, conn)
			}()
		}
		wg.Wait()
	}()

	sshConfigFile := sshConfigFileName(t)

	tcpAddr, valid := listener.Addr().(*net.TCPAddr)
	require.True(t, valid)
	inv, root := clitest.New(t, "config-ssh",
		"--ssh-option", "HostName "+tcpAddr.IP.String(),
		"--ssh-option", "Port "+strconv.Itoa(tcpAddr.Port),
		"--ssh-config-file", sshConfigFile,
		"--skip-proxy-command")
	clitest.SetupConfig(t, member, root)
	stdout := expecter.NewAttachedToInvocation(t, inv)
	stdin := testutil.NewWriterAttachedToInvocation(t, logger.Named("stdin"), inv)

	// Created here so setup does not drain it. WaitLong because config-ssh
	// fsyncs the SSH config and fsync ignores the context.
	ctx := testutil.Context(t, testutil.WaitLong)
	waiter := clitest.StartWithWaiter(t, inv.WithContext(ctx))

	matches := []struct {
		match, write string
	}{
		{match: "Continue?", write: "yes"},
	}
	for _, m := range matches {
		stdout.ExpectMatch(ctx, m.match)
		stdin.WriteLine(m.write)
	}

	waiter.RequireSuccess()

	fileContents, err := os.ReadFile(sshConfigFile)
	require.NoError(t, err, "read ssh config file")
	require.Contains(t, string(fileContents), expectedKey, "ssh config file contains expected key")
	require.NotContains(t, string(fileContents), removeKey, "ssh config file should not have removed key")

	home := filepath.Dir(filepath.Dir(sshConfigFile))
	// #nosec
	sshCmd := exec.Command("ssh", "-F", sshConfigFile, hostname+r.Workspace.Name, "echo", "test")
	// Set HOME because coder config is included from ~/.ssh/coder.
	sshCmd.Env = append(sshCmd.Env, fmt.Sprintf("HOME=%s", home))
	data, err := sshCmd.Output()
	require.NoError(t, err)
	require.Equal(t, "test", strings.TrimSpace(string(data)))

	_ = listener.Close()
	<-copyDone
}

func TestConfigSSH_Example(t *testing.T) {
	t.Parallel()

	var resourceRequests sync.Map
	client, db := coderdtest.NewWithDatabase(t, &coderdtest.Options{
		APIMiddleware: func(next http.Handler) http.Handler {
			return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
				isHistory := strings.HasPrefix(r.URL.Path, "/api/v2/workspaces/") && strings.HasSuffix(r.URL.Path, "/builds")
				isImport := strings.HasPrefix(r.URL.Path, "/api/v2/templateversions/") && strings.HasSuffix(r.URL.Path, "/resources")
				if r.Method == http.MethodGet && (isHistory || isImport) {
					counter, _ := resourceRequests.LoadOrStore(r.URL.Path, new(atomic.Int32))
					counter.(*atomic.Int32).Add(1)
				}
				next.ServeHTTP(w, r)
			})
		},
	})
	admin := coderdtest.CreateFirstUser(t, client)

	for _, noWildcard := range []bool{false, true} {
		for _, tc := range []struct {
			stopped bool
			agents  int
		}{{false, 2}, {true, 2}, {true, 1}} {
			t.Run(fmt.Sprintf("NoWildcard=%t/Stopped=%t/Agents=%d", noWildcard, tc.stopped, tc.agents), func(t *testing.T) {
				t.Parallel()

				member, user := coderdtest.CreateAnotherUser(t, client, admin.OrganizationID)
				version := dbfake.TemplateVersion(t, db).Seed(database.TemplateVersion{
					OrganizationID: admin.OrganizationID,
					CreatedBy:      user.ID,
				}).Resources(&sdkproto.Resource{
					Name: "example",
					Type: "aws_instance",
					Agents: []*sdkproto.Agent{
						{Id: uuid.NewString(), Name: "aardvark", Auth: &sdkproto.Agent_Token{Token: uuid.NewString()}},
						{Id: uuid.NewString(), Name: "zebra", Auth: &sdkproto.Agent_Token{Token: uuid.NewString()}},
					},
				}).Do()
				build := dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{
					OrganizationID: admin.OrganizationID,
					OwnerID:        user.ID,
					TemplateID:     version.Template.ID,
					Name:           "ssh-" + uuid.NewString()[:8],
				}).WithAgent(func(agents []*sdkproto.Agent) []*sdkproto.Agent {
					agents[0].Name = "alpha"
					if tc.agents == 1 {
						return agents
					}
					return append(agents, &sdkproto.Agent{
						Id:   uuid.NewString(),
						Name: "beta",
						Auth: &sdkproto.Agent_Token{Token: uuid.NewString()},
						Env:  map[string]string{},
					})
				})
				workspace := build.Do().Workspace
				if tc.stopped {
					dbfake.WorkspaceBuild(t, db, workspace).Seed(database.WorkspaceBuild{
						Transition:  database.WorkspaceTransitionStop,
						BuildNumber: 2,
					}).Do()
				}
				sshConfigPath := sshConfigFileName(t)
				args := []string{"config-ssh", "--ssh-config-file", sshConfigPath, "--hostname-suffix", "coder", "--yes"}
				if noWildcard {
					args = append(args, "--no-wildcard")
				}
				inv, root := clitest.New(t, args...)
				clitest.SetupConfig(t, member, root)
				var stdout bytes.Buffer
				inv.Stdout = &stdout
				ctx := testutil.Context(t, testutil.WaitLong)
				require.NoError(t, inv.WithContext(ctx).Run())
				want := "$ ssh " + workspace.Name + ".coder"
				if tc.agents > 1 {
					want = "$ ssh alpha." + workspace.Name + "." + user.Username + ".coder"
				}
				require.Contains(t, stdout.String(), want)
				if noWildcard {
					require.Contains(t, sshConfigFileRead(t, sshConfigPath), "Host "+strings.TrimPrefix(want, "$ ssh ")+"\n")
				}

				if tc.stopped {
					path := "/api/v2/workspaces/" + workspace.ID.String() + "/builds"
					counter, ok := resourceRequests.Load(path)
					require.True(t, ok, "stopped workspace hint fetches its start-build resources")
					require.EqualValues(t, 1, counter.(*atomic.Int32).Load(), "reuse the resources fetched for host entries")
				}
				_, imported := resourceRequests.Load("/api/v2/templateversions/" + version.TemplateVersion.ID.String() + "/resources")
				require.False(t, imported, "import defaults do not describe this workspace's agents")
			})
		}
	}
}

func TestConfigSSH_RejectsUnsafeServerConfig(t *testing.T) {
	t.Parallel()

	if runtime.GOOS == "windows" {
		t.Skip("See coder/internal#117")
	}

	testCases := []struct {
		name      string
		configSSH codersdk.SSHConfigResponse
		wantErr   string
	}{
		{
			name:      "HostnameSuffix",
			configSSH: codersdk.SSHConfigResponse{HostnameSuffix: "coder\nHost *"},
			wantErr:   "workspace hostname suffix",
		},
		{
			name:      "HostnamePrefix",
			configSSH: codersdk.SSHConfigResponse{HostnamePrefix: "coder.\nHost *"},
			wantErr:   "workspace hostname prefix",
		},
		{
			name:      "HostnameSuffixGlob",
			configSSH: codersdk.SSHConfigResponse{HostnameSuffix: "*"},
			wantErr:   "glob",
		},
	}

	for _, tc := range testCases {
		t.Run(tc.name, func(t *testing.T) {
			t.Parallel()

			const existingConfig = "Host safe\n\tHostName safe.example.com\n"
			client := coderdtest.New(t, &coderdtest.Options{
				ConfigSSH: tc.configSSH,
			})
			_ = coderdtest.CreateFirstUser(t, client)

			sshConfigPath := sshConfigFileName(t)
			sshConfigFileCreate(t, sshConfigPath, strings.NewReader(existingConfig))

			inv, root := clitest.New(t,
				"config-ssh",
				"--ssh-config-file", sshConfigPath,
				"--yes",
			)
			clitest.SetupConfig(t, client, root)

			err := inv.Run()
			require.Error(t, err)
			require.ErrorContains(t, err, tc.wantErr)
			require.Equal(t, existingConfig, sshConfigFileRead(t, sshConfigPath))
		})
	}
}

func TestConfigSSH_MissingDirectory(t *testing.T) {
	t.Parallel()

	if runtime.GOOS == "windows" {
		t.Skip("See coder/internal#117")
	}

	client := coderdtest.New(t, nil)
	_ = coderdtest.CreateFirstUser(t, client)

	// Create a temporary directory but don't create .ssh subdirectory
	tmpdir := t.TempDir()
	sshConfigPath := filepath.Join(tmpdir, ".ssh", "config")

	// Run config-ssh with a non-existent .ssh directory
	args := []string{
		"config-ssh",
		"--ssh-config-file", sshConfigPath,
		"--yes", // Skip confirmation prompts
	}
	inv, root := clitest.New(t, args...)
	clitest.SetupConfig(t, client, root)

	err := inv.Run()
	require.NoError(t, err, "config-ssh should succeed with non-existent directory")

	// Verify that the .ssh directory was created
	sshDir := filepath.Dir(sshConfigPath)
	_, err = os.Stat(sshDir)
	require.NoError(t, err, ".ssh directory should exist")

	// Verify that the config file was created
	_, err = os.Stat(sshConfigPath)
	require.NoError(t, err, "config file should exist")

	// Check that the directory has proper permissions (rwx for owner, none for
	// group and everyone)
	sshDirInfo, err := os.Stat(sshDir)
	require.NoError(t, err)
	require.Equal(t, os.FileMode(0o700), sshDirInfo.Mode().Perm(), "directory should have rwx------ permissions")
}

func TestConfigSSH_FileWriteAndOptionsFlow(t *testing.T) {
	t.Parallel()

	headerStart := strings.Join([]string{
		"# ------------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\".",
		"#",
	}, "\n")
	headerEnd := "# ------------END-CODER------------"
	baseHeader := strings.Join([]string{
		headerStart,
		headerEnd,
	}, "\n")

	type writeConfig struct {
		ssh string
	}
	type wantConfig struct {
		ssh        []string
		notWant    []string
		regexMatch string
	}
	type match struct {
		match, write string
	}
	tests := []struct {
		name        string
		args        []string
		env         map[string]string
		matches     []match
		writeConfig writeConfig
		wantConfig  wantConfig
		wantErr     bool
		hasAgent    bool
	}{
		{
			name: "Config file is created",
			matches: []match{
				{match: "Continue?", write: "yes"},
			},
			wantConfig: wantConfig{
				ssh: []string{
					headerStart,
					headerEnd,
				},
			},
		},
		{
			name: "Section is written after user content",
			writeConfig: writeConfig{
				ssh: strings.Join([]string{
					"Host myhost",
					"	HostName myhost",
				}, "\n"),
			},
			wantConfig: wantConfig{
				ssh: []string{
					strings.Join([]string{
						"Host myhost",
						"	HostName myhost",
					}, "\n"),
					headerStart,
					headerEnd,
				},
			},
			matches: []match{
				{match: "Continue?", write: "yes"},
			},
		},
		{
			name: "Section is not moved on re-run with new options",
			writeConfig: writeConfig{
				ssh: strings.Join([]string{
					"Host myhost",
					"	HostName myhost",
					"",
					baseHeader,
					"",
					"Host otherhost",
					"	HostName otherhost",
					"",
				}, "\n"),
			},
			wantConfig: wantConfig{
				ssh: []string{
					strings.Join([]string{
						"Host myhost",
						"	HostName myhost",
						"",
						headerStart,
						"# Last config-ssh options:",
						"# :ssh-option=ForwardAgent=yes",
						"#",
					}, "\n"),
					strings.Join([]string{
						headerEnd,
						"",
						"Host otherhost",
						"	HostName otherhost",
						"",
					}, "\n"),
				},
			},
			args: []string{
				"--ssh-option", "ForwardAgent=yes",
			},
			matches: []match{
				{match: "Use new options?", write: "yes"},
				{match: "Continue?", write: "yes"},
			},
		},
		{
			name: "Adds newline at EOF",
			writeConfig: writeConfig{
				ssh: strings.Join([]string{
					baseHeader,
				}, "\n"),
			},
			wantConfig: wantConfig{
				ssh: []string{
					headerStart,
					strings.Join([]string{
						headerEnd,
						"",
					}, "\n"),
				},
			},
			matches: []match{
				{match: "Continue?", write: "yes"},
			},
		},
		{
			name: "Do not prompt for new options on first run",
			writeConfig: writeConfig{
				ssh: "",
			},
			wantConfig: wantConfig{
				ssh: []string{
					strings.Join([]string{
						headerStart,
						"# Last config-ssh options:",
						"# :ssh-option=ForwardAgent=yes",
						"#",
					}, "\n"),
					strings.Join([]string{
						headerEnd,
						"",
					}, "\n"),
				},
			},
			args: []string{"--ssh-option", "ForwardAgent=yes"},
			matches: []match{
				{match: "Continue?", write: "yes"},
			},
		},
		{
			name: "Prompt for new options when there are no previous options",
			writeConfig: writeConfig{
				ssh: strings.Join([]string{
					baseHeader,
				}, "\n"),
			},
			wantConfig: wantConfig{
				ssh: []string{
					strings.Join([]string{
						headerStart,
						"# Last config-ssh options:",
						"# :ssh-option=ForwardAgent=yes",
						"#",
					}, "\n"),
					strings.Join([]string{
						headerEnd,
						"",
					}, "\n"),
				},
			},
			args: []string{"--ssh-option", "ForwardAgent=yes"},
			matches: []match{
				{match: "Use new options?", write: "yes"},
				{match: "Continue?", write: "yes"},
			},
		},
		{
			name: "Prompt for new options when there are previous options",
			writeConfig: writeConfig{
				ssh: strings.Join([]string{
					headerStart,
					"# Last config-ssh options:",
					"# :ssh-option=ForwardAgent=yes",
					"#",
					headerEnd,
				}, "\n"),
			},
			wantConfig: wantConfig{
				ssh: []string{
					headerStart,
					strings.Join([]string{
						headerEnd,
						"",
					}, "\n"),
				},
			},
			matches: []match{
				{match: "Use new options?", write: "yes"},
				{match: "Continue?", write: "yes"},
			},
		},
		{
			name: "No changes when continue = no",
			writeConfig: writeConfig{
				ssh: strings.Join([]string{
					headerStart,
					"# Last config-ssh options:",
					"# :ssh-option=ForwardAgent=yes",
					"#",
					headerEnd,
					"",
				}, "\n"),
			},
			wantConfig: wantConfig{
				ssh: []string{strings.Join([]string{
					headerStart,
					"# Last config-ssh options:",
					"# :ssh-option=ForwardAgent=yes",
					"#",
					headerEnd,
					"",
				}, "\n")},
			},
			args: []string{"--ssh-option", "ForwardAgent=no"},
			matches: []match{
				{match: "Use new options?", write: "yes"},
				{match: "Continue?", write: "no"},
			},
		},
		{
			name: "Do not prompt when using --yes",
			writeConfig: writeConfig{
				ssh: strings.Join([]string{
					headerStart,
					"# Last config-ssh options:",
					"# :ssh-option=ForwardAgent=yes",
					"#",
					headerEnd,
					"",
				}, "\n"),
			},
			wantConfig: wantConfig{
				ssh: []string{
					headerStart,
					headerEnd,
				},
			},
			args: []string{"--yes"},
		},
		{
			name: "Serialize supported flags",
			wantConfig: wantConfig{
				ssh: []string{
					strings.Join([]string{
						headerStart,
						"# Last config-ssh options:",
						"# :wait=yes",
						"# :ssh-host-prefix=coder-test.",
						"# :hostname-suffix=coder-suffix",
						"# :header=X-Test-Header=foo",
						"# :header=X-Test-Header2=bar",
						"# :header-command=echo h1=v1 h2=\"v2\" h3='v3'",
						"#",
					}, "\n"),
					strings.Join([]string{
						headerEnd,
						"",
					}, "\n"),
				},
			},
			args: []string{
				"--yes",
				"--wait=yes",
				"--ssh-host-prefix", "coder-test.",
				"--hostname-suffix", "coder-suffix",
				"--header", "X-Test-Header=foo",
				"--header", "X-Test-Header2=bar",
				"--header-command", "echo h1=v1 h2=\"v2\" h3='v3'",
			},
		},
		{
			name: "Serialize no-wildcard flag",
			wantConfig: wantConfig{
				ssh: []string{
					strings.Join([]string{
						headerStart,
						"# Last config-ssh options:",
						"# :hostname-suffix=coder-suffix",
						"# :no-wildcard=true",
						"#",
					}, "\n"),
					strings.Join([]string{
						headerEnd,
						"",
					}, "\n"),
				},
			},
			args: []string{
				"--yes",
				"--hostname-suffix", "coder-suffix",
				"--no-wildcard",
			},
		},
		{
			name: "No wildcard generates per-workspace entries",
			args: []string{
				"--yes",
				"--hostname-suffix", "coder",
				"--no-wildcard",
			},
			hasAgent: true,
			wantConfig: wantConfig{
				ssh: []string{
					"# :hostname-suffix=coder",
					"# :no-wildcard=true",
				},
				regexMatch: `Host [a-z0-9_-]+\.coder`,
			},
		},
		{
			name: "Do not prompt for new options when prev opts flag is set",
			writeConfig: writeConfig{
				ssh: strings.Join([]string{
					headerStart,
					"# Last config-ssh options:",
					"# :wait=no",
					"# :ssh-option=ForwardAgent=yes",
					"#",
					headerEnd,
					"",
				}, "\n"),
			},
			wantConfig: wantConfig{
				ssh: []string{
					strings.Join(
						[]string{
							headerStart,
							"# Last config-ssh options:",
							"# :wait=no",
							"# :ssh-option=ForwardAgent=yes",
							"#",
						}, "\n"),
					strings.Join([]string{
						headerEnd,
						"",
					}, "\n"),
				},
			},
			args: []string{
				"--use-previous-options",
				"--yes",
			},
		},
		{
			name: "Do not overwrite config when using --dry-run",
			writeConfig: writeConfig{
				ssh: strings.Join([]string{
					baseHeader,
					"",
				}, "\n"),
			},
			wantConfig: wantConfig{
				ssh: []string{strings.Join([]string{
					baseHeader,
					"",
				}, "\n")},
			},
			args: []string{
				"--ssh-option", "ForwardAgent=yes",
				"--dry-run",
				"--yes",
			},
		},
		{
			name:    "Start/End out of order",
			matches: []match{
				// {match: "Continue?", write: "yes"},
			},
			writeConfig: writeConfig{
				ssh: strings.Join([]string{
					"# Content before coder block",
					headerEnd,
					headerStart,
					"# Content after coder block",
				}, "\n"),
			},
			wantErr: true,
		},
		{
			name:    "Multiple sections",
			matches: []match{
				// {match: "Continue?", write: "yes"},
			},
			writeConfig: writeConfig{
				ssh: strings.Join([]string{
					headerStart,
					headerEnd,
					headerStart,
					headerEnd,
				}, "\n"),
			},
			wantErr: true,
		},
		{
			name: "Custom CLI Path",
			args: []string{
				"-y", "--coder-binary-path", "/foo/bar/coder",
			},
			wantErr:  false,
			hasAgent: true,
			wantConfig: wantConfig{
				regexMatch: "ProxyCommand /foo/bar/coder",
			},
		},
		{
			name: "Header",
			args: []string{
				"--yes",
				"--header", "X-Test-Header=foo",
				"--header", "X-Test-Header2=bar",
			},
			wantErr:  false,
			hasAgent: true,
			wantConfig: wantConfig{
				regexMatch: `ProxyCommand .* --header "X-Test-Header=foo" --header "X-Test-Header2=bar" ssh .* --ssh-host-prefix coder. %h`,
			},
		},
		{
			name: "Header command",
			args: []string{
				"--yes",
				"--header-command", "echo h1=v1",
			},
			wantErr:  false,
			hasAgent: true,
			wantConfig: wantConfig{
				regexMatch: `ProxyCommand .* --header-command "echo h1=v1" ssh .* --ssh-host-prefix coder. %h`,
			},
		},
		{
			name: "Header command with double quotes",
			args: []string{
				"--yes",
				"--header-command", "echo h1=v1 h2=\"v2\"",
			},
			wantErr:  false,
			hasAgent: true,
			wantConfig: wantConfig{
				regexMatch: `ProxyCommand .* --header-command "echo h1=v1 h2=\\\"v2\\\"" ssh .* --ssh-host-prefix coder. %h`,
			},
		},
		{
			name: "Header command with single quotes",
			args: []string{
				"--yes",
				"--header-command", "echo h1=v1 h2='v2'",
			},
			wantErr:  false,
			hasAgent: true,
			wantConfig: wantConfig{
				regexMatch: `ProxyCommand .* --header-command "echo h1=v1 h2='v2'" ssh .* --ssh-host-prefix coder. %h`,
			},
		},
		{
			name: "Multiple remote forwards",
			args: []string{
				"--yes",
				"--ssh-option", "RemoteForward 2222 192.168.11.1:2222",
				"--ssh-option", "RemoteForward 2223 192.168.11.1:2223",
			},
			wantErr:  false,
			hasAgent: true,
			wantConfig: wantConfig{
				regexMatch: "RemoteForward 2222 192.168.11.1:2222.*\n.*RemoteForward 2223 192.168.11.1:2223",
			},
		},
		{
			name: "Hostname Suffix",
			args: []string{
				"--yes",
				"--ssh-option", "Foo=bar",
				"--hostname-suffix", "testy",
			},
			wantErr:  false,
			hasAgent: true,
			wantConfig: wantConfig{
				ssh: []string{
					"Host *.testy",
					"Foo=bar",
					"ConnectTimeout=0",
					"StrictHostKeyChecking=no",
					"UserKnownHostsFile=/dev/null",
					"LogLevel ERROR",
				},
				regexMatch: `Match host \*\.testy !exec ".* connect exists %h"\n\tProxyCommand .* ssh .* --hostname-suffix testy %h`,
			},
		},
		{
			name: "Hostname Prefix and Suffix",
			args: []string{
				"--yes",
				"--ssh-host-prefix", "presto.",
				"--hostname-suffix", "testy",
			},
			wantErr:  false,
			hasAgent: true,
			wantConfig: wantConfig{
				ssh: []string{"Host presto.*", "Match host *.testy !exec"},
			},
		},
		{
			// Regression test for https://github.com/coder/internal/issues/1208:
			// an explicitly empty --ssh-host-prefix must not fall back to the
			// server's default prefix.
			name: "Explicit empty ssh-host-prefix omits legacy block",
			args: []string{
				"--yes",
				"--ssh-host-prefix", "",
			},
			wantErr: false,
			wantConfig: wantConfig{
				ssh: []string{
					headerStart,
					"# Last config-ssh options:",
					"# :ssh-host-prefix=\n",
					headerEnd,
				},
				notWant: []string{"Host coder.*", "--ssh-host-prefix coder."},
			},
		},
		{
			// Same as above, but via the env var instead of the flag.
			name: "Explicit empty ssh-host-prefix env var omits legacy block",
			args: []string{"--yes"},
			env: map[string]string{
				"CODER_CONFIGSSH_SSH_HOST_PREFIX": "",
			},
			wantErr: false,
			wantConfig: wantConfig{
				ssh: []string{
					headerStart,
					"# Last config-ssh options:",
					"# :ssh-host-prefix=\n",
					headerEnd,
				},
				notWant: []string{"Host coder.*", "--ssh-host-prefix coder."},
			},
		},
		{
			// An explicit empty prefix alongside an explicit suffix should
			// produce only the suffix block, not both.
			name: "Explicit empty ssh-host-prefix with hostname-suffix set",
			args: []string{
				"--yes",
				"--ssh-host-prefix", "",
				"--hostname-suffix", "testy",
			},
			wantErr:  false,
			hasAgent: true,
			wantConfig: wantConfig{
				ssh: []string{
					"# :ssh-host-prefix=\n",
					"# :hostname-suffix=testy\n",
					"Host *.testy",
				},
				notWant: []string{"Host coder.*", "--ssh-host-prefix coder."},
			},
		},
		{
			// Regression test: the "omit this block" choice must survive a
			// later --use-previous-options run that doesn't repeat the flag,
			// not just the invocation where the flag was passed.
			name: "use-previous-options preserves an explicitly empty prefix across runs",
			writeConfig: writeConfig{
				ssh: strings.Join([]string{
					headerStart,
					"# Last config-ssh options:",
					"# :ssh-host-prefix=",
					"#",
					headerEnd,
					"",
				}, "\n"),
			},
			args: []string{
				"--use-previous-options",
				"--yes",
			},
			wantConfig: wantConfig{
				ssh: []string{
					"# :ssh-host-prefix=\n",
				},
				notWant: []string{"Host coder.*", "--ssh-host-prefix coder."},
			},
		},
		{
			// Regression test: --use-previous-options should still win over
			// this run's explicit empty flag, since that's what "use previous
			// options" means. The empty-prefix fix must not change this.
			name: "use-previous-options keeps prior prefix despite this run's explicit empty flag",
			writeConfig: writeConfig{
				ssh: strings.Join([]string{
					headerStart,
					"# Last config-ssh options:",
					"# :ssh-host-prefix=coder-test.",
					"#",
					headerEnd,
					"",
				}, "\n"),
			},
			args: []string{
				"--use-previous-options",
				"--yes",
				"--ssh-host-prefix", "",
			},
			wantConfig: wantConfig{
				ssh: []string{
					"# :ssh-host-prefix=coder-test.",
					"Host coder-test.*",
				},
			},
		},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			t.Parallel()
			logger := testutil.Logger(t)
			ctx := testutil.Context(t, testutil.WaitMedium)

			client, db := coderdtest.NewWithDatabase(t, nil)
			user := coderdtest.CreateFirstUser(t, client)
			if tt.hasAgent {
				_ = dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{
					OrganizationID: user.OrganizationID,
					OwnerID:        user.UserID,
				}).WithAgent().Do()
			}

			// Prepare ssh config files.
			sshConfigName := sshConfigFileName(t)
			if tt.writeConfig.ssh != "" {
				sshConfigFileCreate(t, sshConfigName, strings.NewReader(tt.writeConfig.ssh))
			}

			args := []string{
				"config-ssh",
				"--ssh-config-file", sshConfigName,
			}
			args = append(args, tt.args...)
			inv, root := clitest.New(t, args...)
			//nolint:gocritic // This has always ran with the admin user.
			clitest.SetupConfig(t, client, root)
			for k, v := range tt.env {
				inv.Environ.Set(k, v)
			}

			stdout := expecter.NewAttachedToInvocation(t, inv)
			stdin := testutil.NewWriterAttachedToInvocation(t, logger.Named("stdin"), inv)
			done := tGo(t, func() {
				err := inv.Run()
				if !tt.wantErr {
					assert.NoError(t, err)
				} else {
					assert.Error(t, err)
				}
			})

			for _, m := range tt.matches {
				stdout.ExpectMatch(ctx, m.match)
				stdin.WriteLine(m.write)
			}

			<-done

			if len(tt.wantConfig.ssh) != 0 || tt.wantConfig.regexMatch != "" || len(tt.wantConfig.notWant) != 0 {
				full := sshConfigFileRead(t, sshConfigName)
				got := full
				// Require that the generated config has the expected snippets in order.
				for _, want := range tt.wantConfig.ssh {
					idx := strings.Index(got, want)
					if idx == -1 {
						require.Contains(t, got, want)
					}
					got = got[idx+len(want):]
				}
				if tt.wantConfig.regexMatch != "" {
					assert.Regexp(t, tt.wantConfig.regexMatch, got, "regex match")
				}
				for _, notWant := range tt.wantConfig.notWant {
					assert.NotContains(t, full, notWant, "unexpected snippet found")
				}
			}
		})
	}
}

func TestConfigSSH_NoWildcard(t *testing.T) {
	t.Parallel()

	if runtime.GOOS == "windows" {
		t.Skip("See coder/internal#117")
	}

	var requestMu sync.Mutex
	historyRequests := make(map[string]int)
	var templateResourceRequests int
	client, db := coderdtest.NewWithDatabase(t, &coderdtest.Options{
		APIMiddleware: func(next http.Handler) http.Handler {
			return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
				if r.Method == http.MethodGet {
					requestMu.Lock()
					if strings.HasPrefix(r.URL.Path, "/api/v2/workspaces/") && strings.HasSuffix(r.URL.Path, "/builds") {
						historyRequests[r.URL.Path]++
					}
					if strings.HasPrefix(r.URL.Path, "/api/v2/templateversions/") && strings.HasSuffix(r.URL.Path, "/resources") {
						templateResourceRequests++
					}
					requestMu.Unlock()
				}
				next.ServeHTTP(w, r)
			})
		},
	})
	user := coderdtest.CreateFirstUser(t, client)

	// Create two workspaces with names in reverse lexical order so that we can
	// verify the SSH config entries are sorted by name, not by creation order.
	// ws-beta sorts after ws-alpha alphabetically.
	dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{
		OrganizationID: user.OrganizationID,
		OwnerID:        user.UserID,
		Name:           "ws-beta",
	}).WithAgent(func(a []*sdkproto.Agent) []*sdkproto.Agent {
		a[0].Name = "agent-beta"
		// A second agent, so both agents of the workspace get their own entry.
		return append(a, &sdkproto.Agent{
			Id:   uuid.NewString(),
			Name: "agent-gamma",
			Auth: &sdkproto.Agent_Token{Token: uuid.NewString()},
			Env:  map[string]string{},
		})
	}).Do()
	const injectedAgentName = "unsafe\nProxyCommand injected-command"
	dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{
		OrganizationID: user.OrganizationID,
		OwnerID:        user.UserID,
		Name:           "ws-alpha",
	}).WithAgent(func(a []*sdkproto.Agent) []*sdkproto.Agent {
		a[0].Name = "agent-alpha"
		// Attached environments precreate Terraform devcontainer subagents.
		// Their names permit underscores, unlike regular provisioned agents.
		for _, name := range []string{"dev_box", injectedAgentName} {
			a[0].Devcontainers = append(a[0].Devcontainers, &sdkproto.Devcontainer{
				Name:            name,
				WorkspaceFolder: "/workspace",
				Envs:            []*sdkproto.Env{{Name: "EDITOR", Value: "vim"}},
			})
		}
		return a
	}).Do()

	// Import defaults need not match either workspace's actual agent set,
	// even when the workspaces use the same template version.
	version := dbfake.TemplateVersion(t, db).Seed(database.TemplateVersion{
		OrganizationID: user.OrganizationID,
		CreatedBy:      user.UserID,
	}).Resources(&sdkproto.Resource{
		Name: "example",
		Type: "aws_instance",
		Agents: []*sdkproto.Agent{{
			Id:   uuid.NewString(),
			Name: "default-agent",
			Auth: &sdkproto.Agent_Token{Token: uuid.NewString()},
			Env:  map[string]string{},
		}},
	}).Do()
	wantHistoryRequests := make(map[string]int)
	for _, ws := range []struct{ name, agent string }{
		{name: "ws-gamma", agent: "agent-delta"},
		{name: "ws-delta", agent: "agent-epsilon"},
	} {
		started := dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{
			OrganizationID: user.OrganizationID,
			OwnerID:        user.UserID,
			TemplateID:     version.Template.ID,
			Name:           ws.name,
		}).Seed(database.WorkspaceBuild{
			TemplateVersionID: version.TemplateVersion.ID,
		}).WithAgent(func(a []*sdkproto.Agent) []*sdkproto.Agent {
			a[0].Name = ws.agent
			return a
		}).Do()
		dbfake.WorkspaceBuild(t, db, started.Workspace).Seed(database.WorkspaceBuild{
			TemplateVersionID: version.TemplateVersion.ID,
			Transition:        database.WorkspaceTransitionStop,
			BuildNumber:       2,
		}).Do()
		wantHistoryRequests[fmt.Sprintf("/api/v2/workspaces/%s/builds", started.Workspace.ID)] = 1
	}

	sshConfigPath := sshConfigFileName(t)

	runConfigSSH := func() {
		t.Helper()
		requestMu.Lock()
		clear(historyRequests)
		templateResourceRequests = 0
		requestMu.Unlock()
		inv, root := clitest.New(t,
			"config-ssh",
			"--ssh-config-file", sshConfigPath,
			"--hostname-suffix", "coder",
			"--no-wildcard",
			"--yes",
		)
		//nolint:gocritic // This has always ran with the admin user.
		clitest.SetupConfig(t, client, root)
		// A fresh budget per run, so setup and the first run do not drain it.
		ctx := testutil.Context(t, testutil.WaitLong)
		err := inv.WithContext(ctx).Run()
		require.NoError(t, err)
		requestMu.Lock()
		defer requestMu.Unlock()
		assert.Equal(t, wantHistoryRequests, historyRequests, "fetch history once per stopped workspace, not once per host alias")
		assert.Zero(t, templateResourceRequests, "import resources cannot describe workspace-specific agents")
	}

	// hostLines extracts lines beginning with "Host " from the SSH config.
	// ProxyCommand lines embed a per-invocation temp path and are excluded so
	// that two runs with different global-config dirs can still be compared.
	hostLines := func(s string) []string {
		var out []string
		for line := range strings.SplitSeq(s, "\n") {
			if strings.HasPrefix(line, "Host ") {
				out = append(out, line)
			}
		}
		return out
	}

	runConfigSSH()
	config := sshConfigFileRead(t, sshConfigPath)

	// The server always injects a "coder." hostname prefix in addition to the
	// user-supplied "--hostname-suffix coder" entries. With stable workspace
	// names we can assert the complete, ordered host-entry list exactly.
	// ws-alpha sorts before ws-beta even though ws-alpha was created second,
	// and the stopped workspaces come after the running workspaces.
	// Each workspace entry is followed by an <agent>.<workspace>.<owner> entry
	// per agent, matching the hostnames the dashboard and Coder Connect use.
	owner := coderdtest.FirstUserParams.Username
	wantHosts := []string{
		"Host coder.ws-alpha",
		"Host coder.agent-alpha.ws-alpha." + owner,
		"Host coder.dev_box.ws-alpha." + owner,
		"Host coder.ws-beta",
		"Host coder.agent-beta.ws-beta." + owner,
		"Host coder.agent-gamma.ws-beta." + owner,
		"Host coder.ws-delta",
		"Host coder.agent-epsilon.ws-delta." + owner,
		"Host coder.ws-gamma",
		"Host coder.agent-delta.ws-gamma." + owner,
		"Host ws-alpha.coder",
		"Host agent-alpha.ws-alpha." + owner + ".coder",
		"Host dev_box.ws-alpha." + owner + ".coder",
		"Host ws-beta.coder",
		"Host agent-beta.ws-beta." + owner + ".coder",
		"Host agent-gamma.ws-beta." + owner + ".coder",
		"Host ws-delta.coder",
		"Host agent-epsilon.ws-delta." + owner + ".coder",
		"Host ws-gamma.coder",
		"Host agent-delta.ws-gamma." + owner + ".coder",
	}
	assert.Empty(t, cmp.Diff(wantHosts, hostLines(config)))
	assert.NotContains(t, config, "default-agent")
	assert.NotContains(t, config, "unsafe")
	assert.NotContains(t, config, "injected-command")

	// No wildcard entries must appear in the Coder section.
	require.NotContains(t, config, "Host *.coder")
	require.NotContains(t, config, "Host *.")

	// The no-wildcard option must be persisted in the header.
	require.Contains(t, config, "# :no-wildcard=true")

	// Running the command again must yield identical host entries, confirming
	// that the ordering is stable across runs.
	runConfigSSH()
	require.Empty(t, cmp.Diff(wantHosts, hostLines(sshConfigFileRead(t, sshConfigPath))))
}