Skip to content

Commit fe247c8

Browse files
authored
feat: Add deployment side config-ssh options (#6613)
* feat: Allow setting deployment wide ssh config settings * feat: config-ssh respects deployment ssh config * The '.' is now configurable * Move buildinfo into deployment.go
1 parent 25e8abd commit fe247c8

18 files changed

+641
-48
lines changed

cli/configssh.go

Lines changed: 115 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88
"fmt"
99
"io"
1010
"io/fs"
11+
"net/http"
1112
"os"
1213
"path/filepath"
1314
"runtime"
@@ -48,6 +49,43 @@ type sshConfigOptions struct {
4849
sshOptions []string
4950
}
5051

52+
// addOptions expects options in the form of "option=value" or "option value".
53+
// It will override any existing option with the same key to prevent duplicates.
54+
// Invalid options will return an error.
55+
func (o *sshConfigOptions) addOptions(options ...string) error {
56+
for _, option := range options {
57+
err := o.addOption(option)
58+
if err != nil {
59+
return err
60+
}
61+
}
62+
return nil
63+
}
64+
65+
func (o *sshConfigOptions) addOption(option string) error {
66+
key, _, err := codersdk.ParseSSHConfigOption(option)
67+
if err != nil {
68+
return err
69+
}
70+
for i, existing := range o.sshOptions {
71+
// Override existing option if they share the same key.
72+
// This is case-insensitive. Parsing each time might be a little slow,
73+
// but it is ok.
74+
existingKey, _, err := codersdk.ParseSSHConfigOption(existing)
75+
if err != nil {
76+
// Don't mess with original values if there is an error.
77+
// This could have come from the user's manual edits.
78+
continue
79+
}
80+
if strings.EqualFold(existingKey, key) {
81+
o.sshOptions[i] = option
82+
return nil
83+
}
84+
}
85+
o.sshOptions = append(o.sshOptions, option)
86+
return nil
87+
}
88+
5189
func (o sshConfigOptions) equal(other sshConfigOptions) bool {
5290
// Compare without side-effects or regard to order.
5391
opt1 := slices.Clone(o.sshOptions)
@@ -139,6 +177,7 @@ func configSSH() *cobra.Command {
139177
usePreviousOpts bool
140178
dryRun bool
141179
skipProxyCommand bool
180+
userHostPrefix string
142181
)
143182
cmd := &cobra.Command{
144183
Annotations: workspaceCommand,
@@ -156,12 +195,13 @@ func configSSH() *cobra.Command {
156195
),
157196
Args: cobra.ExactArgs(0),
158197
RunE: func(cmd *cobra.Command, _ []string) error {
198+
ctx := cmd.Context()
159199
client, err := CreateClient(cmd)
160200
if err != nil {
161201
return err
162202
}
163203

164-
recvWorkspaceConfigs := sshPrepareWorkspaceConfigs(cmd.Context(), client)
204+
recvWorkspaceConfigs := sshPrepareWorkspaceConfigs(ctx, client)
165205

166206
out := cmd.OutOrStdout()
167207
if dryRun {
@@ -220,6 +260,13 @@ func configSSH() *cobra.Command {
220260
if usePreviousOpts && lastConfig != nil {
221261
sshConfigOpts = *lastConfig
222262
} else if lastConfig != nil && !sshConfigOpts.equal(*lastConfig) {
263+
for _, v := range sshConfigOpts.sshOptions {
264+
// If the user passes an invalid option, we should catch
265+
// this early.
266+
if _, _, err := codersdk.ParseSSHConfigOption(v); err != nil {
267+
return xerrors.Errorf("invalid option from flag: %w", err)
268+
}
269+
}
223270
newOpts := sshConfigOpts.asList()
224271
newOptsMsg := "\n\n New options: none"
225272
if len(newOpts) > 0 {
@@ -269,42 +316,85 @@ func configSSH() *cobra.Command {
269316
if err != nil {
270317
return xerrors.Errorf("fetch workspace configs failed: %w", err)
271318
}
319+
320+
coderdConfig, err := client.SSHConfiguration(ctx)
321+
if err != nil {
322+
// If the error is 404, this deployment does not support
323+
// this endpoint yet. Do not error, just assume defaults.
324+
// TODO: Remove this in 2 months (May 31, 2023). Just return the error
325+
// and remove this 404 check.
326+
var sdkErr *codersdk.Error
327+
if !(xerrors.As(err, &sdkErr) && sdkErr.StatusCode() == http.StatusNotFound) {
328+
return xerrors.Errorf("fetch coderd config failed: %w", err)
329+
}
330+
coderdConfig.HostnamePrefix = "coder."
331+
}
332+
333+
if userHostPrefix != "" {
334+
// Override with user flag.
335+
coderdConfig.HostnamePrefix = userHostPrefix
336+
}
337+
272338
// Ensure stable sorting of output.
273339
slices.SortFunc(workspaceConfigs, func(a, b sshWorkspaceConfig) bool {
274340
return a.Name < b.Name
275341
})
276342
for _, wc := range workspaceConfigs {
277343
sort.Strings(wc.Hosts)
278344
// Write agent configuration.
279-
for _, hostname := range wc.Hosts {
280-
configOptions := []string{
281-
"Host coder." + hostname,
282-
}
283-
for _, option := range sshConfigOpts.sshOptions {
284-
configOptions = append(configOptions, "\t"+option)
285-
}
286-
configOptions = append(configOptions,
287-
"\tHostName coder."+hostname,
288-
"\tConnectTimeout=0",
289-
"\tStrictHostKeyChecking=no",
345+
for _, workspaceHostname := range wc.Hosts {
346+
sshHostname := fmt.Sprintf("%s%s", coderdConfig.HostnamePrefix, workspaceHostname)
347+
defaultOptions := []string{
348+
"HostName " + sshHostname,
349+
"ConnectTimeout=0",
350+
"StrictHostKeyChecking=no",
290351
// Without this, the "REMOTE HOST IDENTITY CHANGED"
291352
// message will appear.
292-
"\tUserKnownHostsFile=/dev/null",
353+
"UserKnownHostsFile=/dev/null",
293354
// This disables the "Warning: Permanently added 'hostname' (RSA) to the list of known hosts."
294355
// message from appearing on every SSH. This happens because we ignore the known hosts.
295-
"\tLogLevel ERROR",
296-
)
356+
"LogLevel ERROR",
357+
}
358+
297359
if !skipProxyCommand {
298-
configOptions = append(
299-
configOptions,
300-
fmt.Sprintf(
301-
"\tProxyCommand %s --global-config %s ssh --stdio %s",
302-
escapedCoderBinary, escapedGlobalConfig, hostname,
303-
),
304-
)
360+
defaultOptions = append(defaultOptions, fmt.Sprintf(
361+
"ProxyCommand %s --global-config %s ssh --stdio %s",
362+
escapedCoderBinary, escapedGlobalConfig, workspaceHostname,
363+
))
364+
}
365+
366+
var configOptions sshConfigOptions
367+
// Add standard options.
368+
err := configOptions.addOptions(defaultOptions...)
369+
if err != nil {
370+
return err
371+
}
372+
373+
// Override with deployment options
374+
for k, v := range coderdConfig.SSHConfigOptions {
375+
opt := fmt.Sprintf("%s %s", k, v)
376+
err := configOptions.addOptions(opt)
377+
if err != nil {
378+
return xerrors.Errorf("add coderd config option %q: %w", opt, err)
379+
}
380+
}
381+
// Override with flag options
382+
for _, opt := range sshConfigOpts.sshOptions {
383+
err := configOptions.addOptions(opt)
384+
if err != nil {
385+
return xerrors.Errorf("add flag config option %q: %w", opt, err)
386+
}
387+
}
388+
389+
hostBlock := []string{
390+
"Host " + sshHostname,
391+
}
392+
// Prefix with '\t'
393+
for _, v := range configOptions.sshOptions {
394+
hostBlock = append(hostBlock, "\t"+v)
305395
}
306396

307-
_, _ = buf.WriteString(strings.Join(configOptions, "\n"))
397+
_, _ = buf.WriteString(strings.Join(hostBlock, "\n"))
308398
_ = buf.WriteByte('\n')
309399
}
310400
}
@@ -363,7 +453,7 @@ func configSSH() *cobra.Command {
363453

364454
if len(workspaceConfigs) > 0 {
365455
_, _ = fmt.Fprintln(out, "You should now be able to ssh into your workspace.")
366-
_, _ = fmt.Fprintf(out, "For example, try running:\n\n\t$ ssh coder.%s\n", workspaceConfigs[0].Name)
456+
_, _ = fmt.Fprintf(out, "For example, try running:\n\n\t$ ssh %s%s\n", coderdConfig.HostnamePrefix, workspaceConfigs[0].Name)
367457
} else {
368458
_, _ = fmt.Fprint(out, "You don't have any workspaces yet, try creating one with:\n\n\t$ coder create <workspace>\n")
369459
}
@@ -376,6 +466,7 @@ func configSSH() *cobra.Command {
376466
cmd.Flags().BoolVarP(&skipProxyCommand, "skip-proxy-command", "", false, "Specifies whether the ProxyCommand option should be skipped. Useful for testing.")
377467
_ = cmd.Flags().MarkHidden("skip-proxy-command")
378468
cliflag.BoolVarP(cmd.Flags(), &usePreviousOpts, "use-previous-options", "", "CODER_SSH_USE_PREVIOUS_OPTIONS", false, "Specifies whether or not to keep options from previous run of config-ssh.")
469+
cmd.Flags().StringVarP(&userHostPrefix, "ssh-host-prefix", "", "", "Override the default host prefix.")
379470
cliui.AllowSkipPrompt(cmd)
380471

381472
return cmd

cli/configssh_internal_test.go

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"os/exec"
66
"path/filepath"
77
"runtime"
8+
"sort"
89
"strings"
910
"testing"
1011

@@ -179,3 +180,80 @@ func Test_sshConfigExecEscape(t *testing.T) {
179180
})
180181
}
181182
}
183+
184+
func Test_sshConfigOptions_addOption(t *testing.T) {
185+
t.Parallel()
186+
testCases := []struct {
187+
Name string
188+
Start []string
189+
Add []string
190+
Expect []string
191+
ExpectError bool
192+
}{
193+
{
194+
Name: "Empty",
195+
},
196+
{
197+
Name: "AddOne",
198+
Add: []string{"foo bar"},
199+
Expect: []string{
200+
"foo bar",
201+
},
202+
},
203+
{
204+
Name: "Replace",
205+
Start: []string{
206+
"foo bar",
207+
},
208+
Add: []string{"Foo baz"},
209+
Expect: []string{
210+
"Foo baz",
211+
},
212+
},
213+
{
214+
Name: "AddAndReplace",
215+
Start: []string{
216+
"a b",
217+
"foo bar",
218+
"buzz bazz",
219+
},
220+
Add: []string{
221+
"b c",
222+
"A hello",
223+
"hello world",
224+
},
225+
Expect: []string{
226+
"foo bar",
227+
"buzz bazz",
228+
"b c",
229+
"A hello",
230+
"hello world",
231+
},
232+
},
233+
{
234+
Name: "Error",
235+
Add: []string{"novalue"},
236+
ExpectError: true,
237+
},
238+
}
239+
240+
for _, tt := range testCases {
241+
tt := tt
242+
t.Run(tt.Name, func(t *testing.T) {
243+
t.Parallel()
244+
245+
o := sshConfigOptions{
246+
sshOptions: tt.Start,
247+
}
248+
err := o.addOptions(tt.Add...)
249+
if tt.ExpectError {
250+
require.Error(t, err)
251+
return
252+
}
253+
require.NoError(t, err)
254+
sort.Strings(tt.Expect)
255+
sort.Strings(o.sshOptions)
256+
require.Equal(t, tt.Expect, o.sshOptions)
257+
})
258+
}
259+
}

cli/configssh_test.go

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import (
2424
"github.com/coder/coder/agent"
2525
"github.com/coder/coder/cli/clitest"
2626
"github.com/coder/coder/coderd/coderdtest"
27+
"github.com/coder/coder/codersdk"
2728
"github.com/coder/coder/codersdk/agentsdk"
2829
"github.com/coder/coder/provisioner/echo"
2930
"github.com/coder/coder/provisionersdk/proto"
@@ -63,7 +64,18 @@ func sshConfigFileRead(t *testing.T, name string) string {
6364
func TestConfigSSH(t *testing.T) {
6465
t.Parallel()
6566

66-
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
67+
const hostname = "test-coder."
68+
const expectedKey = "ConnectionAttempts"
69+
client := coderdtest.New(t, &coderdtest.Options{
70+
IncludeProvisionerDaemon: true,
71+
ConfigSSH: codersdk.SSHConfigResponse{
72+
HostnamePrefix: hostname,
73+
SSHConfigOptions: map[string]string{
74+
// Something we can test for
75+
expectedKey: "3",
76+
},
77+
},
78+
})
6779
user := coderdtest.CreateFirstUser(t, client)
6880
authToken := uuid.NewString()
6981
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{
@@ -181,9 +193,13 @@ func TestConfigSSH(t *testing.T) {
181193

182194
<-doneChan
183195

196+
fileContents, err := os.ReadFile(sshConfigFile)
197+
require.NoError(t, err, "read ssh config file")
198+
require.Contains(t, string(fileContents), expectedKey, "ssh config file contains expected key")
199+
184200
home := filepath.Dir(filepath.Dir(sshConfigFile))
185201
// #nosec
186-
sshCmd := exec.Command("ssh", "-F", sshConfigFile, "coder."+workspace.Name, "echo", "test")
202+
sshCmd := exec.Command("ssh", "-F", sshConfigFile, hostname+workspace.Name, "echo", "test")
187203
pty = ptytest.New(t)
188204
// Set HOME because coder config is included from ~/.ssh/coder.
189205
sshCmd.Env = append(sshCmd.Env, fmt.Sprintf("HOME=%s", home))

cli/server.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -672,6 +672,11 @@ flags, and YAML configuration. The precedence is as follows:
672672
return xerrors.Errorf("parse real ip config: %w", err)
673673
}
674674

675+
configSSHOptions, err := cfg.SSHConfig.ParseOptions()
676+
if err != nil {
677+
return xerrors.Errorf("parse ssh config options %q: %w", cfg.SSHConfig.SSHConfigOptions.String(), err)
678+
}
679+
675680
options := &coderd.Options{
676681
AccessURL: cfg.AccessURL.Value(),
677682
AppHostname: appHostname,
@@ -696,6 +701,10 @@ flags, and YAML configuration. The precedence is as follows:
696701
LoginRateLimit: loginRateLimit,
697702
FilesRateLimit: filesRateLimit,
698703
HTTPClient: httpClient,
704+
SSHConfig: codersdk.SSHConfigResponse{
705+
HostnamePrefix: cfg.SSHConfig.DeploymentName.String(),
706+
SSHConfigOptions: configSSHOptions,
707+
},
699708
}
700709
if tlsConfig != nil {
701710
options.TLSCertificates = tlsConfig.Certificates

cli/testdata/coder_config-ssh_--help.golden

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ Flags:
1919
-h, --help help for config-ssh
2020
--ssh-config-file string Specifies the path to an SSH config.
2121
Consumes $CODER_SSH_CONFIG_FILE (default "~/.ssh/config")
22+
--ssh-host-prefix string Override the default host prefix.
2223
-o, --ssh-option stringArray Specifies additional SSH options to embed in each host stanza.
2324
--use-previous-options Specifies whether or not to keep options from previous run of
2425
config-ssh.

0 commit comments

Comments
 (0)