diff --git a/docs/pages/setup/reference/cli.mdx b/docs/pages/setup/reference/cli.mdx index 43240c045c420..efdc5669a6aab 100644 --- a/docs/pages/setup/reference/cli.mdx +++ b/docs/pages/setup/reference/cli.mdx @@ -662,7 +662,36 @@ Environment variables configure your tsh client and can help you avoid using fla | TELEPORT_USER | A Teleport user name | alice | | TELEPORT_ADD_KEYS_TO_AGENT | Specifies if the user certificate should be stored on the running SSH agent | yes, no, auto, only | | TELEPORT_USE_LOCAL_SSH_AGENT | Disable or enable local SSH agent integration | true, false | +| TELEPORT_GLOBAL_TSH_CONFIG | Override location of global `tsh` config file from default `/etc/tsh.yaml` | /opt/teleport/tsh.yaml | +### tsh configuration files + +`tsh` has an optional configuration files: +- global, shared config: `/etc/tsh.yaml`. Location can be overridden with `TELEPORT_GLOBAL_TSH_CONFIG` environment variable. +- user specific config: `$TELEPORT_HOME/config/config.yaml`. Unless changed, `TELEPORT_HOME` defaults to `~/.tsh`. + +The settings from both are merged, with the user config taking precedence. + +The `tsh` configuration file enables you to specify HTTP headers to be +included in requests to Teleport Proxy Servers with addresses matching +the `proxy` field. + +```yaml +add_headers: + - proxy: "*.example.com" # matching proxies will have headers included + headers: # headers are pairs to include in the http headers + foo: bar # Key/Value to be included in the http request +``` + +Adding HTTP headers may be useful, if for example an intermediate HTTP +proxy is in place that requires setting an authentication token: + +```yaml +add_headers: + - proxy: "*.infra.corp.xyz" + headers: + "Authorization": "Bearer tokentokentoken" +``` ## tctl diff --git a/tool/tsh/tsh.go b/tool/tsh/tsh.go index 52744c12fe3ea..91317022d0572 100644 --- a/tool/tsh/tsh.go +++ b/tool/tsh/tsh.go @@ -39,7 +39,6 @@ import ( "github.com/gravitational/teleport" "github.com/gravitational/teleport/api/constants" apidefaults "github.com/gravitational/teleport/api/defaults" - "github.com/gravitational/teleport/api/profile" "github.com/gravitational/teleport/api/types" "github.com/gravitational/teleport/api/types/wrappers" apiutils "github.com/gravitational/teleport/api/utils" @@ -273,6 +272,9 @@ type CLIConf struct { // HomePath is where tsh stores profiles HomePath string + // GlobalTshConfigPath is a path to global TSH config. Can be overridden with TELEPORT_GLOBAL_TSH_CONFIG. + GlobalTshConfigPath string + // LocalProxyPort is a port used by local proxy listener. LocalProxyPort string // LocalProxyCertFile is the client certificate used by local proxy. @@ -366,6 +368,7 @@ const ( userEnvVar = "TELEPORT_USER" addKeysToAgentEnvVar = "TELEPORT_ADD_KEYS_TO_AGENT" useLocalSSHAgentEnvVar = "TELEPORT_USE_LOCAL_SSH_AGENT" + globalTshConfigEnvVar = "TELEPORT_GLOBAL_TSH_CONFIG" clusterHelp = "Specify the Teleport cluster to connect" browserHelp = "Set to 'none' to suppress browser opening on login" @@ -715,11 +718,11 @@ func Run(args []string, opts ...cliOption) error { setEnvFlags(&cf, os.Getenv) - fullConfigPath := filepath.Join(profile.FullProfilePath(cf.HomePath), tshConfigPath) - confOptions, err := loadConfig(fullConfigPath) + confOptions, err := loadAllConfigs(cf) if err != nil { - return trace.Wrap(err, "failed to load tsh config from %s", fullConfigPath) + return trace.Wrap(err) } + cf.ExtraProxyHeaders = confOptions.ExtraHeaders switch command { @@ -2885,7 +2888,10 @@ func setEnvFlags(cf *CLIConf, fn envGetter) { if cf.KubernetesCluster == "" { setKubernetesClusterFromEnv(cf, fn) } + + // these can only be set with env vars. setTeleportHomeFromEnv(cf, fn) + setGlobalTshConfigPathFromEnv(cf, fn) } // setSiteNameFromEnv sets teleport site name from environment if configured. @@ -2913,6 +2919,13 @@ func setKubernetesClusterFromEnv(cf *CLIConf, fn envGetter) { } } +// setGlobalTshConfigPathFromEnv sets path to global tsh config file. +func setGlobalTshConfigPathFromEnv(cf *CLIConf, fn envGetter) { + if configPath := fn(globalTshConfigEnvVar); configPath != "" { + cf.GlobalTshConfigPath = path.Clean(configPath) + } +} + func handleUnimplementedError(ctx context.Context, perr error, cf CLIConf) error { const ( errMsgFormat = "This server does not implement this feature yet. Likely the client version you are using is newer than the server. The server version: %v, the client version: %v. Please upgrade the server." diff --git a/tool/tsh/tsh_test.go b/tool/tsh/tsh_test.go index 7e11a716b384b..91236bcc8ec1c 100644 --- a/tool/tsh/tsh_test.go +++ b/tool/tsh/tsh_test.go @@ -911,6 +911,20 @@ func TestEnvFlags(t *testing.T) { }, })) }) + + t.Run("tsh global config path", func(t *testing.T) { + t.Run("nothing set", testEnvFlag(testCase{ + outCLIConf: CLIConf{}, + })) + t.Run("TELEPORT_GLOBAL_TSH_CONFIG set", testEnvFlag(testCase{ + envMap: map[string]string{ + globalTshConfigEnvVar: "/opt/teleport/tsh.yaml", + }, + outCLIConf: CLIConf{ + GlobalTshConfigPath: "/opt/teleport/tsh.yaml", + }, + })) + }) } func TestKubeConfigUpdate(t *testing.T) { diff --git a/tool/tsh/tshconfig.go b/tool/tsh/tshconfig.go index f12e9b47fe550..fa37d1df4f474 100644 --- a/tool/tsh/tshconfig.go +++ b/tool/tsh/tshconfig.go @@ -20,6 +20,9 @@ import ( "errors" "io/fs" "os" + "path/filepath" + + "github.com/gravitational/teleport/api/profile" "github.com/gravitational/trace" "gopkg.in/yaml.v2" @@ -30,11 +33,14 @@ import ( // unmarshal errors. const tshConfigPath = "config/config.yaml" +// default location of global tsh config file. +const globalTshConfigPathDefault = "/etc/tsh.yaml" + // TshConfig represents configuration loaded from the tsh config file. type TshConfig struct { // ExtraHeaders are additional http headers to be included in // webclient requests. - ExtraHeaders []ExtraProxyHeaders `yaml:"add_headers"` + ExtraHeaders []ExtraProxyHeaders `yaml:"add_headers,omitempty"` } // ExtraProxyHeaders represents the headers to include with the @@ -46,6 +52,26 @@ type ExtraProxyHeaders struct { Headers map[string]string `yaml:"headers,omitempty"` } +// Merge two configs into one. The passed in otherConfig argument has higher priority. +func (config *TshConfig) Merge(otherConfig *TshConfig) TshConfig { + baseConfig := config + if baseConfig == nil { + baseConfig = &TshConfig{} + } + + if otherConfig == nil { + otherConfig = &TshConfig{} + } + + newConfig := TshConfig{} + + // extra headers + newConfig.ExtraHeaders = append(baseConfig.ExtraHeaders, otherConfig.ExtraHeaders...) + + return newConfig +} + +// loadConfig load a single config file from given path. If the path does not exist, an empty config is returned instead. func loadConfig(fullConfigPath string) (*TshConfig, error) { bs, err := os.ReadFile(fullConfigPath) if err != nil { @@ -61,3 +87,26 @@ func loadConfig(fullConfigPath string) (*TshConfig, error) { } return &cfg, nil } + +// loadAllConfigs loads all tsh configs and merges them in appropriate order. +func loadAllConfigs(cf CLIConf) (*TshConfig, error) { + // default to globalTshConfigPathDefault + globalConfigPath := cf.GlobalTshConfigPath + if globalConfigPath == "" { + globalConfigPath = globalTshConfigPathDefault + } + + globalConf, err := loadConfig(globalConfigPath) + if err != nil { + return nil, trace.Wrap(err, "failed to load global tsh config from %q", cf.GlobalTshConfigPath) + } + + fullConfigPath := filepath.Join(profile.FullProfilePath(cf.HomePath), tshConfigPath) + userConf, err := loadConfig(fullConfigPath) + if err != nil { + return nil, trace.Wrap(err, "failed to load tsh config from %q", fullConfigPath) + } + + confOptions := globalConf.Merge(userConf) + return &confOptions, nil +} diff --git a/tool/tsh/tshconfig_test.go b/tool/tsh/tshconfig_test.go index 4d8e8f56e243f..04c0eeb16b62d 100644 --- a/tool/tsh/tshconfig_test.go +++ b/tool/tsh/tshconfig_test.go @@ -18,10 +18,12 @@ package main import ( "os" + "path" "testing" "github.com/google/uuid" "github.com/stretchr/testify/require" + "gopkg.in/yaml.v2" ) func TestLoadConfigNonExistingFile(t *testing.T) { @@ -43,3 +45,165 @@ func TestLoadConfigEmptyFile(t *testing.T) { require.NoError(t, gotErr) require.Equal(t, &TshConfig{}, gotConfig) } + +func TestLoadAllConfigs(t *testing.T) { + writeConf := func(fn string, config TshConfig) { + dir, _ := path.Split(fn) + err := os.MkdirAll(dir, 0777) + require.NoError(t, err) + out, err := yaml.Marshal(config) + require.NoError(t, err) + err = os.WriteFile(fn, out, 0777) + require.NoError(t, err) + } + + tmp := t.TempDir() + + globalPath := path.Join(tmp, "etc", "tsh_global.yaml") + globalConf := TshConfig{ + ExtraHeaders: []ExtraProxyHeaders{{ + Proxy: "global", + Headers: map[string]string{"bar": "123"}, + }}, + } + + homeDir := path.Join(tmp, "home", "myuser", ".tsh") + userPath := path.Join(homeDir, "config", "config.yaml") + userConf := TshConfig{ + ExtraHeaders: []ExtraProxyHeaders{{ + Proxy: "user", + Headers: map[string]string{"bar": "456"}, + }}, + } + + writeConf(globalPath, globalConf) + writeConf(userPath, userConf) + + config, err := loadAllConfigs(CLIConf{ + GlobalTshConfigPath: globalPath, + HomePath: homeDir, + }) + + require.NoError(t, err) + require.Equal(t, &TshConfig{ + ExtraHeaders: []ExtraProxyHeaders{ + { + Proxy: "global", + Headers: map[string]string{"bar": "123"}, + }, + { + Proxy: "user", + Headers: map[string]string{"bar": "456"}, + }, + }, + }, config) + +} + +func TestTshConfigMerge(t *testing.T) { + sampleConfig := TshConfig{ + ExtraHeaders: []ExtraProxyHeaders{{ + Proxy: "foo", + Headers: map[string]string{ + "bar": "baz", + }, + }}, + } + + tests := []struct { + name string + config1 *TshConfig + config2 *TshConfig + want TshConfig + }{ + { + name: "empty + empty = empty", + config1: nil, + config2: nil, + want: TshConfig{}, + }, + { + name: "empty + x = x", + config1: &sampleConfig, + config2: nil, + want: sampleConfig, + }, + { + name: "x + empty = x", + config1: nil, + config2: &sampleConfig, + want: sampleConfig, + }, + { + name: "headers combine different proxies", + config1: &TshConfig{ + ExtraHeaders: []ExtraProxyHeaders{{ + Proxy: "foo", + Headers: map[string]string{ + "bar": "123", + }, + }}}, + config2: &TshConfig{ + ExtraHeaders: []ExtraProxyHeaders{{ + Proxy: "bar", + Headers: map[string]string{ + "baz": "456", + }, + }}}, + want: TshConfig{ + ExtraHeaders: []ExtraProxyHeaders{ + { + Proxy: "foo", + Headers: map[string]string{ + "bar": "123", + }, + }, + { + Proxy: "bar", + Headers: map[string]string{ + "baz": "456", + }, + }, + }}, + }, + { + name: "headers combine same proxy", + config1: &TshConfig{ + ExtraHeaders: []ExtraProxyHeaders{{ + Proxy: "foo", + Headers: map[string]string{ + "bar": "123", + }, + }}}, + config2: &TshConfig{ + ExtraHeaders: []ExtraProxyHeaders{{ + Proxy: "foo", + Headers: map[string]string{ + "bar": "456", + }, + }}}, + want: TshConfig{ + ExtraHeaders: []ExtraProxyHeaders{ + { + Proxy: "foo", + Headers: map[string]string{ + "bar": "123", + }, + }, + { + Proxy: "foo", + Headers: map[string]string{ + "bar": "456", + }, + }, + }}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + config3 := tt.config1.Merge(tt.config2) + require.Equal(t, tt.want, config3) + }) + } +}