From efa980258b9749d80deec08beb13e0dcd9919563 Mon Sep 17 00:00:00 2001 From: Andrew Lytvynov Date: Mon, 9 Nov 2020 09:32:01 -0800 Subject: [PATCH] Add "tsh kube" commands 1. `tsh kube clusters` - lists registered kubernetes clusters note: this only includes clusters connected via `kubernetes_service` 2. `tsh kube credentials` - returns TLS credentials for a specific kube cluster; this is a hidden command used as an exec plugin for kubectl 3. `tsh kube login` - switches the kubectl context to one of the registered clusters; roughly equivalent to `kubectl config use-context` When updating kubeconfigs, tsh now uses the exec plugin mode: https://kubernetes.io/docs/reference/access-authn-authz/authentication/#client-go-credential-plugins This means that on each kubectl run, kubectl will execute tsh with special arguments to get the TLS credentials. Using tsh as exec plugin allows us to put a login prompt when certs expire. It also lets us lazy-initialize TLS certs for kubernetes clusters. --- e | 2 +- lib/auth/auth.go | 2 +- lib/auth/auth_test.go | 75 +++++++ lib/client/api.go | 21 +- lib/client/client.go | 11 +- lib/client/identityfile/identity.go | 6 +- lib/client/interfaces.go | 63 +++--- lib/client/keyagent.go | 14 +- lib/client/keystore.go | 116 +++++++++-- lib/client/keystore_test.go | 7 +- lib/kube/kubeconfig/kubeconfig.go | 179 +++++++++++++---- lib/kube/kubeconfig/kubeconfig_test.go | 22 +- lib/services/role.go | 1 + lib/services/server.go | 12 ++ tool/tctl/common/tctl.go | 2 +- tool/tsh/kube.go | 268 +++++++++++++++++++++++++ tool/tsh/tsh.go | 29 ++- tool/tsh/tsh_test.go | 2 +- 18 files changed, 722 insertions(+), 110 deletions(-) create mode 100644 tool/tsh/kube.go diff --git a/e b/e index 166abf71aa7c6..c3ac851bbb267 160000 --- a/e +++ b/e @@ -1 +1 @@ -Subproject commit 166abf71aa7c69fb6db71add890e26ba56a667da +Subproject commit c3ac851bbb2677c16ecadc5a2596582641396188 diff --git a/lib/auth/auth.go b/lib/auth/auth.go index d84fbbc4aeea8..4080981ce66f3 100644 --- a/lib/auth/auth.go +++ b/lib/auth/auth.go @@ -603,7 +603,7 @@ func (a *Server) generateUserCert(req certRequest) (*certs, error) { // Only validate/default kubernetes cluster name for the current teleport // cluster. If this cert is targeting a trusted teleport cluster, leave all // the kubernetes cluster validation up to them. - if req.routeToCluster == clusterName { + if req.routeToCluster == "" || req.routeToCluster == clusterName { req.kubernetesCluster, err = kubeutils.CheckOrSetKubeCluster(a.closeCtx, a.Presence, req.kubernetesCluster, clusterName) if err != nil { if !trace.IsNotFound(err) { diff --git a/lib/auth/auth_test.go b/lib/auth/auth_test.go index e06d2fab80a3c..4f2879336b0b8 100644 --- a/lib/auth/auth_test.go +++ b/lib/auth/auth_test.go @@ -339,6 +339,81 @@ func (s *AuthSuite) TestAuthenticateSSHUser(c *C) { c.Assert(err, IsNil) c.Assert(*gotID, DeepEquals, wantID) + // Register a kubernetes cluster to verify the defaulting logic in TLS cert + // generation. + err = s.a.UpsertKubeService(ctx, &services.ServerV2{ + Metadata: services.Metadata{Name: "kube-service"}, + Kind: services.KindKubeService, + Version: services.V2, + Spec: services.ServerSpecV2{ + KubernetesClusters: []*services.KubernetesCluster{{Name: "root-kube-cluster"}}, + }, + }) + c.Assert(err, IsNil) + + // Login specifying a valid kube cluster. It should appear in the TLS cert. + resp, err = s.a.AuthenticateSSHUser(AuthenticateSSHRequest{ + AuthenticateUserRequest: AuthenticateUserRequest{ + Username: user, + Pass: &PassCreds{Password: pass}, + }, + PublicKey: pub, + TTL: time.Hour, + RouteToCluster: "me.localhost", + KubernetesCluster: "root-kube-cluster", + }) + c.Assert(err, IsNil) + c.Assert(resp.Username, Equals, user) + gotTLSCert, err = tlsca.ParseCertificatePEM(resp.TLSCert) + c.Assert(err, IsNil) + wantID = tlsca.Identity{ + Username: user, + Groups: []string{role.GetName()}, + Principals: []string{user}, + KubernetesUsers: []string{user}, + KubernetesGroups: []string{"system:masters"}, + KubernetesCluster: "root-kube-cluster", + Expires: gotTLSCert.NotAfter, + RouteToCluster: "me.localhost", + TeleportCluster: "me.localhost", + } + gotID, err = tlsca.FromSubject(gotTLSCert.Subject, gotTLSCert.NotAfter) + c.Assert(err, IsNil) + c.Assert(*gotID, DeepEquals, wantID) + + // Login without specifying kube cluster. A registered one should be picked + // automatically. + resp, err = s.a.AuthenticateSSHUser(AuthenticateSSHRequest{ + AuthenticateUserRequest: AuthenticateUserRequest{ + Username: user, + Pass: &PassCreds{Password: pass}, + }, + PublicKey: pub, + TTL: time.Hour, + RouteToCluster: "me.localhost", + // Intentionally empty, auth server should default to a registered + // kubernetes cluster. + KubernetesCluster: "", + }) + c.Assert(err, IsNil) + c.Assert(resp.Username, Equals, user) + gotTLSCert, err = tlsca.ParseCertificatePEM(resp.TLSCert) + c.Assert(err, IsNil) + wantID = tlsca.Identity{ + Username: user, + Groups: []string{role.GetName()}, + Principals: []string{user}, + KubernetesUsers: []string{user}, + KubernetesGroups: []string{"system:masters"}, + KubernetesCluster: "root-kube-cluster", + Expires: gotTLSCert.NotAfter, + RouteToCluster: "me.localhost", + TeleportCluster: "me.localhost", + } + gotID, err = tlsca.FromSubject(gotTLSCert.Subject, gotTLSCert.NotAfter) + c.Assert(err, IsNil) + c.Assert(*gotID, DeepEquals, wantID) + // Login specifying an invalid kube cluster. This should fail. _, err = s.a.AuthenticateSSHUser(AuthenticateSSHRequest{ AuthenticateUserRequest: AuthenticateUserRequest{ diff --git a/lib/client/api.go b/lib/client/api.go index f835a0247147d..b5183abcc12fa 100644 --- a/lib/client/api.go +++ b/lib/client/api.go @@ -402,7 +402,7 @@ func readProfile(profileDir string, profileName string) (*ProfileStatus, error) if err != nil { return nil, trace.Wrap(err) } - key, err := store.GetKey(profile.Name(), profile.Username) + key, err := store.GetKey(profile.Name(), profile.Username, WithKubeCerts(profile.SiteName)) if err != nil { return nil, trace.Wrap(err) } @@ -474,7 +474,7 @@ func readProfile(profileDir string, profileName string) (*ProfileStatus, error) clusterName = profile.Name() } - tlsCert, err := key.TLSCertificate() + tlsCert, err := key.TeleportTLSCertificate() if err != nil { return nil, trace.Wrap(err) } @@ -906,6 +906,8 @@ func (tc *TeleportClient) ReissueUserCerts(ctx context.Context, params ReissuePa if err != nil { return trace.Wrap(err) } + defer proxyClient.Close() + return proxyClient.ReissueUserCerts(ctx, params) } @@ -915,6 +917,8 @@ func (tc *TeleportClient) CreateAccessRequest(ctx context.Context, req services. if err != nil { return trace.Wrap(err) } + defer proxyClient.Close() + return proxyClient.CreateAccessRequest(ctx, req) } @@ -924,6 +928,8 @@ func (tc *TeleportClient) GetAccessRequests(ctx context.Context, filter services if err != nil { return nil, trace.Wrap(err) } + defer proxyClient.Close() + return proxyClient.GetAccessRequests(ctx, filter) } @@ -933,6 +939,8 @@ func (tc *TeleportClient) GetRole(ctx context.Context, name string) (services.Ro if err != nil { return nil, trace.Wrap(err) } + defer proxyClient.Close() + return proxyClient.GetRole(ctx, name) } @@ -942,6 +950,8 @@ func (tc *TeleportClient) NewWatcher(ctx context.Context, watch services.Watch) if err != nil { return nil, trace.Wrap(err) } + defer proxyClient.Close() + return proxyClient.NewWatcher(ctx, watch) } @@ -1148,6 +1158,8 @@ func (tc *TeleportClient) Play(ctx context.Context, namespace, sessionID string) if err != nil { return trace.Wrap(err) } + defer proxyClient.Close() + site, err := proxyClient.ConnectToCurrentCluster(ctx, false) if err != nil { return trace.Wrap(err) @@ -1662,7 +1674,7 @@ func (tc *TeleportClient) Logout() error { if tc.localAgent == nil { return nil } - if err := tc.localAgent.DeleteKey(); err != nil { + if err := tc.localAgent.DeleteKey(WithKubeCerts(tc.SiteName)); err != nil { return trace.Wrap(err) } @@ -1750,6 +1762,9 @@ func (tc *TeleportClient) Login(ctx context.Context) (*Key, error) { // extract the new certificate out of the response key.Cert = response.Cert key.TLSCert = response.TLSCert + if tc.KubernetesCluster != "" { + key.KubeTLSCerts[tc.KubernetesCluster] = response.TLSCert + } key.ProxyHost = webProxyHost key.TrustedCA = response.HostSigners diff --git a/lib/client/client.go b/lib/client/client.go index 55561186c5109..daa507160cd20 100644 --- a/lib/client/client.go +++ b/lib/client/client.go @@ -152,7 +152,7 @@ type ReissueParams struct { // that have a metadata instructing server to route the requests to the cluster func (proxy *ProxyClient) ReissueUserCerts(ctx context.Context, params ReissueParams) error { localAgent := proxy.teleportClient.LocalAgent() - key, err := localAgent.GetKey() + key, err := localAgent.GetKey(WithKubeCerts(params.RouteToCluster)) if err != nil { return trace.Wrap(err) } @@ -160,7 +160,7 @@ func (proxy *ProxyClient) ReissueUserCerts(ctx context.Context, params ReissuePa if err != nil { return trace.Wrap(err) } - tlsCert, err := key.TLSCertificate() + tlsCert, err := key.TeleportTLSCertificate() if err != nil { return trace.Wrap(err) } @@ -202,6 +202,9 @@ func (proxy *ProxyClient) ReissueUserCerts(ctx context.Context, params ReissuePa } key.Cert = certs.SSH key.TLSCert = certs.TLS + if params.KubernetesCluster != "" { + key.KubeTLSCerts[params.KubernetesCluster] = certs.TLS + } // save the cert to the local storage (~/.tsh usually): _, err = localAgent.AddKey(key) @@ -215,7 +218,7 @@ func (proxy *ProxyClient) RootClusterName() (string, error) { if err != nil { return "", trace.Wrap(err) } - tlsCert, err := key.TLSCertificate() + tlsCert, err := key.TeleportTLSCertificate() if err != nil { return "", trace.Wrap(err) } @@ -378,7 +381,7 @@ func (proxy *ProxyClient) ConnectToCluster(ctx context.Context, clusterName stri if err != nil { return nil, trace.Wrap(err, "failed to fetch TLS key for %v", proxy.teleportClient.Username) } - tlsConfig, err := key.ClientTLSConfig(nil) + tlsConfig, err := key.TeleportClientTLSConfig(nil) if err != nil { return nil, trace.Wrap(err, "failed to generate client TLS config") } diff --git a/lib/client/identityfile/identity.go b/lib/client/identityfile/identity.go index 9c779ada77c8e..f15d35527360b 100644 --- a/lib/client/identityfile/identity.go +++ b/lib/client/identityfile/identity.go @@ -160,9 +160,9 @@ func Write(filePath string, key *client.Key, format Format, clusterAddr string) case FormatKubernetes: filesWritten = append(filesWritten, filePath) if err := kubeconfig.Update(filePath, kubeconfig.Values{ - Name: key.ClusterName, - ClusterAddr: clusterAddr, - Credentials: key, + TeleportClusterName: key.ClusterName, + ClusterAddr: clusterAddr, + Credentials: key, }); err != nil { return nil, trace.Wrap(err) } diff --git a/lib/client/interfaces.go b/lib/client/interfaces.go index 5ee4184f63172..bc629023080bf 100644 --- a/lib/client/interfaces.go +++ b/lib/client/interfaces.go @@ -17,7 +17,6 @@ limitations under the License. package client import ( - "bytes" "crypto/tls" "crypto/x509" "fmt" @@ -48,8 +47,12 @@ type Key struct { Pub []byte `json:"Pub,omitempty"` // Cert is an SSH client certificate Cert []byte `json:"Cert,omitempty"` - // TLSCert is a PEM encoded client TLS x509 certificate + // TLSCert is a PEM encoded client TLS x509 certificate. + // It's used to authenticate to the Teleport APIs. TLSCert []byte `json:"TLSCert,omitempty"` + // KubeTLSCerts are TLS certificates (PEM-encoded) for individual + // kubernetes clusters. Map key is a kubernetes cluster name. + KubeTLSCerts map[string][]byte `json:"KubeCerts,omitempty"` // ProxyHost (optionally) contains the hostname of the proxy server // which issued this key @@ -71,8 +74,9 @@ func NewKey() (key *Key, err error) { } return &Key{ - Priv: priv, - Pub: pub, + Priv: priv, + Pub: pub, + KubeTLSCerts: make(map[string][]byte), }, nil } @@ -92,9 +96,23 @@ func (k *Key) SSHCAs() (result [][]byte) { return result } -// TLSConfig returns client TLS configuration used -// to authenticate against API servers -func (k *Key) ClientTLSConfig(cipherSuites []uint16) (*tls.Config, error) { +// KubeClientTLSConfig returns client TLS configuration used +// to authenticate against kubernetes servers. +func (k *Key) KubeClientTLSConfig(cipherSuites []uint16, kubeClusterName string) (*tls.Config, error) { + tlsCert, ok := k.KubeTLSCerts[kubeClusterName] + if !ok { + return nil, trace.NotFound("TLS certificate for kubernetes cluster %q not found", kubeClusterName) + } + return k.clientTLSConfig(cipherSuites, tlsCert) +} + +// TeleportClientTLSConfig returns client TLS configuration used +// to authenticate against API servers. +func (k *Key) TeleportClientTLSConfig(cipherSuites []uint16) (*tls.Config, error) { + return k.clientTLSConfig(cipherSuites, k.TLSCert) +} + +func (k *Key) clientTLSConfig(cipherSuites []uint16, tlsCertRaw []byte) (*tls.Config, error) { tlsConfig := utils.TLSConfig(cipherSuites) pool := x509.NewCertPool() @@ -106,7 +124,7 @@ func (k *Key) ClientTLSConfig(cipherSuites []uint16) (*tls.Config, error) { } } tlsConfig.RootCAs = pool - tlsCert, err := tls.X509KeyPair(k.TLSCert, k.Priv) + tlsCert, err := tls.X509KeyPair(tlsCertRaw, k.Priv) if err != nil { return nil, trace.Wrap(err, "failed to parse TLS cert and key") } @@ -241,25 +259,24 @@ func (k *Key) AsAgentKeys() ([]agent.AddedKey, error) { }, nil } -// EqualsTo returns true if this key is the same as the other. -// Primarily used in tests -func (k *Key) EqualsTo(other *Key) bool { - if k == other { - return true - } - return bytes.Equal(k.Cert, other.Cert) && - bytes.Equal(k.Priv, other.Priv) && - bytes.Equal(k.Pub, other.Pub) && - bytes.Equal(k.TLSCert, other.TLSCert) +// TeleportTLSCertificate returns the parsed x509 certificate for +// authentication against Teleport APIs. +func (k *Key) TeleportTLSCertificate() (*x509.Certificate, error) { + return tlsca.ParseCertificatePEM(k.TLSCert) } -// TLSCertificate returns x509 certificate -func (k *Key) TLSCertificate() (*x509.Certificate, error) { - return tlsca.ParseCertificatePEM(k.TLSCert) +// KubeTLSCertificate returns the parsed x509 certificate for +// authentication against a named kubernetes cluster. +func (k *Key) KubeTLSCertificate(kubeClusterName string) (*x509.Certificate, error) { + tlsCert, ok := k.KubeTLSCerts[kubeClusterName] + if !ok { + return nil, trace.NotFound("TLS certificate for kubernetes cluster %q not found", kubeClusterName) + } + return tlsca.ParseCertificatePEM(tlsCert) } -// TLSCertValidBefore returns the time of the TLS cert expiration -func (k *Key) TLSCertValidBefore() (t time.Time, err error) { +// TeleportTLSCertValidBefore returns the time of the TLS cert expiration +func (k *Key) TeleportTLSCertValidBefore() (t time.Time, err error) { cert, err := tlsca.ParseCertificatePEM(k.TLSCert) if err != nil { return t, trace.Wrap(err) diff --git a/lib/client/keyagent.go b/lib/client/keyagent.go index 320ba8e96a045..f0bfc35ad82da 100644 --- a/lib/client/keyagent.go +++ b/lib/client/keyagent.go @@ -254,8 +254,11 @@ func (a *LocalKeyAgent) UnloadKeys() error { // GetKey returns the key for this user in a proxy from the filesystem keystore // at ~/.tsh. -func (a *LocalKeyAgent) GetKey() (*Key, error) { - return a.keyStore.GetKey(a.proxyHost, a.username) +// +// clusterName is an optional teleport cluster name to load kubernetes +// certificates for. +func (a *LocalKeyAgent) GetKey(opts ...KeyOption) (*Key, error) { + return a.keyStore.GetKey(a.proxyHost, a.username, opts...) } // AddHostSignersToCache takes a list of CAs whom we trust. This list is added to a database @@ -417,9 +420,12 @@ func (a *LocalKeyAgent) AddKey(key *Key) (*agent.AddedKey, error) { // DeleteKey removes the key from the key store as well as unloading the key // from the agent. -func (a *LocalKeyAgent) DeleteKey() error { +// +// clusterName is an optional teleport cluster name to delete kubernetes +// certificates for. +func (a *LocalKeyAgent) DeleteKey(opts ...KeyOption) error { // remove key from key store - err := a.keyStore.DeleteKey(a.proxyHost, a.username) + err := a.keyStore.DeleteKey(a.proxyHost, a.username, opts...) if err != nil { return trace.Wrap(err) } diff --git a/lib/client/keystore.go b/lib/client/keystore.go index c6fa4e55a3f88..14ffe980be10a 100644 --- a/lib/client/keystore.go +++ b/lib/client/keystore.go @@ -47,6 +47,7 @@ const ( sessionKeyDir = "keys" fileNameKnownHosts = "known_hosts" fileNameTLSCerts = "certs.pem" + kubeDirSuffix = "-kube" // profileDirPerms is the default permissions applied to the profile // directory (usually ~/.tsh) @@ -68,10 +69,10 @@ type LocalKeyStore interface { AddKey(proxy string, username string, key *Key) error // GetKey returns the session key for the given username and proxy. - GetKey(proxy string, username string) (*Key, error) + GetKey(proxy, username string, opts ...KeyOption) (*Key, error) // DeleteKey removes a specific session key from a proxy. - DeleteKey(proxyHost string, username string) error + DeleteKey(proxyHost, username string, opts ...KeyOption) error // DeleteKeys removes all session keys from disk. DeleteKeys() error @@ -98,14 +99,20 @@ type LocalKeyStore interface { // Here's the file layout for the FS store: // // ~/.tsh/ -// ├── known_hosts --> trusted certificate authorities (their keys) in a format similar to known_hosts +// ├── known_hosts --> trusted certificate authorities (their keys) in a format similar to known_hosts // └── keys -//    ├── one.example.com -//    │   ├── certs.pem -//    │   ├── foo --> RSA Private Key -//    │   ├── foo-cert.pub --> SSH certificate for proxies and nodes -//    │   ├── foo.pub --> Public Key -//    │   └── foo-x509.pem --> TLS client certificate for Auth Server +//    ├── one.example.com --> Proxy hostname +//    │   ├── certs.pem --> TLS CA certs for the Teleport CA +//    │   ├── foo --> RSA Private Key for user "foo" +//    │   ├── foo-cert.pub --> SSH certificate for proxies and nodes +//    │   ├── foo.pub --> Public Key +//    │   ├── foo-x509.pem --> TLS client certificate for Auth Server +//    │   └── foo-kube --> Kubernetes certs for user "foo" +//    │   ├── root --> Kubernetes certs for teleport cluster "root" +//    │   │ ├── kubeA-x509.pem --> TLS cert for Kubernetes cluster "kubeA" +//    │   │ └── kubeB-x509.pem --> TLS cert for Kubernetes cluster "kubeB" +//    │   └── leaf --> Kubernetes certs for teleport cluster "leaf" +//    │   └── kubeC-x509.pem --> TLS cert for Kubernetes cluster "kubeC" //    └── two.example.com //    ├── certs.pem //    ├── bar @@ -165,11 +172,33 @@ func (fs *FSLocalKeyStore) AddKey(host, username string, key *Key) error { if err = writeBytes(username, key.Priv); err != nil { return trace.Wrap(err) } + // TODO(awly): unit test this. + kubeDir := filepath.Join(dirPath, username+kubeDirSuffix, key.ClusterName) + // Clean up any old kube certs. + if err := os.RemoveAll(kubeDir); err != nil { + return trace.Wrap(err) + } + if err := os.MkdirAll(kubeDir, os.ModeDir|profileDirPerms); err != nil { + return trace.Wrap(err) + } + for kubeCluster, cert := range key.KubeTLSCerts { + // Prevent directory traversal via a crafted kubernetes cluster name. + // + // This will confuse cluster cert loading (GetKey will return + // kubernetes cluster names different from the ones stored here), but I + // don't expect any well-meaning user to create bad names. + kubeCluster = filepath.Clean(kubeCluster) + + fname := filepath.Join(username+kubeDirSuffix, key.ClusterName, kubeCluster+fileExtTLSCert) + if err := writeBytes(fname, cert); err != nil { + return trace.Wrap(err) + } + } return nil } // DeleteKey deletes a key from the local store -func (fs *FSLocalKeyStore) DeleteKey(host string, username string) error { +func (fs *FSLocalKeyStore) DeleteKey(host, username string, opts ...KeyOption) error { dirPath, err := fs.dirFor(host, false) if err != nil { return trace.Wrap(err) @@ -185,6 +214,11 @@ func (fs *FSLocalKeyStore) DeleteKey(host string, username string) error { return trace.Wrap(err) } } + for _, o := range opts { + if err := o.deleteKey(dirPath, username); err != nil { + return trace.Wrap(err) + } + } return nil } @@ -202,7 +236,7 @@ func (fs *FSLocalKeyStore) DeleteKeys() error { // GetKey returns a key for a given host. If the key is not found, // returns trace.NotFound error. -func (fs *FSLocalKeyStore) GetKey(proxyHost string, username string) (*Key, error) { +func (fs *FSLocalKeyStore) GetKey(proxyHost, username string, opts ...KeyOption) (*Key, error) { dirPath, err := fs.dirFor(proxyHost, false) if err != nil { return nil, trace.Wrap(err) @@ -249,6 +283,14 @@ func (fs *FSLocalKeyStore) GetKey(proxyHost string, username string) (*Key, erro TrustedCA: []auth.TrustedCerts{{ TLSCertificates: tlsCA, }}, + KubeTLSCerts: make(map[string][]byte), + } + + for _, o := range opts { + if err := o.getKey(dirPath, username, key); err != nil { + fs.log.Error(err) + return nil, trace.Wrap(err) + } } // Validate the key loaded from disk. @@ -263,7 +305,7 @@ func (fs *FSLocalKeyStore) GetKey(proxyHost string, username string) (*Key, erro if err != nil { return nil, trace.Wrap(err) } - tlsCertExpiration, err := key.TLSCertValidBefore() + tlsCertExpiration, err := key.TeleportTLSCertValidBefore() if err != nil { return nil, trace.Wrap(err) } @@ -277,6 +319,56 @@ func (fs *FSLocalKeyStore) GetKey(proxyHost string, username string) (*Key, erro return key, nil } +// KeyOption is an additional step to run when loading (LocalKeyStore.GetKey) +// or deleting (LocalKeyStore.DeleteKey) keys. These are the steps skipped by +// default to reduce the amount of work that Get/DeleteKey performs by default. +type KeyOption interface { + getKey(dirPath, username string, key *Key) error + deleteKey(dirPath, username string) error +} + +// WithKubeCerts returns a GetKeyOption to load kubernetes certificates from +// the store for a given teleport cluster. +func WithKubeCerts(teleportClusterName string) KeyOption { + return withKubeCerts{teleportClusterName: teleportClusterName} +} + +type withKubeCerts struct { + teleportClusterName string +} + +// TODO(awly): unit test this. +func (o withKubeCerts) getKey(dirPath, username string, key *Key) error { + kubeDir := filepath.Join(dirPath, username+kubeDirSuffix, o.teleportClusterName) + kubeFiles, err := ioutil.ReadDir(kubeDir) + if err != nil && !os.IsNotExist(err) { + return trace.Wrap(err) + } + if key.KubeTLSCerts == nil { + key.KubeTLSCerts = make(map[string][]byte) + } + for _, fi := range kubeFiles { + data, err := ioutil.ReadFile(filepath.Join(kubeDir, fi.Name())) + if err != nil { + return trace.Wrap(err) + } + kubeCluster := strings.TrimSuffix(filepath.Base(fi.Name()), fileExtTLSCert) + key.KubeTLSCerts[kubeCluster] = data + } + if key.ClusterName == "" { + key.ClusterName = o.teleportClusterName + } + return nil +} + +func (o withKubeCerts) deleteKey(dirPath, username string) error { + kubeCertsDir := filepath.Join(dirPath, username+kubeDirSuffix, o.teleportClusterName) + if err := os.RemoveAll(kubeCertsDir); err != nil { + return trace.Wrap(err) + } + return nil +} + // SaveCerts saves trusted TLS certificates of certificate authorities func (fs *FSLocalKeyStore) SaveCerts(proxy string, cas []auth.TrustedCerts) error { dir, err := fs.dirFor(proxy, true) diff --git a/lib/client/keystore_test.go b/lib/client/keystore_test.go index 7a02a5cfd5a26..49ce937c56072 100644 --- a/lib/client/keystore_test.go +++ b/lib/client/keystore_test.go @@ -26,6 +26,8 @@ import ( "testing" "time" + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" "github.com/gravitational/teleport" "github.com/gravitational/teleport/lib/auth" "github.com/gravitational/teleport/lib/auth/testauthority" @@ -66,7 +68,7 @@ func TestListKeys(t *testing.T) { host := fmt.Sprintf("host-%v", i) keys2, err := s.store.GetKey(host, "bob") require.NoError(t, err) - require.Equal(t, *keys2, keys[i]) + require.Empty(t, cmp.Diff(*keys2, keys[i], cmpopts.EquateEmpty())) } // read sam's key and make sure it's the same: @@ -89,7 +91,8 @@ func TestKeyCRUD(t *testing.T) { // load back and compare: keyCopy, err := s.store.GetKey("host.a", "bob") require.NoError(t, err) - require.True(t, key.EqualsTo(keyCopy)) + key.ProxyHost = keyCopy.ProxyHost + require.Empty(t, cmp.Diff(key, keyCopy, cmpopts.EquateEmpty())) // Delete & verify that it's gone err = s.store.DeleteKey("host.a", "bob") diff --git a/lib/kube/kubeconfig/kubeconfig.go b/lib/kube/kubeconfig/kubeconfig.go index 15a2fe35910b6..fd7e172b9221e 100644 --- a/lib/kube/kubeconfig/kubeconfig.go +++ b/lib/kube/kubeconfig/kubeconfig.go @@ -3,11 +3,15 @@ package kubeconfig import ( "bytes" + "context" + "fmt" "os" "path/filepath" + "strings" "github.com/gravitational/teleport" "github.com/gravitational/teleport/lib/client" + kubeutils "github.com/gravitational/teleport/lib/kube/utils" "github.com/gravitational/teleport/lib/utils" "github.com/gravitational/trace" @@ -23,38 +27,90 @@ var log = logrus.WithFields(logrus.Fields{ // Values are Teleport user data needed to generate kubeconfig entries. type Values struct { - // Name is used to name kubeconfig sections ("context", "cluster" and + // TeleportClusterName is used to name kubeconfig sections ("context", "cluster" and // "user"). Should match Teleport cluster name. - Name string + TeleportClusterName string // ClusterAddr is the public address the Kubernetes client will talk to, // usually a proxy. ClusterAddr string // Credentials are user credentials to use for authentication the // ClusterAddr. Only TLS fields (key/cert/CA) from Credentials are used. Credentials *client.Key + // Exec contains optional values to use, when configuring tsh as an exec + // auth plugin in kubeconfig. + // + // If not set, static key/cert from Credentials are written to kubeconfig + // instead. + Exec *ExecValues +} + +// ExecValues contain values for configuring tsh as an exec auth plugin in +// kubeconfig. +type ExecValues struct { + // TshBinaryPath is a path to the tsh binary for use as exec plugin. + TshBinaryPath string + // KubeClusters is a list of kubernetes clusters to generate contexts for. + KubeClusters []string + // SelectCluster is the name of the kubernetes cluster to set in + // current-context. + SelectCluster string + // TshBinaryInsecure defines whether to set the --insecure flag in the tsh + // exec plugin arguments. This is used when the proxy doesn't have a + // trusted TLS cert during login. + TshBinaryInsecure bool } // UpdateWithClient adds Teleport configuration to kubeconfig based on the -// configured TeleportClient. +// configured TeleportClient. This will use the exec plugin model and must only +// be called from tsh. // // If `path` is empty, UpdateWithClient will try to guess it based on the // environment or known defaults. -func UpdateWithClient(path string, tc *client.TeleportClient) error { - clusterAddr := tc.KubeClusterAddr() - clusterName, _ := tc.KubeProxyHostPort() +func UpdateWithClient(ctx context.Context, path string, tc *client.TeleportClient, tshBinary string) error { + v := Values{ + Exec: &ExecValues{ + TshBinaryPath: tshBinary, + }, + } + + v.ClusterAddr = tc.KubeClusterAddr() + v.TeleportClusterName, _ = tc.KubeProxyHostPort() if tc.SiteName != "" { - clusterName = tc.SiteName + v.TeleportClusterName = tc.SiteName } - creds, err := tc.LocalAgent().GetKey() + var err error + v.Credentials, err = tc.LocalAgent().GetKey() if err != nil { return trace.Wrap(err) } - return Update(path, Values{ - Name: clusterName, - ClusterAddr: clusterAddr, - Credentials: creds, - }) + // TODO(awly): unit test this. + if tshBinary != "" { + v.Exec.TshBinaryInsecure = tc.InsecureSkipVerify + + // Fetch the list of known kubernetes clusters. + pc, err := tc.ConnectToProxy(ctx) + if err != nil { + return trace.Wrap(err) + } + defer pc.Close() + ac, err := pc.ConnectToCurrentCluster(ctx, true) + if err != nil { + return trace.Wrap(err) + } + defer ac.Close() + v.Exec.KubeClusters, err = kubeutils.KubeClusterNames(ctx, ac) + if err != nil && !trace.IsNotFound(err) { + return trace.Wrap(err) + } + // Use the same defaulting as the auth server. + v.Exec.SelectCluster, err = kubeutils.CheckOrSetKubeCluster(ctx, ac, tc.KubernetesCluster, v.TeleportClusterName) + if err != nil && !trace.IsNotFound(err) { + return trace.Wrap(err) + } + } + + return Update(path, v) } // Update adds Teleport configuration to kubeconfig. @@ -68,39 +124,79 @@ func Update(path string, v Values) error { } cas := bytes.Join(v.Credentials.TLSCAs(), []byte("\n")) - // Validate the provided credentials, to avoid partially-populated - // kubeconfig. - if len(v.Credentials.Priv) == 0 { - return trace.BadParameter("private key missing in provided credentials") - } - if len(v.Credentials.TLSCert) == 0 { - return trace.BadParameter("TLS certificate missing in provided credentials") - } if len(cas) == 0 { return trace.BadParameter("TLS trusted CAs missing in provided credentials") } - - config.AuthInfos[v.Name] = &clientcmdapi.AuthInfo{ - ClientCertificateData: v.Credentials.TLSCert, - ClientKeyData: v.Credentials.Priv, - } - config.Clusters[v.Name] = &clientcmdapi.Cluster{ + config.Clusters[v.TeleportClusterName] = &clientcmdapi.Cluster{ Server: v.ClusterAddr, CertificateAuthorityData: cas, } - lastContext := config.Contexts[v.Name] + if v.Exec != nil { + // Called from tsh, use the exec plugin model. + clusterName := v.TeleportClusterName + for _, c := range v.Exec.KubeClusters { + contextName := ContextName(v.TeleportClusterName, c) + authName := contextName + authInfo := &clientcmdapi.AuthInfo{ + Exec: &clientcmdapi.ExecConfig{ + APIVersion: "client.authentication.k8s.io/v1beta1", + Command: v.Exec.TshBinaryPath, + Args: []string{"kube", "credentials", + fmt.Sprintf("--kube-cluster=%s", c), + fmt.Sprintf("--teleport-cluster=%s", v.TeleportClusterName), + }, + }, + } + if v.Exec.TshBinaryInsecure { + authInfo.Exec.Args = append(authInfo.Exec.Args, "--insecure") + } + config.AuthInfos[authName] = authInfo + + setContext(config.Contexts, contextName, clusterName, authName) + } + if v.Exec.SelectCluster != "" { + contextName := ContextName(v.TeleportClusterName, v.Exec.SelectCluster) + if _, ok := config.Contexts[contextName]; !ok { + return trace.BadParameter("can't switch kubeconfig context to cluster %q, run 'tsh kube clusters' to see available clusters", v.Exec.SelectCluster) + } + config.CurrentContext = contextName + } + } else { + // Called when generating an identity file, use plaintext credentials. + // + // Validate the provided credentials, to avoid partially-populated + // kubeconfig. + if len(v.Credentials.Priv) == 0 { + return trace.BadParameter("private key missing in provided credentials") + } + if len(v.Credentials.TLSCert) == 0 { + return trace.BadParameter("TLS certificate missing in provided credentials") + } + + config.AuthInfos[v.TeleportClusterName] = &clientcmdapi.AuthInfo{ + ClientCertificateData: v.Credentials.TLSCert, + ClientKeyData: v.Credentials.Priv, + } + + setContext(config.Contexts, v.TeleportClusterName, v.TeleportClusterName, v.TeleportClusterName) + config.CurrentContext = v.TeleportClusterName + } + + return Save(path, *config) +} + +func setContext(contexts map[string]*clientcmdapi.Context, name, cluster, auth string) { + lastContext := contexts[name] newContext := &clientcmdapi.Context{ - Cluster: v.Name, - AuthInfo: v.Name, + Cluster: cluster, + AuthInfo: auth, } if lastContext != nil { newContext.Namespace = lastContext.Namespace newContext.Extensions = lastContext.Extensions } - config.Contexts[v.Name] = newContext - config.CurrentContext = v.Name - return save(path, *config) + contexts[name] = newContext } // Remove removes Teleport configuration from kubeconfig. @@ -129,7 +225,7 @@ func Remove(path, name string) error { } // Update kubeconfig on disk. - return save(path, *config) + return Save(path, *config) } // Load tries to read a kubeconfig file and if it can't, returns an error. @@ -151,9 +247,9 @@ func Load(path string) (*clientcmdapi.Config, error) { return config, nil } -// save saves updated config to location specified by environment variable or +// Save saves updated config to location specified by environment variable or // default location -func save(path string, config clientcmdapi.Config) error { +func Save(path string, config clientcmdapi.Config) error { filename, err := finalPath(path) if err != nil { return trace.Wrap(err) @@ -201,3 +297,14 @@ func pathFromEnv() string { return configPath } + +// ContextName returns a kubeconfig context name generated by this package. +func ContextName(teleportCluster, kubeCluster string) string { + return fmt.Sprintf("%s-%s", teleportCluster, kubeCluster) +} + +// KubeClusterFromContext extracts the kubernetes cluster name from context +// name generated by this package. +func KubeClusterFromContext(contextName, teleportCluster string) string { + return strings.TrimPrefix(contextName, teleportCluster+"-") +} diff --git a/lib/kube/kubeconfig/kubeconfig_test.go b/lib/kube/kubeconfig/kubeconfig_test.go index 05a0f42b4cbc2..06837cba5a0ae 100644 --- a/lib/kube/kubeconfig/kubeconfig_test.go +++ b/lib/kube/kubeconfig/kubeconfig_test.go @@ -153,7 +153,7 @@ func (s *KubeconfigSuite) TestSave(c *check.C) { Extensions: map[string]runtime.Object{}, } - err := save(s.kubeconfigPath, cfg) + err := Save(s.kubeconfigPath, cfg) c.Assert(err, check.IsNil) config, err := Load(s.kubeconfigPath) @@ -169,9 +169,9 @@ func (s *KubeconfigSuite) TestUpdate(c *check.C) { creds, caCertPEM, err := s.genUserKey() c.Assert(err, check.IsNil) err = Update(s.kubeconfigPath, Values{ - Name: clusterName, - ClusterAddr: clusterAddr, - Credentials: creds, + TeleportClusterName: clusterName, + ClusterAddr: clusterAddr, + Credentials: creds, }) c.Assert(err, check.IsNil) @@ -211,9 +211,9 @@ func (s *KubeconfigSuite) TestRemove(c *check.C) { // Add teleport-generated entries to kubeconfig. err = Update(s.kubeconfigPath, Values{ - Name: clusterName, - ClusterAddr: clusterAddr, - Credentials: creds, + TeleportClusterName: clusterName, + ClusterAddr: clusterAddr, + Credentials: creds, }) c.Assert(err, check.IsNil) @@ -233,9 +233,9 @@ func (s *KubeconfigSuite) TestRemove(c *check.C) { // Add teleport-generated entries to kubeconfig again. err = Update(s.kubeconfigPath, Values{ - Name: clusterName, - ClusterAddr: clusterAddr, - Credentials: creds, + TeleportClusterName: clusterName, + ClusterAddr: clusterAddr, + Credentials: creds, }) c.Assert(err, check.IsNil) @@ -244,7 +244,7 @@ func (s *KubeconfigSuite) TestRemove(c *check.C) { config, err = Load(s.kubeconfigPath) c.Assert(err, check.IsNil) config.CurrentContext = "prod" - err = save(s.kubeconfigPath, *config) + err = Save(s.kubeconfigPath, *config) c.Assert(err, check.IsNil) // Remove teleport-generated entries from kubeconfig. diff --git a/lib/services/role.go b/lib/services/role.go index 42971e067efeb..0cbe986ef1fd4 100644 --- a/lib/services/role.go +++ b/lib/services/role.go @@ -83,6 +83,7 @@ var DefaultImplicitRules = []Rule{ NewRule(KindSSHSession, RO()), NewRule(KindAppServer, RO()), NewRule(KindRemoteCluster, RO()), + NewRule(KindKubeService, RO()), } // DefaultCertAuthorityRules provides access the minimal set of resources diff --git a/lib/services/server.go b/lib/services/server.go index 2662c206df1aa..06964f0ffe700 100644 --- a/lib/services/server.go +++ b/lib/services/server.go @@ -19,6 +19,7 @@ package services import ( "encoding/json" "fmt" + "regexp" "sort" "strings" "time" @@ -350,6 +351,11 @@ func (s *ServerV2) CheckAndSetDefaults() error { return trace.BadParameter("invalid label key: %q", key) } } + for _, kc := range s.Spec.KubernetesClusters { + if !validKubeClusterName.MatchString(kc.Name) { + return trace.BadParameter("invalid kubernetes cluster name: %q", kc.Name) + } + } return nil } @@ -985,3 +991,9 @@ func GuessProxyHostAndVersion(proxies []Server) (string, string, error) { guessProxyHost := fmt.Sprintf("%v:%v", proxies[0].GetHostname(), defaults.HTTPListenPort) return guessProxyHost, proxies[0].GetTeleportVersion(), nil } + +// validKubeClusterName filters the allowed characters in kubernetes cluster +// names. We need this because cluster names are used for cert filenames on the +// client side, in the ~/.tsh directory. Restricting characters helps with +// sneaky cluster names being used for client directory traversal and exploits. +var validKubeClusterName = regexp.MustCompile(`^[a-zA-Z0-9._-]+$`) diff --git a/tool/tctl/common/tctl.go b/tool/tctl/common/tctl.go index 37759646395c4..b51e904d65a17 100644 --- a/tool/tctl/common/tctl.go +++ b/tool/tctl/common/tctl.go @@ -354,7 +354,7 @@ func applyConfig(ccf *GlobalCLIFlags, cfg *service.Config, loadConfigExt LoadCon return nil, trace.Wrap(err) } - authConfig.TLS, err = key.ClientTLSConfig(cfg.CipherSuites) + authConfig.TLS, err = key.TeleportClientTLSConfig(cfg.CipherSuites) if err != nil { return nil, trace.Wrap(err) } diff --git a/tool/tsh/kube.go b/tool/tsh/kube.go new file mode 100644 index 0000000000000..50b30406dac8f --- /dev/null +++ b/tool/tsh/kube.go @@ -0,0 +1,268 @@ +/* +Copyright 2020 Gravitational, Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package main + +import ( + "fmt" + "time" + + "github.com/gravitational/kingpin" + "github.com/gravitational/teleport/lib/asciitable" + "github.com/gravitational/teleport/lib/client" + "github.com/gravitational/teleport/lib/kube/kubeconfig" + kubeutils "github.com/gravitational/teleport/lib/kube/utils" + "github.com/gravitational/teleport/lib/utils" + "github.com/gravitational/trace" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/runtime/serializer" + "k8s.io/client-go/pkg/apis/clientauthentication" + clientauthv1beta1 "k8s.io/client-go/pkg/apis/clientauthentication/v1beta1" +) + +type kubeCommands struct { + credentials *kubeCredentialsCommand + clusters *kubeClustersCommand + login *kubeLoginCommand +} + +func newKubeCommand(app *kingpin.Application) kubeCommands { + kube := app.Command("kube", "Manage available kubernetes clusters") + cmds := kubeCommands{ + credentials: newKubeCredentialsCommand(kube), + clusters: newKubeClustersCommand(kube), + login: newKubeLoginCommand(kube), + } + return cmds +} + +type kubeCredentialsCommand struct { + *kingpin.CmdClause + kubeCluster string + teleportCluster string +} + +func newKubeCredentialsCommand(parent *kingpin.CmdClause) *kubeCredentialsCommand { + c := &kubeCredentialsCommand{ + // This command is always hidden. It's called from the kubeconfig that + // tsh generates and never by users directly. + CmdClause: parent.Command("credentials", "Get credentials for kubectl access").Hidden(), + } + c.Flag("teleport-cluster", "Name of the teleport cluster to get credentials for.").Required().StringVar(&c.teleportCluster) + c.Flag("kube-cluster", "Name of the kubernetes cluster to get credentials for.").Required().StringVar(&c.kubeCluster) + return c +} + +func (c *kubeCredentialsCommand) run(cf *CLIConf) error { + tc, err := makeClient(cf, true) + if err != nil { + return trace.Wrap(err) + } + + // Try loading existing keys. + k, err := tc.LocalAgent().GetKey(client.WithKubeCerts(c.teleportCluster)) + if err != nil && !trace.IsNotFound(err) { + return trace.Wrap(err) + } + // Loaded existing credentials and have a cert for this cluster? Return it + // right away. + if err == nil { + crt, err := k.KubeTLSCertificate(c.kubeCluster) + if err != nil && !trace.IsNotFound(err) { + return trace.Wrap(err) + } + if crt != nil && time.Until(crt.NotAfter) > time.Minute { + log.Debugf("Re-using existing TLS cert for kubernetes cluster %q", c.kubeCluster) + return c.writeResponse(k, c.kubeCluster) + } + // Otherwise, cert for this k8s cluster is missing or expired. Request + // a new one. + } + + log.Debugf("Requesting TLS cert for kubernetes cluster %q", c.kubeCluster) + err = client.RetryWithRelogin(cf.Context, tc, func() error { + return tc.ReissueUserCerts(cf.Context, client.ReissueParams{ + RouteToCluster: c.teleportCluster, + KubernetesCluster: c.kubeCluster, + }) + }) + if err != nil { + return trace.Wrap(err) + } + + // ReissueUserCerts should cache the new cert on disk, re-read them. + k, err = tc.LocalAgent().GetKey(client.WithKubeCerts(c.teleportCluster)) + if err != nil { + return trace.Wrap(err) + } + + return c.writeResponse(k, c.kubeCluster) +} + +func (c *kubeCredentialsCommand) writeResponse(key *client.Key, kubeClusterName string) error { + crt, err := key.KubeTLSCertificate(kubeClusterName) + if err != nil { + return trace.Wrap(err) + } + resp := &clientauthentication.ExecCredential{ + Status: &clientauthentication.ExecCredentialStatus{ + // Indicate slightly earlier expiration to avoid the cert expiring + // mid-request. + ExpirationTimestamp: &metav1.Time{Time: crt.NotAfter.Add(-1 * time.Minute)}, + ClientCertificateData: string(key.KubeTLSCerts[kubeClusterName]), + ClientKeyData: string(key.Priv), + }, + } + data, err := runtime.Encode(kubeCodecs.LegacyCodec(kubeGroupVersion), resp) + if err != nil { + return trace.Wrap(err) + } + fmt.Println(string(data)) + return nil +} + +type kubeClustersCommand struct { + *kingpin.CmdClause +} + +func newKubeClustersCommand(parent *kingpin.CmdClause) *kubeClustersCommand { + c := &kubeClustersCommand{ + CmdClause: parent.Command("clusters", "Get credentials for kubectl access"), + } + return c +} + +func (c *kubeClustersCommand) run(cf *CLIConf) error { + tc, err := makeClient(cf, true) + if err != nil { + return trace.Wrap(err) + } + + var currentTeleportCluster string + var kubeClusters []string + err = client.RetryWithRelogin(cf.Context, tc, func() error { + pc, err := tc.ConnectToProxy(cf.Context) + if err != nil { + return trace.Wrap(err) + } + defer pc.Close() + ac, err := pc.ConnectToCurrentCluster(cf.Context, true) + if err != nil { + return trace.Wrap(err) + } + defer ac.Close() + + cn, err := ac.GetClusterName() + if err != nil { + return trace.Wrap(err) + } + currentTeleportCluster = cn.GetClusterName() + + kubeClusters, err = kubeutils.KubeClusterNames(cf.Context, ac) + if err != nil { + return trace.Wrap(err) + } + return nil + }) + if err != nil { + return trace.Wrap(err) + } + + var selectedCluster string + if kc, err := kubeconfig.Load(""); err != nil { + log.WithError(err).Warning("Failed parsing existing kubeconfig") + } else { + selectedCluster = kubeconfig.KubeClusterFromContext(kc.CurrentContext, currentTeleportCluster) + } + + var t asciitable.Table + if cf.Quiet { + t = asciitable.MakeHeadlessTable(2) + } else { + t = asciitable.MakeTable([]string{"Kube Cluster Name", "Selected"}) + } + for _, cluster := range kubeClusters { + var selectedMark string + if cluster == selectedCluster { + selectedMark = "*" + } + t.AddRow([]string{cluster, selectedMark}) + } + fmt.Println(t.AsBuffer().String()) + + return nil +} + +type kubeLoginCommand struct { + *kingpin.CmdClause + kubeCluster string +} + +func newKubeLoginCommand(parent *kingpin.CmdClause) *kubeLoginCommand { + c := &kubeLoginCommand{ + CmdClause: parent.Command("login", "Login to a kubernetes cluster"), + } + c.Arg("kube-cluster", "Name of the kubernetes cluster to login to. Check 'tsh kube clusters' for a list of available clusters.").Required().StringVar(&c.kubeCluster) + return c +} + +func (c *kubeLoginCommand) run(cf *CLIConf) error { + profile, _, err := client.Status("", cf.Proxy) + if err != nil { + if trace.IsNotFound(err) { + fmt.Println("Not logged in.") + return nil + } + utils.FatalError(err) + } + kc, err := kubeconfig.Load("") + if err != nil { + return trace.Wrap(err) + } + + kubeContext := kubeconfig.ContextName(profile.Cluster, c.kubeCluster) + if _, ok := kc.Contexts[kubeContext]; !ok { + fmt.Println(`check 'tsh kube clusters' for known clusters +if this is a new cluster, you may need to 'tsh login' again to access it`) + return trace.BadParameter("kubernetes cluster %q not found", c.kubeCluster) + } + kc.CurrentContext = kubeContext + if err := kubeconfig.Save("", *kc); err != nil { + return trace.Wrap(err) + } + fmt.Printf("Logged into kubernetes cluster %q\n", c.kubeCluster) + return nil +} + +// Required magic boilerplate to use the k8s encoder. + +var ( + kubeScheme = runtime.NewScheme() + kubeCodecs = serializer.NewCodecFactory(kubeScheme) + kubeGroupVersion = schema.GroupVersion{ + Group: "client.authentication.k8s.io", + Version: "v1beta1", + } +) + +func init() { + metav1.AddToGroupVersion(kubeScheme, schema.GroupVersion{Version: "v1"}) + clientauthv1beta1.AddToScheme(kubeScheme) + clientauthentication.AddToScheme(kubeScheme) +} diff --git a/tool/tsh/tsh.go b/tool/tsh/tsh.go index f5550467502e7..726bf19f9ea45 100644 --- a/tool/tsh/tsh.go +++ b/tool/tsh/tsh.go @@ -315,9 +315,7 @@ func Run(args []string) { login.Flag("request-reason", "Reason for requesting additional roles").StringVar(&cf.RequestReason) login.Arg("cluster", clusterHelp).StringVar(&cf.SiteName) login.Flag("browser", browserHelp).StringVar(&cf.Browser) - // TODO(awly): unhide this flag in 5.0, after 'tsh kube ...' commands are - // implemented. - login.Flag("kube-cluster", "Name of the Kubernetes cluster to login to").Hidden().StringVar(&cf.KubernetesCluster) + login.Flag("kube-cluster", "Name of the Kubernetes cluster to login to").StringVar(&cf.KubernetesCluster) login.Alias(loginUsageFooter) // logout deletes obtained session certificates in ~/.tsh @@ -346,6 +344,9 @@ func Run(args []string) { // about the certificate. status := app.Command("status", "Display the list of proxy servers and retrieved certificates") + // Kubernetes subcommands. + kube := newKubeCommand(app) + // On Windows, hide the "ssh", "join", "play", "scp", and "bench" commands // because they all use a terminal. if runtime.GOOS == teleport.WindowsOS { @@ -414,6 +415,18 @@ func Run(args []string) { onStatus(&cf) case lsApps.FullCommand(): onApps(&cf) + case kube.credentials.FullCommand(): + err = kube.credentials.run(&cf) + case kube.clusters.FullCommand(): + err = kube.clusters.run(&cf) + case kube.login.FullCommand(): + err = kube.login.run(&cf) + default: + // This should only happen when there's a missing switch case above. + err = trace.BadParameter("command %q not configured", command) + } + if err != nil { + utils.FatalError(err) } } @@ -515,7 +528,7 @@ func onLogin(cf *CLIConf) { if err := tc.SaveProfile("", true); err != nil { utils.FatalError(err) } - if err := kubeconfig.UpdateWithClient("", tc); err != nil { + if err := kubeconfig.UpdateWithClient(cf.Context, "", tc, os.Args[0]); err != nil { utils.FatalError(err) } onStatus(cf) @@ -574,7 +587,7 @@ func onLogin(cf *CLIConf) { // If the proxy is advertising that it supports Kubernetes, update kubeconfig. if tc.KubeProxyAddr != "" { - if err := kubeconfig.UpdateWithClient("", tc); err != nil { + if err := kubeconfig.UpdateWithClient(cf.Context, "", tc, os.Args[0]); err != nil { utils.FatalError(err) } } @@ -655,7 +668,7 @@ func setupNoninteractiveClient(tc *client.TeleportClient, key *client.Key) error if err != nil { return trace.Wrap(err) } - tc.TLS, err = key.ClientTLSConfig(nil) + tc.TLS, err = key.TeleportClientTLSConfig(nil) if err != nil { return trace.Wrap(err) } @@ -1253,7 +1266,7 @@ func makeClient(cf *CLIConf, useProfileLogin bool) (*client.TeleportClient, erro } if len(key.TLSCert) > 0 { - c.TLS, err = key.ClientTLSConfig(nil) + c.TLS, err = key.TeleportClientTLSConfig(nil) if err != nil { return nil, trace.Wrap(err) } @@ -1626,7 +1639,7 @@ func reissueWithRequests(cf *CLIConf, tc *client.TeleportClient, reqIDs ...strin if err := tc.SaveProfile("", true); err != nil { return trace.Wrap(err) } - if err := kubeconfig.UpdateWithClient("", tc); err != nil { + if err := kubeconfig.UpdateWithClient(cf.Context, "", tc, os.Args[0]); err != nil { return trace.Wrap(err) } return nil diff --git a/tool/tsh/tsh_test.go b/tool/tsh/tsh_test.go index e0b458277750e..e06a471d01afd 100644 --- a/tool/tsh/tsh_test.go +++ b/tool/tsh/tsh_test.go @@ -269,7 +269,7 @@ func (s *MainTestSuite) TestIdentityRead(c *check.C) { c.Assert(k, check.NotNil) c.Assert(k.TLSCert, check.NotNil) // generate a TLS client config - conf, err := k.ClientTLSConfig(nil) + conf, err := k.TeleportClientTLSConfig(nil) c.Assert(err, check.IsNil) c.Assert(conf, check.NotNil) // ensure that at least root CA was successfully loaded