Skip to content

Commit

Permalink
Implement global tsh config file: /etc/tsh.yaml (#12598) (#12626)
Browse files Browse the repository at this point in the history
* Implement global tsh config file: `/etc/tsh.yaml`. The default location can be changed with `TELEPORT_GLOBAL_TSH_CONFIG` env var.

* The user config file is merged with the global config file. The user config file has a higher priority.

* If the global config file is absent, no error is raised.
  • Loading branch information
Tener authored May 13, 2022
1 parent dfccd4e commit c36f323
Show file tree
Hide file tree
Showing 5 changed files with 274 additions and 5 deletions.
29 changes: 29 additions & 0 deletions docs/pages/setup/reference/cli.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
21 changes: 17 additions & 4 deletions tool/tsh/tsh.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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."
Expand Down
14 changes: 14 additions & 0 deletions tool/tsh/tsh_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
51 changes: 50 additions & 1 deletion tool/tsh/tshconfig.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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
Expand All @@ -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 {
Expand All @@ -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
}
164 changes: 164 additions & 0 deletions tool/tsh/tshconfig_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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)
})
}
}

0 comments on commit c36f323

Please sign in to comment.