From 42a751256c5577be2a9543946f9796a3e66fb8ed Mon Sep 17 00:00:00 2001 From: Josh Vawdrey Date: Fri, 20 Oct 2023 09:26:14 +1100 Subject: [PATCH 1/7] feat(configssh): add any provided header command to the SSH config also --- cli/configssh.go | 9 +++++++-- cli/configssh_test.go | 14 ++++++++++++++ 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/cli/configssh.go b/cli/configssh.go index 7e9e8109ea554..7d735849a76f4 100644 --- a/cli/configssh.go +++ b/cli/configssh.go @@ -388,13 +388,18 @@ func (r *RootCmd) configSSH() *clibase.Cmd { } if !skipProxyCommand { + rootFlags := fmt.Sprintf("--global-config %s", escapedGlobalConfig) + if r.headerCommand != "" { + rootFlags += fmt.Sprintf(" --header-command %s", r.headerCommand) + } + flags := "" if sshConfigOpts.waitEnum != "auto" { flags += " --wait=" + sshConfigOpts.waitEnum } defaultOptions = append(defaultOptions, fmt.Sprintf( - "ProxyCommand %s --global-config %s ssh --stdio%s %s", - escapedCoderBinary, escapedGlobalConfig, flags, workspaceHostname, + "ProxyCommand %s %s ssh --stdio%s %s", + escapedCoderBinary, rootFlags, flags, workspaceHostname, )) } diff --git a/cli/configssh_test.go b/cli/configssh_test.go index a39afe606f873..1e38389749292 100644 --- a/cli/configssh_test.go +++ b/cli/configssh_test.go @@ -585,6 +585,20 @@ func TestConfigSSH_FileWriteAndOptionsFlow(t *testing.T) { regexMatch: "ProxyCommand /foo/bar/coder", }, }, + { + name: "Header command", + args: []string{ + "--header-command", "/foo/bar/coder", + }, + wantErr: false, + echoResponse: &echo.Responses{ + Parse: echo.ParseComplete, + ProvisionApply: echo.ProvisionApplyWithAgent(""), + }, + wantConfig: wantConfig{ + regexMatch: "--header-command /foo/bar/coder ssh", + }, + }, } for _, tt := range tests { tt := tt From 9a9ff381b8f1ae01be9267c3d7d719508c03eddf Mon Sep 17 00:00:00 2001 From: Josh Vawdrey Date: Fri, 20 Oct 2023 09:40:10 +1100 Subject: [PATCH 2/7] chore: escape command also --- cli/configssh.go | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/cli/configssh.go b/cli/configssh.go index 7d735849a76f4..151807dba7b91 100644 --- a/cli/configssh.go +++ b/cli/configssh.go @@ -249,6 +249,17 @@ func (r *RootCmd) configSSH() *clibase.Cmd { return xerrors.Errorf("escape coder binary for ssh failed: %w", err) } + escapedHeaderCommand := "" + + if r.headerCommand != "" { + headerCommand, err := sshConfigExecEscape(coderBinary, forceUnixSeparators) + if err != nil { + return xerrors.Errorf("escape header command for ssh failed: %w", err) + } + + escapedHeaderCommand = headerCommand + } + root := r.createConfig() escapedGlobalConfig, err := sshConfigExecEscape(string(root), forceUnixSeparators) if err != nil { @@ -389,8 +400,8 @@ func (r *RootCmd) configSSH() *clibase.Cmd { if !skipProxyCommand { rootFlags := fmt.Sprintf("--global-config %s", escapedGlobalConfig) - if r.headerCommand != "" { - rootFlags += fmt.Sprintf(" --header-command %s", r.headerCommand) + if escapedHeaderCommand != "" { + rootFlags += fmt.Sprintf(" --header-command %s", escapedHeaderCommand) } flags := "" From 1220039b73fba608fd6b572253c26de51b0bd320 Mon Sep 17 00:00:00 2001 From: Josh Vawdrey Date: Fri, 20 Oct 2023 09:49:23 +1100 Subject: [PATCH 3/7] chore: fix up the variable name --- cli/configssh.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cli/configssh.go b/cli/configssh.go index 151807dba7b91..67cc0e203e663 100644 --- a/cli/configssh.go +++ b/cli/configssh.go @@ -252,7 +252,7 @@ func (r *RootCmd) configSSH() *clibase.Cmd { escapedHeaderCommand := "" if r.headerCommand != "" { - headerCommand, err := sshConfigExecEscape(coderBinary, forceUnixSeparators) + headerCommand, err := sshConfigExecEscape(r.headerCommand, forceUnixSeparators) if err != nil { return xerrors.Errorf("escape header command for ssh failed: %w", err) } From a785392ebe2cfe940a6922b45f8e0146ded8d23d Mon Sep 17 00:00:00 2001 From: Josh Vawdrey Date: Fri, 20 Oct 2023 09:55:17 +1100 Subject: [PATCH 4/7] chore: more test cases --- cli/configssh_test.go | 30 +++++++++++++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/cli/configssh_test.go b/cli/configssh_test.go index 1e38389749292..a2ad14fccc656 100644 --- a/cli/configssh_test.go +++ b/cli/configssh_test.go @@ -596,7 +596,35 @@ func TestConfigSSH_FileWriteAndOptionsFlow(t *testing.T) { ProvisionApply: echo.ProvisionApplyWithAgent(""), }, wantConfig: wantConfig{ - regexMatch: "--header-command /foo/bar/coder ssh", + regexMatch: "--header-command \"/foo/bar/coder\" ssh", + }, + }, + { + name: "Header command with double quotes", + args: []string{ + "--header-command", "/foo/bar/coder arg1 arg2=\"quoted value\"", + }, + wantErr: false, + echoResponse: &echo.Responses{ + Parse: echo.ParseComplete, + ProvisionApply: echo.ProvisionApplyWithAgent(""), + }, + wantConfig: wantConfig{ + regexMatch: "--header-command \"/foo/bar/coder arg1 arg2=\\\"quoted value\\\"\" ssh", + }, + }, + { + name: "Header command with single quotes", + args: []string{ + "--header-command", "/foo/bar/coder arg1 arg2='quoted value'", + }, + wantErr: false, + echoResponse: &echo.Responses{ + Parse: echo.ParseComplete, + ProvisionApply: echo.ProvisionApplyWithAgent(""), + }, + wantConfig: wantConfig{ + regexMatch: "--header-command \"/foo/bar/coder arg1 arg2='quoted value'\" ssh", }, }, } From c5e0ec15a34e860160f6bf28a75a207448eeea23 Mon Sep 17 00:00:00 2001 From: Josh Vawdrey Date: Fri, 20 Oct 2023 10:37:04 +1100 Subject: [PATCH 5/7] chore: fix tests --- cli/configssh_test.go | 21 ++++++++++++--------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/cli/configssh_test.go b/cli/configssh_test.go index a2ad14fccc656..74a5aa621c4e9 100644 --- a/cli/configssh_test.go +++ b/cli/configssh_test.go @@ -588,43 +588,46 @@ func TestConfigSSH_FileWriteAndOptionsFlow(t *testing.T) { { name: "Header command", args: []string{ - "--header-command", "/foo/bar/coder", + "--yes", + "--header-command", "printf h1=v1", }, - wantErr: false, echoResponse: &echo.Responses{ Parse: echo.ParseComplete, ProvisionApply: echo.ProvisionApplyWithAgent(""), }, + wantErr: false, wantConfig: wantConfig{ - regexMatch: "--header-command \"/foo/bar/coder\" ssh", + regexMatch: "--header-command \"printf h1=v1\" ssh", }, }, { name: "Header command with double quotes", args: []string{ - "--header-command", "/foo/bar/coder arg1 arg2=\"quoted value\"", + "--yes", + "--header-command", "printf h1=v1 h2=\"v2\"", }, - wantErr: false, echoResponse: &echo.Responses{ Parse: echo.ParseComplete, ProvisionApply: echo.ProvisionApplyWithAgent(""), }, + wantErr: false, wantConfig: wantConfig{ - regexMatch: "--header-command \"/foo/bar/coder arg1 arg2=\\\"quoted value\\\"\" ssh", + regexMatch: "--header-command \"printf h1=v1 h2=\\\\\"v2\\\\\"\" ssh", }, }, { name: "Header command with single quotes", args: []string{ - "--header-command", "/foo/bar/coder arg1 arg2='quoted value'", + "--yes", + "--header-command", "printf h1=v1 h2='v2'", }, - wantErr: false, echoResponse: &echo.Responses{ Parse: echo.ParseComplete, ProvisionApply: echo.ProvisionApplyWithAgent(""), }, + wantErr: false, wantConfig: wantConfig{ - regexMatch: "--header-command \"/foo/bar/coder arg1 arg2='quoted value'\" ssh", + regexMatch: "--header-command \"printf h1=v1 h2='v2'\" ssh", }, }, } From 1c585c44eb83669042ba16895e448632d3e908d6 Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Mon, 5 Feb 2024 15:48:50 +0000 Subject: [PATCH 6/7] remove echoResponses --- cli/configssh_test.go | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/cli/configssh_test.go b/cli/configssh_test.go index b3fe123d85dbd..20b5dbdd0f4a3 100644 --- a/cli/configssh_test.go +++ b/cli/configssh_test.go @@ -569,10 +569,6 @@ func TestConfigSSH_FileWriteAndOptionsFlow(t *testing.T) { "--yes", "--header-command", "printf h1=v1", }, - echoResponse: &echo.Responses{ - Parse: echo.ParseComplete, - ProvisionApply: echo.ProvisionApplyWithAgent(""), - }, wantErr: false, wantConfig: wantConfig{ regexMatch: "--header-command \"printf h1=v1\" ssh", @@ -584,10 +580,6 @@ func TestConfigSSH_FileWriteAndOptionsFlow(t *testing.T) { "--yes", "--header-command", "printf h1=v1 h2=\"v2\"", }, - echoResponse: &echo.Responses{ - Parse: echo.ParseComplete, - ProvisionApply: echo.ProvisionApplyWithAgent(""), - }, wantErr: false, wantConfig: wantConfig{ regexMatch: "--header-command \"printf h1=v1 h2=\\\\\"v2\\\\\"\" ssh", @@ -599,10 +591,6 @@ func TestConfigSSH_FileWriteAndOptionsFlow(t *testing.T) { "--yes", "--header-command", "printf h1=v1 h2='v2'", }, - echoResponse: &echo.Responses{ - Parse: echo.ParseComplete, - ProvisionApply: echo.ProvisionApplyWithAgent(""), - }, wantErr: false, wantConfig: wantConfig{ regexMatch: "--header-command \"printf h1=v1 h2='v2'\" ssh", From bcdb1296a04fef383c1facdf0494b1cd4b8ad5cb Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Mon, 5 Feb 2024 16:49:52 +0000 Subject: [PATCH 7/7] add support for serializing header and header-command --- cli/configssh.go | 64 +++++++++++++++++++++++++++++-------------- cli/configssh_test.go | 34 +++++++++++++++++++---- 2 files changed, 72 insertions(+), 26 deletions(-) diff --git a/cli/configssh.go b/cli/configssh.go index fdc5a2af43eb5..cb91cec8c0d8d 100644 --- a/cli/configssh.go +++ b/cli/configssh.go @@ -19,6 +19,7 @@ import ( "github.com/cli/safeexec" "github.com/pkg/diff" "github.com/pkg/diff/write" + "golang.org/x/exp/constraints" "golang.org/x/exp/slices" "golang.org/x/sync/errgroup" "golang.org/x/xerrors" @@ -51,6 +52,8 @@ type sshConfigOptions struct { userHostPrefix string sshOptions []string disableAutostart bool + header []string + headerCommand string } // addOptions expects options in the form of "option=value" or "option value". @@ -100,15 +103,25 @@ func (o *sshConfigOptions) addOption(option string) error { } func (o sshConfigOptions) equal(other sshConfigOptions) bool { - // Compare without side-effects or regard to order. - opt1 := slices.Clone(o.sshOptions) - sort.Strings(opt1) - opt2 := slices.Clone(other.sshOptions) - sort.Strings(opt2) - if !slices.Equal(opt1, opt2) { + if !slicesSortedEqual(o.sshOptions, other.sshOptions) { return false } - return o.waitEnum == other.waitEnum && o.userHostPrefix == other.userHostPrefix && o.disableAutostart == other.disableAutostart + if !slicesSortedEqual(o.header, other.header) { + return false + } + return o.waitEnum == other.waitEnum && o.userHostPrefix == other.userHostPrefix && o.disableAutostart == other.disableAutostart && o.headerCommand == other.headerCommand +} + +// 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) { @@ -124,6 +137,13 @@ func (o sshConfigOptions) asList() (list []string) { 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 } @@ -230,6 +250,8 @@ func (r *RootCmd) configSSH() *clibase.Cmd { // 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 recvWorkspaceConfigs := sshPrepareWorkspaceConfigs(inv.Context(), client) @@ -254,17 +276,6 @@ func (r *RootCmd) configSSH() *clibase.Cmd { return xerrors.Errorf("escape coder binary for ssh failed: %w", err) } - escapedHeaderCommand := "" - - if r.headerCommand != "" { - headerCommand, err := sshConfigExecEscape(r.headerCommand, forceUnixSeparators) - if err != nil { - return xerrors.Errorf("escape header command for ssh failed: %w", err) - } - - escapedHeaderCommand = headerCommand - } - root := r.createConfig() escapedGlobalConfig, err := sshConfigExecEscape(string(root), forceUnixSeparators) if err != nil { @@ -405,8 +416,11 @@ func (r *RootCmd) configSSH() *clibase.Cmd { if !skipProxyCommand { rootFlags := fmt.Sprintf("--global-config %s", escapedGlobalConfig) - if escapedHeaderCommand != "" { - rootFlags += fmt.Sprintf(" --header-command %s", escapedHeaderCommand) + for _, h := range sshConfigOpts.header { + rootFlags += fmt.Sprintf(" --header %q", h) + } + if sshConfigOpts.headerCommand != "" { + rootFlags += fmt.Sprintf(" --header-command %q", sshConfigOpts.headerCommand) } flags := "" @@ -639,6 +653,12 @@ func sshConfigWriteSectionHeader(w io.Writer, addNewline bool, o sshConfigOption 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()) @@ -670,6 +690,10 @@ func sshConfigParseLastOptions(r io.Reader) (o sshConfigOptions) { o.sshOptions = append(o.sshOptions, parts[1]) case "disable-autostart": o.disableAutostart, _ = strconv.ParseBool(parts[1]) + case "header": + o.header = append(o.header, parts[1]) + case "header-command": + o.headerCommand = parts[1] default: // Unknown option, ignore. } diff --git a/cli/configssh_test.go b/cli/configssh_test.go index 20b5dbdd0f4a3..ee66e350c1582 100644 --- a/cli/configssh_test.go +++ b/cli/configssh_test.go @@ -462,6 +462,9 @@ func TestConfigSSH_FileWriteAndOptionsFlow(t *testing.T) { "# Last config-ssh options:", "# :wait=yes", "# :ssh-host-prefix=coder-test.", + "# :header=X-Test-Header=foo", + "# :header=X-Test-Header2=bar", + "# :header-command=printf h1=v1 h2=\"v2\" h3='v3'", "#", headerEnd, "", @@ -471,6 +474,9 @@ func TestConfigSSH_FileWriteAndOptionsFlow(t *testing.T) { "--yes", "--wait=yes", "--ssh-host-prefix", "coder-test.", + "--header", "X-Test-Header=foo", + "--header", "X-Test-Header2=bar", + "--header-command", "printf h1=v1 h2=\"v2\" h3='v3'", }, }, { @@ -563,15 +569,29 @@ func TestConfigSSH_FileWriteAndOptionsFlow(t *testing.T) { 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`, + }, + }, { name: "Header command", args: []string{ "--yes", "--header-command", "printf h1=v1", }, - wantErr: false, + wantErr: false, + hasAgent: true, wantConfig: wantConfig{ - regexMatch: "--header-command \"printf h1=v1\" ssh", + regexMatch: `ProxyCommand .* --header-command "printf h1=v1" ssh`, }, }, { @@ -580,9 +600,10 @@ func TestConfigSSH_FileWriteAndOptionsFlow(t *testing.T) { "--yes", "--header-command", "printf h1=v1 h2=\"v2\"", }, - wantErr: false, + wantErr: false, + hasAgent: true, wantConfig: wantConfig{ - regexMatch: "--header-command \"printf h1=v1 h2=\\\\\"v2\\\\\"\" ssh", + regexMatch: `ProxyCommand .* --header-command "printf h1=v1 h2=\\\"v2\\\"" ssh`, }, }, { @@ -591,9 +612,10 @@ func TestConfigSSH_FileWriteAndOptionsFlow(t *testing.T) { "--yes", "--header-command", "printf h1=v1 h2='v2'", }, - wantErr: false, + wantErr: false, + hasAgent: true, wantConfig: wantConfig{ - regexMatch: "--header-command \"printf h1=v1 h2='v2'\" ssh", + regexMatch: `ProxyCommand .* --header-command "printf h1=v1 h2='v2'" ssh`, }, }, }