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
This commit is contained in:
Steven Masley 2023-03-16 13:03:37 -05:00 committed by GitHub
parent 25e8abd63e
commit fe247c86eb
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
18 changed files with 642 additions and 49 deletions

View File

@ -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",
)
if !skipProxyCommand {
configOptions = append(
configOptions,
fmt.Sprintf(
"\tProxyCommand %s --global-config %s ssh --stdio %s",
escapedCoderBinary, escapedGlobalConfig, hostname,
),
)
"LogLevel ERROR",
}
_, _ = buf.WriteString(strings.Join(configOptions, "\n"))
if !skipProxyCommand {
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(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 <workspace>\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

View File

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

View File

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

View File

@ -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

View File

@ -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.

58
coderd/apidoc/docs.go generated
View File

@ -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": [
@ -6574,6 +6599,9 @@ const docTemplate = `{
"config": {
"type": "string"
},
"config_ssh": {
"$ref": "#/definitions/codersdk.SSHConfig"
},
"dangerous": {
"$ref": "#/definitions/codersdk.DangerousConfig"
},
@ -7688,6 +7716,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": {

View File

@ -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": [
@ -5870,6 +5891,9 @@
"config": {
"type": "string"
},
"config_ssh": {
"$ref": "#/definitions/codersdk.SSHConfig"
},
"dangerous": {
"$ref": "#/definitions/codersdk.DangerousConfig"
},
@ -6895,6 +6919,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": {

View File

@ -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(),
})
}

View File

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

View File

@ -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},

View File

@ -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,
}
}

View File

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

View File

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

View File

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

View File

@ -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

View File

@ -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

View File

@ -42,6 +42,13 @@ Specifies the path to an SSH config.
| Consumes | <code>$CODER_SSH_CONFIG_FILE</code> |
| Default | <code>~/.ssh/config</code> |
### --ssh-host-prefix
Override the default host prefix.
<br/>
| | |
| --- | --- |
### --ssh-option, -o
Specifies additional SSH options to embed in each host stanza.

View File

@ -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<string, string>
}
// From codersdk/serversentevents.go
export interface ServerSentEvent {
readonly type: ServerSentEventType