diff --git a/cli/configssh.go b/cli/configssh.go index 090489c2ba611..71d6e45107988 100644 --- a/cli/configssh.go +++ b/cli/configssh.go @@ -8,6 +8,7 @@ import ( "fmt" "io" "io/fs" + "net/http" "os" "path/filepath" "runtime" @@ -48,6 +49,43 @@ type sshConfigOptions struct { sshOptions []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, _, err := codersdk.ParseSSHConfigOption(option) + if err != nil { + return err + } + for i, existing := range o.sshOptions { + // Override existing option if they share the same key. + // This is case-insensitive. Parsing each time might be a little slow, + // but it is ok. + existingKey, _, err := codersdk.ParseSSHConfigOption(existing) + if err != nil { + // Don't mess with original values if there is an error. + // This could have come from the user's manual edits. + continue + } + if strings.EqualFold(existingKey, key) { + o.sshOptions[i] = option + return nil + } + } + o.sshOptions = append(o.sshOptions, option) + return nil +} + func (o sshConfigOptions) equal(other sshConfigOptions) bool { // Compare without side-effects or regard to order. opt1 := slices.Clone(o.sshOptions) @@ -139,6 +177,7 @@ func configSSH() *cobra.Command { usePreviousOpts bool dryRun bool skipProxyCommand bool + userHostPrefix string ) cmd := &cobra.Command{ Annotations: workspaceCommand, @@ -156,12 +195,13 @@ func configSSH() *cobra.Command { ), Args: cobra.ExactArgs(0), RunE: func(cmd *cobra.Command, _ []string) error { + ctx := cmd.Context() client, err := CreateClient(cmd) if err != nil { return err } - recvWorkspaceConfigs := sshPrepareWorkspaceConfigs(cmd.Context(), client) + recvWorkspaceConfigs := sshPrepareWorkspaceConfigs(ctx, client) out := cmd.OutOrStdout() if dryRun { @@ -220,6 +260,13 @@ func configSSH() *cobra.Command { 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 { @@ -269,6 +316,25 @@ func configSSH() *cobra.Command { if err != nil { return xerrors.Errorf("fetch workspace configs failed: %w", err) } + + coderdConfig, err := client.SSHConfiguration(ctx) + if err != nil { + // If the error is 404, this deployment does not support + // this endpoint yet. Do not error, just assume defaults. + // TODO: Remove this in 2 months (May 31, 2023). Just return the error + // and remove this 404 check. + var sdkErr *codersdk.Error + if !(xerrors.As(err, &sdkErr) && sdkErr.StatusCode() == http.StatusNotFound) { + return xerrors.Errorf("fetch coderd config failed: %w", err) + } + coderdConfig.HostnamePrefix = "coder." + } + + if userHostPrefix != "" { + // Override with user flag. + coderdConfig.HostnamePrefix = userHostPrefix + } + // Ensure stable sorting of output. slices.SortFunc(workspaceConfigs, func(a, b sshWorkspaceConfig) bool { return a.Name < b.Name @@ -276,35 +342,59 @@ func configSSH() *cobra.Command { for _, wc := range workspaceConfigs { sort.Strings(wc.Hosts) // Write agent configuration. - for _, hostname := range wc.Hosts { - configOptions := []string{ - "Host coder." + hostname, - } - for _, option := range sshConfigOpts.sshOptions { - configOptions = append(configOptions, "\t"+option) - } - configOptions = append(configOptions, - "\tHostName coder."+hostname, - "\tConnectTimeout=0", - "\tStrictHostKeyChecking=no", + for _, workspaceHostname := range wc.Hosts { + sshHostname := fmt.Sprintf("%s%s", coderdConfig.HostnamePrefix, workspaceHostname) + defaultOptions := []string{ + "HostName " + sshHostname, + "ConnectTimeout=0", + "StrictHostKeyChecking=no", // Without this, the "REMOTE HOST IDENTITY CHANGED" // message will appear. - "\tUserKnownHostsFile=/dev/null", + "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. - "\tLogLevel ERROR", - ) + "LogLevel ERROR", + } + if !skipProxyCommand { - configOptions = append( - configOptions, - fmt.Sprintf( - "\tProxyCommand %s --global-config %s ssh --stdio %s", - escapedCoderBinary, escapedGlobalConfig, hostname, - ), - ) + defaultOptions = append(defaultOptions, fmt.Sprintf( + "ProxyCommand %s --global-config %s ssh --stdio %s", + escapedCoderBinary, escapedGlobalConfig, workspaceHostname, + )) + } + + var configOptions sshConfigOptions + // Add standard options. + err := configOptions.addOptions(defaultOptions...) + if err != nil { + return err + } + + // Override with deployment options + for k, v := range coderdConfig.SSHConfigOptions { + opt := fmt.Sprintf("%s %s", k, v) + err := configOptions.addOptions(opt) + if err != nil { + return xerrors.Errorf("add coderd config option %q: %w", opt, err) + } + } + // Override with flag options + for _, opt := range sshConfigOpts.sshOptions { + err := configOptions.addOptions(opt) + if err != nil { + return xerrors.Errorf("add flag config option %q: %w", opt, err) + } + } + + hostBlock := []string{ + "Host " + sshHostname, + } + // Prefix with '\t' + for _, v := range configOptions.sshOptions { + hostBlock = append(hostBlock, "\t"+v) } - _, _ = buf.WriteString(strings.Join(configOptions, "\n")) + _, _ = buf.WriteString(strings.Join(hostBlock, "\n")) _ = buf.WriteByte('\n') } } @@ -363,7 +453,7 @@ func configSSH() *cobra.Command { if len(workspaceConfigs) > 0 { _, _ = fmt.Fprintln(out, "You should now be able to ssh into your workspace.") - _, _ = fmt.Fprintf(out, "For example, try running:\n\n\t$ ssh coder.%s\n", workspaceConfigs[0].Name) + _, _ = fmt.Fprintf(out, "For example, try running:\n\n\t$ ssh %s%s\n", coderdConfig.HostnamePrefix, workspaceConfigs[0].Name) } else { _, _ = fmt.Fprint(out, "You don't have any workspaces yet, try creating one with:\n\n\t$ coder create \n") } @@ -376,6 +466,7 @@ func configSSH() *cobra.Command { cmd.Flags().BoolVarP(&skipProxyCommand, "skip-proxy-command", "", false, "Specifies whether the ProxyCommand option should be skipped. Useful for testing.") _ = cmd.Flags().MarkHidden("skip-proxy-command") 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.") + cmd.Flags().StringVarP(&userHostPrefix, "ssh-host-prefix", "", "", "Override the default host prefix.") cliui.AllowSkipPrompt(cmd) return cmd diff --git a/cli/configssh_internal_test.go b/cli/configssh_internal_test.go index 88de43b73a104..201dc9fea5c96 100644 --- a/cli/configssh_internal_test.go +++ b/cli/configssh_internal_test.go @@ -5,6 +5,7 @@ import ( "os/exec" "path/filepath" "runtime" + "sort" "strings" "testing" @@ -179,3 +180,80 @@ func Test_sshConfigExecEscape(t *testing.T) { }) } } + +func Test_sshConfigOptions_addOption(t *testing.T) { + t.Parallel() + testCases := []struct { + Name string + Start []string + Add []string + Expect []string + ExpectError bool + }{ + { + Name: "Empty", + }, + { + Name: "AddOne", + Add: []string{"foo bar"}, + Expect: []string{ + "foo bar", + }, + }, + { + Name: "Replace", + Start: []string{ + "foo bar", + }, + Add: []string{"Foo baz"}, + Expect: []string{ + "Foo baz", + }, + }, + { + Name: "AddAndReplace", + Start: []string{ + "a b", + "foo bar", + "buzz bazz", + }, + Add: []string{ + "b c", + "A hello", + "hello world", + }, + Expect: []string{ + "foo bar", + "buzz bazz", + "b c", + "A hello", + "hello world", + }, + }, + { + Name: "Error", + Add: []string{"novalue"}, + ExpectError: true, + }, + } + + for _, tt := range testCases { + tt := tt + t.Run(tt.Name, func(t *testing.T) { + t.Parallel() + + o := sshConfigOptions{ + sshOptions: tt.Start, + } + err := o.addOptions(tt.Add...) + if tt.ExpectError { + require.Error(t, err) + return + } + require.NoError(t, err) + sort.Strings(tt.Expect) + sort.Strings(o.sshOptions) + require.Equal(t, tt.Expect, o.sshOptions) + }) + } +} diff --git a/cli/configssh_test.go b/cli/configssh_test.go index 343a6af43dc58..13d36a1efed53 100644 --- a/cli/configssh_test.go +++ b/cli/configssh_test.go @@ -24,6 +24,7 @@ import ( "github.com/coder/coder/agent" "github.com/coder/coder/cli/clitest" "github.com/coder/coder/coderd/coderdtest" + "github.com/coder/coder/codersdk" "github.com/coder/coder/codersdk/agentsdk" "github.com/coder/coder/provisioner/echo" "github.com/coder/coder/provisionersdk/proto" @@ -63,7 +64,18 @@ func sshConfigFileRead(t *testing.T, name string) string { func TestConfigSSH(t *testing.T) { t.Parallel() - client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) + const hostname = "test-coder." + const expectedKey = "ConnectionAttempts" + client := coderdtest.New(t, &coderdtest.Options{ + IncludeProvisionerDaemon: true, + ConfigSSH: codersdk.SSHConfigResponse{ + HostnamePrefix: hostname, + SSHConfigOptions: map[string]string{ + // Something we can test for + expectedKey: "3", + }, + }, + }) user := coderdtest.CreateFirstUser(t, client) authToken := uuid.NewString() version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{ @@ -181,9 +193,13 @@ func TestConfigSSH(t *testing.T) { <-doneChan + 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") + home := filepath.Dir(filepath.Dir(sshConfigFile)) // #nosec - sshCmd := exec.Command("ssh", "-F", sshConfigFile, "coder."+workspace.Name, "echo", "test") + sshCmd := exec.Command("ssh", "-F", sshConfigFile, hostname+workspace.Name, "echo", "test") pty = ptytest.New(t) // Set HOME because coder config is included from ~/.ssh/coder. sshCmd.Env = append(sshCmd.Env, fmt.Sprintf("HOME=%s", home)) diff --git a/cli/server.go b/cli/server.go index 76997b2ea694a..db5b7ae09be82 100644 --- a/cli/server.go +++ b/cli/server.go @@ -672,6 +672,11 @@ flags, and YAML configuration. The precedence is as follows: return xerrors.Errorf("parse real ip config: %w", err) } + configSSHOptions, err := cfg.SSHConfig.ParseOptions() + if err != nil { + return xerrors.Errorf("parse ssh config options %q: %w", cfg.SSHConfig.SSHConfigOptions.String(), err) + } + options := &coderd.Options{ AccessURL: cfg.AccessURL.Value(), AppHostname: appHostname, @@ -696,6 +701,10 @@ flags, and YAML configuration. The precedence is as follows: LoginRateLimit: loginRateLimit, FilesRateLimit: filesRateLimit, HTTPClient: httpClient, + SSHConfig: codersdk.SSHConfigResponse{ + HostnamePrefix: cfg.SSHConfig.DeploymentName.String(), + SSHConfigOptions: configSSHOptions, + }, } if tlsConfig != nil { options.TLSCertificates = tlsConfig.Certificates diff --git a/cli/testdata/coder_config-ssh_--help.golden b/cli/testdata/coder_config-ssh_--help.golden index b088c8586bc5b..1fe06b04be12c 100644 --- a/cli/testdata/coder_config-ssh_--help.golden +++ b/cli/testdata/coder_config-ssh_--help.golden @@ -19,6 +19,7 @@ Flags: -h, --help help for config-ssh --ssh-config-file string Specifies the path to an SSH config. Consumes $CODER_SSH_CONFIG_FILE (default "~/.ssh/config") + --ssh-host-prefix string Override the default host prefix. -o, --ssh-option stringArray Specifies additional SSH options to embed in each host stanza. --use-previous-options Specifies whether or not to keep options from previous run of config-ssh. diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 5785cb57f4ac8..062423f9cef91 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -384,6 +384,31 @@ const docTemplate = `{ } } }, + "/deployment/ssh": { + "get": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "General" + ], + "summary": "SSH Config", + "operationId": "ssh-config", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/codersdk.SSHConfigResponse" + } + } + } + } + }, "/deployment/stats": { "get": { "security": [ @@ -6540,6 +6565,9 @@ const docTemplate = `{ "config": { "type": "string" }, + "config_ssh": { + "$ref": "#/definitions/codersdk.SSHConfig" + }, "dangerous": { "$ref": "#/definitions/codersdk.DangerousConfig" }, @@ -7654,6 +7682,36 @@ const docTemplate = `{ } } }, + "codersdk.SSHConfig": { + "type": "object", + "properties": { + "deploymentName": { + "description": "DeploymentName is the config-ssh Hostname prefix", + "type": "string" + }, + "sshconfigOptions": { + "description": "SSHConfigOptions are additional options to add to the ssh config file.\nThis will override defaults.", + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "codersdk.SSHConfigResponse": { + "type": "object", + "properties": { + "hostname_prefix": { + "type": "string" + }, + "ssh_config_options": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + }, "codersdk.ServiceBannerConfig": { "type": "object", "properties": { diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index dc7a9dd435e4a..db43c036df2fc 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -326,6 +326,27 @@ } } }, + "/deployment/ssh": { + "get": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "produces": ["application/json"], + "tags": ["General"], + "summary": "SSH Config", + "operationId": "ssh-config", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/codersdk.SSHConfigResponse" + } + } + } + } + }, "/deployment/stats": { "get": { "security": [ @@ -5840,6 +5861,9 @@ "config": { "type": "string" }, + "config_ssh": { + "$ref": "#/definitions/codersdk.SSHConfig" + }, "dangerous": { "$ref": "#/definitions/codersdk.DangerousConfig" }, @@ -6865,6 +6889,36 @@ } } }, + "codersdk.SSHConfig": { + "type": "object", + "properties": { + "deploymentName": { + "description": "DeploymentName is the config-ssh Hostname prefix", + "type": "string" + }, + "sshconfigOptions": { + "description": "SSHConfigOptions are additional options to add to the ssh config file.\nThis will override defaults.", + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "codersdk.SSHConfigResponse": { + "type": "object", + "properties": { + "hostname_prefix": { + "type": "string" + }, + "ssh_config_options": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + }, "codersdk.ServiceBannerConfig": { "type": "object", "properties": { diff --git a/coderd/buildinfo.go b/coderd/buildinfo.go deleted file mode 100644 index 76497f8990e8a..0000000000000 --- a/coderd/buildinfo.go +++ /dev/null @@ -1,22 +0,0 @@ -package coderd - -import ( - "net/http" - - "github.com/coder/coder/buildinfo" - "github.com/coder/coder/coderd/httpapi" - "github.com/coder/coder/codersdk" -) - -// @Summary Build info -// @ID build-info -// @Produce json -// @Tags General -// @Success 200 {object} codersdk.BuildInfoResponse -// @Router /buildinfo [get] -func buildInfo(rw http.ResponseWriter, r *http.Request) { - httpapi.Write(r.Context(), rw, http.StatusOK, codersdk.BuildInfoResponse{ - ExternalURL: buildinfo.ExternalURL(), - Version: buildinfo.Version(), - }) -} diff --git a/coderd/coderd.go b/coderd/coderd.go index ed53b095751e5..6e626aa742af4 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -138,6 +138,9 @@ type Options struct { DeploymentValues *codersdk.DeploymentValues UpdateCheckOptions *updatecheck.Options // Set non-nil to enable update checking. + // SSHConfig is the response clients use to configure config-ssh locally. + SSHConfig codersdk.SSHConfigResponse + HTTPClient *http.Client } @@ -210,6 +213,9 @@ func New(options *Options) *API { if options.Auditor == nil { options.Auditor = audit.NewNop() } + if options.SSHConfig.HostnamePrefix == "" { + options.SSHConfig.HostnamePrefix = "coder." + } // TODO: remove this once we promote authz_querier out of experiments. if experiments.Enabled(codersdk.ExperimentAuthzQuerier) { options.Database = dbauthz.New( @@ -403,6 +409,7 @@ func New(options *Options) *API { r.Use(apiKeyMiddleware) r.Get("/config", api.deploymentValues) r.Get("/stats", api.deploymentStats) + r.Get("/ssh", api.sshConfig) }) r.Route("/experiments", func(r chi.Router) { r.Use(apiKeyMiddleware) diff --git a/coderd/coderdtest/authorize.go b/coderd/coderdtest/authorize.go index 4758b1ee46923..de6a333e98c5e 100644 --- a/coderd/coderdtest/authorize.go +++ b/coderd/coderdtest/authorize.go @@ -57,6 +57,7 @@ func AGPLRoutes(a *AuthTester) (map[string]string, map[string]RouteCheck) { "POST:/api/v2/csp/reports": {NoAuthorize: true}, "POST:/api/v2/authcheck": {NoAuthorize: true}, "GET:/api/v2/applications/host": {NoAuthorize: true}, + "GET:/api/v2/deployment/ssh": {NoAuthorize: true, StatusCode: http.StatusOK}, // Has it's own auth "GET:/api/v2/users/oauth2/github/callback": {NoAuthorize: true}, diff --git a/coderd/coderdtest/coderdtest.go b/coderd/coderdtest/coderdtest.go index 987f912c35165..f0e7cc9711c22 100644 --- a/coderd/coderdtest/coderdtest.go +++ b/coderd/coderdtest/coderdtest.go @@ -126,6 +126,8 @@ type Options struct { Database database.Store Pubsub database.Pubsub + ConfigSSH codersdk.SSHConfigResponse + SwaggerEndpoint bool } @@ -333,6 +335,7 @@ func NewOptions(t *testing.T, options *Options) (func(http.Handler), context.Can UpdateCheckOptions: options.UpdateCheckOptions, SwaggerEndpoint: options.SwaggerEndpoint, AppSigningKey: AppSigningKey, + SSHConfig: options.ConfigSSH, } } diff --git a/coderd/deployment.go b/coderd/deployment.go index 158b07c5bfbdd..e9cb55c270c11 100644 --- a/coderd/deployment.go +++ b/coderd/deployment.go @@ -3,6 +3,7 @@ package coderd import ( "net/http" + "github.com/coder/coder/buildinfo" "github.com/coder/coder/coderd/httpapi" "github.com/coder/coder/coderd/rbac" "github.com/coder/coder/codersdk" @@ -59,3 +60,27 @@ func (api *API) deploymentStats(rw http.ResponseWriter, r *http.Request) { httpapi.Write(r.Context(), rw, http.StatusOK, stats) } + +// @Summary Build info +// @ID build-info +// @Produce json +// @Tags General +// @Success 200 {object} codersdk.BuildInfoResponse +// @Router /buildinfo [get] +func buildInfo(rw http.ResponseWriter, r *http.Request) { + httpapi.Write(r.Context(), rw, http.StatusOK, codersdk.BuildInfoResponse{ + ExternalURL: buildinfo.ExternalURL(), + Version: buildinfo.Version(), + }) +} + +// @Summary SSH Config +// @ID ssh-config +// @Security CoderSessionToken +// @Produce json +// @Tags General +// @Success 200 {object} codersdk.SSHConfigResponse +// @Router /deployment/ssh [get] +func (api *API) sshConfig(rw http.ResponseWriter, r *http.Request) { + httpapi.Write(r.Context(), rw, http.StatusOK, api.SSHConfig) +} diff --git a/codersdk/deployment.go b/codersdk/deployment.go index 4135a95bfec46..8b822219cde96 100644 --- a/codersdk/deployment.go +++ b/codersdk/deployment.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "flag" + "fmt" "math" "net/http" "os" @@ -160,6 +161,7 @@ type DeploymentValues struct { DisablePasswordAuth clibase.Bool `json:"disable_password_auth,omitempty" typescript:",notnull"` Support SupportConfig `json:"support,omitempty" typescript:",notnull"` GitAuthProviders clibase.Struct[[]GitAuthConfig] `json:"git_auth,omitempty" typescript:",notnull"` + SSHConfig SSHConfig `json:"config_ssh,omitempty" typescript:",notnull"` Config clibase.String `json:"config,omitempty" typescript:",notnull"` WriteConfig clibase.Bool `json:"write_config,omitempty" typescript:",notnull"` @@ -168,6 +170,40 @@ type DeploymentValues struct { Address clibase.HostPort `json:"address,omitempty" typescript:",notnull"` } +// SSHConfig is configuration the cli & vscode extension use for configuring +// ssh connections. +type SSHConfig struct { + // DeploymentName is the config-ssh Hostname prefix + DeploymentName clibase.String + // SSHConfigOptions are additional options to add to the ssh config file. + // This will override defaults. + SSHConfigOptions clibase.Strings +} + +func (c SSHConfig) ParseOptions() (map[string]string, error) { + m := make(map[string]string) + for _, opt := range c.SSHConfigOptions { + key, value, err := ParseSSHConfigOption(opt) + if err != nil { + return nil, err + } + m[key] = value + } + return m, nil +} + +// ParseSSHConfigOption parses a single ssh config option into it's key/value pair. +func ParseSSHConfigOption(opt string) (key string, value string, err error) { + // An equal sign or whitespace is the separator between the key and value. + idx := strings.IndexFunc(opt, func(r rune) bool { + return r == ' ' || r == '=' + }) + if idx == -1 { + return "", "", fmt.Errorf("invalid config-ssh option %q", opt) + } + return opt[:idx], opt[idx+1:], nil +} + type DERP struct { Server DERPServerConfig `json:"server" typescript:",notnull"` Config DERPConfig `json:"config" typescript:",notnull"` @@ -390,6 +426,11 @@ when required by your organization's security policy.`, deploymentGroupDangerous = clibase.Group{ Name: "⚠️ Dangerous", } + deploymentGroupClient = clibase.Group{ + Name: "Client", + Description: "These options change the behavior of how clients interact with the Coder. " + + "Clients include the coder cli, vs code extension, and the web UI.", + } deploymentGroupConfig = clibase.Group{ Name: "Config", Description: `Use a YAML configuration file when your server launch become unwieldy.`, @@ -1265,6 +1306,29 @@ when required by your organization's security policy.`, Group: &deploymentGroupConfig, Value: &c.Config, }, + { + Name: "SSH Host Prefix", + Description: "The SSH deployment prefix is used in the Host of the ssh config.", + Flag: "ssh-hostname-prefix", + Env: "SSH_HOSTNAME_PREFIX", + YAML: "sshHostnamePrefix", + Group: &deploymentGroupClient, + Value: &c.SSHConfig.DeploymentName, + Hidden: false, + Default: "coder.", + }, + { + Name: "SSH Config Options", + Description: "These SSH config options will override the default SSH config options. " + + "Provide options in \"key=value\" or \"key value\" format separated by commas." + + "Using this incorrectly can break SSH to your deployment, use cautiously.", + Flag: "ssh-config-options", + Env: "SSH_CONFIG_OPTIONS", + YAML: "sshConfigOptions", + Group: &deploymentGroupClient, + Value: &c.SSHConfig.SSHConfigOptions, + Hidden: false, + }, { Name: "Write Config", Description: ` @@ -1580,3 +1644,25 @@ type DeploymentStats struct { Workspaces WorkspaceDeploymentStats `json:"workspaces"` SessionCount SessionCountDeploymentStats `json:"session_count"` } + +type SSHConfigResponse struct { + HostnamePrefix string `json:"hostname_prefix"` + SSHConfigOptions map[string]string `json:"ssh_config_options"` +} + +// SSHConfiguration returns information about the SSH configuration for the +// Coder instance. +func (c *Client) SSHConfiguration(ctx context.Context) (SSHConfigResponse, error) { + res, err := c.Request(ctx, http.MethodGet, "/api/v2/deployment/ssh", nil) + if err != nil { + return SSHConfigResponse{}, err + } + defer res.Body.Close() + + if res.StatusCode != http.StatusOK { + return SSHConfigResponse{}, ReadBodyAsError(res) + } + + var sshConfig SSHConfigResponse + return sshConfig, json.NewDecoder(res.Body).Decode(&sshConfig) +} diff --git a/codersdk/deployment_test.go b/codersdk/deployment_test.go index 54a1af10ff256..8f1e3de930d09 100644 --- a/codersdk/deployment_test.go +++ b/codersdk/deployment_test.go @@ -3,6 +3,9 @@ package codersdk_test import ( "testing" + "github.com/stretchr/testify/require" + + "github.com/coder/coder/cli/clibase" "github.com/coder/coder/codersdk" ) @@ -105,3 +108,82 @@ func TestDeploymentValues_HighlyConfigurable(t *testing.T) { t.Errorf("Excluded option %q is not in the deployment config. Remove it?", opt) } } + +func TestSSHConfig_ParseOptions(t *testing.T) { + t.Parallel() + + testCases := []struct { + Name string + ConfigOptions clibase.Strings + ExpectError bool + Expect map[string]string + }{ + { + Name: "Empty", + ConfigOptions: []string{}, + Expect: map[string]string{}, + }, + { + Name: "Whitespace", + ConfigOptions: []string{ + "test value", + }, + Expect: map[string]string{ + "test": "value", + }, + }, + { + Name: "SimpleValueEqual", + ConfigOptions: []string{ + "test=value", + }, + Expect: map[string]string{ + "test": "value", + }, + }, + { + Name: "SimpleValues", + ConfigOptions: []string{ + "test=value", + "foo=bar", + }, + Expect: map[string]string{ + "test": "value", + "foo": "bar", + }, + }, + { + Name: "ValueWithQuote", + ConfigOptions: []string{ + "bar=buzz=bazz", + }, + Expect: map[string]string{ + "bar": "buzz=bazz", + }, + }, + { + Name: "NoEquals", + ConfigOptions: []string{ + "foobar", + }, + ExpectError: true, + }, + } + + for _, tt := range testCases { + tt := tt + t.Run(tt.Name, func(t *testing.T) { + t.Parallel() + c := codersdk.SSHConfig{ + SSHConfigOptions: tt.ConfigOptions, + } + got, err := c.ParseOptions() + if tt.ExpectError { + require.Error(t, err, tt.ConfigOptions.String()) + } else { + require.NoError(t, err, tt.ConfigOptions.String()) + require.Equalf(t, tt.Expect, got, tt.ConfigOptions.String()) + } + }) + } +} diff --git a/docs/api/general.md b/docs/api/general.md index 679b6405af156..e687a71945264 100644 --- a/docs/api/general.md +++ b/docs/api/general.md @@ -155,6 +155,10 @@ curl -X GET http://coder-server:8080/api/v2/deployment/config \ "browser_only": true, "cache_directory": "string", "config": "string", + "config_ssh": { + "deploymentName": "string", + "sshconfigOptions": ["string"] + }, "dangerous": { "allow_path_app_sharing": true, "allow_path_app_site_owner_access": true @@ -398,6 +402,41 @@ curl -X GET http://coder-server:8080/api/v2/deployment/config \ To perform this operation, you must be authenticated. [Learn more](authentication.md). +## SSH Config + +### Code samples + +```shell +# Example request using curl +curl -X GET http://coder-server:8080/api/v2/deployment/ssh \ + -H 'Accept: application/json' \ + -H 'Coder-Session-Token: API_KEY' +``` + +`GET /deployment/ssh` + +### Example responses + +> 200 Response + +```json +{ + "hostname_prefix": "string", + "ssh_config_options": { + "property1": "string", + "property2": "string" + } +} +``` + +### Responses + +| Status | Meaning | Description | Schema | +| ------ | ------------------------------------------------------- | ----------- | ------------------------------------------------------------------ | +| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | [codersdk.SSHConfigResponse](schemas.md#codersdksshconfigresponse) | + +To perform this operation, you must be authenticated. [Learn more](authentication.md). + ## Get deployment stats ### Code samples diff --git a/docs/api/schemas.md b/docs/api/schemas.md index 7ee906d6195b7..887080bc42134 100644 --- a/docs/api/schemas.md +++ b/docs/api/schemas.md @@ -1687,6 +1687,10 @@ CreateParameterRequest is a structure used to create a new parameter value for a "browser_only": true, "cache_directory": "string", "config": "string", + "config_ssh": { + "deploymentName": "string", + "sshconfigOptions": ["string"] + }, "dangerous": { "allow_path_app_sharing": true, "allow_path_app_site_owner_access": true @@ -2027,6 +2031,10 @@ CreateParameterRequest is a structure used to create a new parameter value for a "browser_only": true, "cache_directory": "string", "config": "string", + "config_ssh": { + "deploymentName": "string", + "sshconfigOptions": ["string"] + }, "dangerous": { "allow_path_app_sharing": true, "allow_path_app_site_owner_access": true @@ -2238,6 +2246,7 @@ CreateParameterRequest is a structure used to create a new parameter value for a | `browser_only` | boolean | false | | | | `cache_directory` | string | false | | | | `config` | string | false | | | +| `config_ssh` | [codersdk.SSHConfig](#codersdksshconfig) | false | | | | `dangerous` | [codersdk.DangerousConfig](#codersdkdangerousconfig) | false | | | | `derp` | [codersdk.DERP](#codersdkderp) | false | | | | `disable_password_auth` | boolean | false | | | @@ -3318,6 +3327,42 @@ Parameter represents a set value for the scope. | `display_name` | string | false | | | | `name` | string | false | | | +## codersdk.SSHConfig + +```json +{ + "deploymentName": "string", + "sshconfigOptions": ["string"] +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +| ------------------ | --------------- | -------- | ------------ | --------------------------------------------------------------------------------------------------- | +| `deploymentName` | string | false | | Deploymentname is the config-ssh Hostname prefix | +| `sshconfigOptions` | array of string | false | | Sshconfigoptions are additional options to add to the ssh config file. This will override defaults. | + +## codersdk.SSHConfigResponse + +```json +{ + "hostname_prefix": "string", + "ssh_config_options": { + "property1": "string", + "property2": "string" + } +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +| -------------------- | ------ | -------- | ------------ | ----------- | +| `hostname_prefix` | string | false | | | +| `ssh_config_options` | object | false | | | +| » `[any property]` | string | false | | | + ## codersdk.ServiceBannerConfig ```json diff --git a/docs/cli/coder_config-ssh.md b/docs/cli/coder_config-ssh.md index ca5c55f688adb..a1943e93af990 100644 --- a/docs/cli/coder_config-ssh.md +++ b/docs/cli/coder_config-ssh.md @@ -42,6 +42,13 @@ Specifies the path to an SSH config. | Consumes | $CODER_SSH_CONFIG_FILE | | Default | ~/.ssh/config | +### --ssh-host-prefix + +Override the default host prefix. +
+| | | +| --- | --- | + ### --ssh-option, -o Specifies additional SSH options to embed in each host stanza. diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 9cb75fd64fa6d..24f4e2010a58f 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -359,6 +359,7 @@ export interface DeploymentValues { // Named type "github.com/coder/coder/cli/clibase.Struct[[]github.com/coder/coder/codersdk.GitAuthConfig]" unknown, using "any" // eslint-disable-next-line @typescript-eslint/no-explicit-any -- TODO explain why this is needed readonly git_auth?: any + readonly config_ssh?: SSHConfig readonly config?: string readonly write_config?: boolean // Named type "github.com/coder/coder/cli/clibase.HostPort" unknown, using "any" @@ -667,6 +668,18 @@ export interface Role { readonly display_name: string } +// From codersdk/deployment.go +export interface SSHConfig { + readonly DeploymentName: string + readonly SSHConfigOptions: string[] +} + +// From codersdk/deployment.go +export interface SSHConfigResponse { + readonly hostname_prefix: string + readonly ssh_config_options: Record +} + // From codersdk/serversentevents.go export interface ServerSentEvent { readonly type: ServerSentEventType