Skip to content
New issue

Have a question about this project? # for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “#”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? # to your account

[v9] Implement global tsh config file: /etc/tsh.yaml (#12598) #12626

Merged
merged 2 commits into from
May 13, 2022
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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)
})
}
}