mirror of https://github.com/coder/coder.git
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:
parent
25e8abd63e
commit
fe247c86eb
141
cli/configssh.go
141
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",
|
||||
)
|
||||
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
|
||||
|
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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))
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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": {
|
||||
|
|
|
@ -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": {
|
||||
|
|
|
@ -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(),
|
||||
})
|
||||
}
|
|
@ -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)
|
||||
|
|
|
@ -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},
|
||||
|
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Reference in New Issue