Implement global tsh config file: /etc/tsh.yaml (#12598)

* 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.
This commit is contained in:
Krzysztof Skrzętnicki 2022-05-13 09:31:48 +02:00 committed by GitHub
parent 30bf2626c5
commit 80bdb11c89
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
5 changed files with 252 additions and 7 deletions

View file

@ -698,10 +698,15 @@ 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 file
### tsh configuration files
`tsh` has an optional configuration file that is stored in `$TELEPORT_HOME/config/config.yaml`.
`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

View file

@ -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"
@ -288,6 +287,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.
@ -381,6 +383,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"
@ -754,11 +757,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 {
@ -3007,7 +3010,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.
@ -3035,6 +3041,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."

View file

@ -908,6 +908,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) {

View file

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

View file

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