teleport/tool/tsh/kube.go
2021-11-12 22:32:42 +01:00

440 lines
14 KiB
Go

/*
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 (
"context"
"fmt"
"net"
"strings"
"time"
"github.com/gravitational/kingpin"
"github.com/gravitational/trace"
"github.com/gravitational/teleport/api/constants"
"github.com/gravitational/teleport/api/profile"
apiutils "github.com/gravitational/teleport/api/utils"
"github.com/gravitational/teleport/api/utils/keypaths"
"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/srv/alpnproxy"
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
ls *kubeLSCommand
login *kubeLoginCommand
}
func newKubeCommand(app *kingpin.Application) kubeCommands {
kube := app.Command("kube", "Manage available kubernetes clusters")
cmds := kubeCommands{
credentials: newKubeCredentialsCommand(kube),
ls: newKubeLSCommand(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(c.teleportCluster, client.WithKubeCerts{})
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 {
var err error
k, err = tc.IssueUserCertsWithMFA(cf.Context, client.ReissueParams{
RouteToCluster: c.teleportCluster,
KubernetesCluster: c.kubeCluster,
})
return err
})
if err != nil {
return trace.Wrap(err)
}
// Cache the new cert on disk for reuse.
if _, err := tc.LocalAgent().AddKey(k); 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)
}
expiry := crt.NotAfter
// Indicate slightly earlier expiration to avoid the cert expiring
// mid-request, if possible.
if time.Until(expiry) > time.Minute {
expiry = expiry.Add(-1 * time.Minute)
}
resp := &clientauthentication.ExecCredential{
Status: &clientauthentication.ExecCredentialStatus{
ExpirationTimestamp: &metav1.Time{Time: expiry},
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 kubeLSCommand struct {
*kingpin.CmdClause
}
func newKubeLSCommand(parent *kingpin.CmdClause) *kubeLSCommand {
c := &kubeLSCommand{
CmdClause: parent.Command("ls", "Get a list of kubernetes clusters"),
}
return c
}
func (c *kubeLSCommand) run(cf *CLIConf) error {
tc, err := makeClient(cf, true)
if err != nil {
return trace.Wrap(err)
}
currentTeleportCluster, kubeClusters, err := fetchKubeClusters(cf.Context, tc)
if err != nil {
return trace.Wrap(err)
}
selectedCluster := selectedKubeCluster(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
}
func selectedKubeCluster(currentTeleportCluster string) string {
kc, err := kubeconfig.Load("")
if err != nil {
log.WithError(err).Warning("Failed parsing existing kubeconfig")
return ""
}
return kubeconfig.KubeClusterFromContext(kc.CurrentContext, currentTeleportCluster)
}
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 ls' for a list of available clusters.").Required().StringVar(&c.kubeCluster)
return c
}
func (c *kubeLoginCommand) run(cf *CLIConf) error {
// Set CLIConf.KubernetesCluster so that the kube cluster's context is automatically selected.
cf.KubernetesCluster = c.kubeCluster
tc, err := makeClient(cf, true)
if err != nil {
return trace.Wrap(err)
}
// Check that this kube cluster exists.
currentTeleportCluster, kubeClusters, err := fetchKubeClusters(cf.Context, tc)
if err != nil {
return trace.Wrap(err)
}
if !apiutils.SliceContainsStr(kubeClusters, c.kubeCluster) {
return trace.NotFound("kubernetes cluster %q not found, check 'tsh kube ls' for a list of known clusters", c.kubeCluster)
}
// Try updating the active kubeconfig context.
if err := kubeconfig.SelectContext(currentTeleportCluster, c.kubeCluster); err != nil {
if !trace.IsNotFound(err) {
return trace.Wrap(err)
}
// We know that this kube cluster exists from the API, but there isn't
// a context for it in the current kubeconfig. This is probably a new
// cluster, added after the last 'tsh login'.
//
// Re-generate kubeconfig contexts and try selecting this kube cluster
// again.
if err := updateKubeConfig(cf, tc, ""); err != nil {
return trace.Wrap(err)
}
}
// Generate a profile specific kubeconfig which can be used
// by setting the kubeconfig environment variable (with `tsh env`)
profileKubeconfigPath := keypaths.KubeConfigPath(
profile.FullProfilePath(cf.HomePath), tc.WebProxyHost(), tc.Username, currentTeleportCluster, c.kubeCluster,
)
if err := updateKubeConfig(cf, tc, profileKubeconfigPath); err != nil {
return trace.Wrap(err)
}
fmt.Printf("Logged into kubernetes cluster %q\n", c.kubeCluster)
return nil
}
func fetchKubeClusters(ctx context.Context, tc *client.TeleportClient) (teleportCluster string, kubeClusters []string, err error) {
err = client.RetryWithRelogin(ctx, tc, func() error {
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()
cn, err := ac.GetClusterName()
if err != nil {
return trace.Wrap(err)
}
teleportCluster = cn.GetClusterName()
kubeClusters, err = kubeutils.KubeClusterNames(ctx, ac)
if err != nil {
return trace.Wrap(err)
}
return nil
})
if err != nil {
return "", nil, trace.Wrap(err)
}
return teleportCluster, kubeClusters, nil
}
// kubernetesStatus holds teleport client information necessary to populate the user's kubeconfig.
type kubernetesStatus struct {
clusterAddr string
teleportClusterName string
kubeClusters []string
credentials *client.Key
tlsServerName string
}
// fetchKubeStatus returns a kubernetesStatus populated from the given TeleportClient.
func fetchKubeStatus(ctx context.Context, tc *client.TeleportClient) (*kubernetesStatus, error) {
var err error
kubeStatus := &kubernetesStatus{
clusterAddr: tc.KubeClusterAddr(),
}
kubeStatus.credentials, err = tc.LocalAgent().GetCoreKey()
if err != nil {
return nil, trace.Wrap(err)
}
kubeStatus.teleportClusterName, kubeStatus.kubeClusters, err = fetchKubeClusters(ctx, tc)
if err != nil {
return nil, trace.Wrap(err)
}
if tc.TLSRoutingEnabled {
kubeStatus.tlsServerName = getKubeTLSServerName(tc)
}
return kubeStatus, nil
}
// getKubeTLSServerName returns k8s server name used in KUBECONFIG to leverage TLS Routing.
func getKubeTLSServerName(tc *client.TeleportClient) string {
k8host, _ := tc.KubeProxyHostPort()
isIPFormat := net.ParseIP(k8host) != nil
if k8host == "" || isIPFormat {
// If proxy is configured without public_addr set the ServerName to the 'kube.teleport.cluster.local' value.
// The k8s server name needs to be a valid hostname but when public_addr is missing from proxy settings
// the web_listen_addr is used thus webHost will contain local proxy IP address like: 0.0.0.0 or 127.0.0.1
return addSubdomainPrefix(constants.APIDomain, alpnproxy.KubeSNIPrefix)
}
return addSubdomainPrefix(k8host, alpnproxy.KubeSNIPrefix)
}
func addSubdomainPrefix(domain, prefix string) string {
return fmt.Sprintf("%s.%s", prefix, domain)
}
// buildKubeConfigUpdate returns a kubeconfig.Values suitable for updating the user's kubeconfig
// based on the CLI parameters and the given kubernetesStatus.
func buildKubeConfigUpdate(cf *CLIConf, kubeStatus *kubernetesStatus) (*kubeconfig.Values, error) {
v := &kubeconfig.Values{
ClusterAddr: kubeStatus.clusterAddr,
TeleportClusterName: kubeStatus.teleportClusterName,
Credentials: kubeStatus.credentials,
TLSServerName: kubeStatus.tlsServerName,
}
if cf.executablePath == "" {
// Don't know tsh path.
// Fall back to the old kubeconfig, with static credentials from v.Credentials.
return v, nil
}
if len(kubeStatus.kubeClusters) == 0 {
// If there are no registered k8s clusters, we may have an older teleport cluster.
// Fall back to the old kubeconfig, with static credentials from v.Credentials.
log.Debug("Disabling exec plugin mode for kubeconfig because this Teleport cluster has no Kubernetes clusters.")
return v, nil
}
v.Exec = &kubeconfig.ExecValues{
TshBinaryPath: cf.executablePath,
TshBinaryInsecure: cf.InsecureSkipVerify,
KubeClusters: kubeStatus.kubeClusters,
}
// Only switch the current context if kube-cluster is explicitly set on the command line.
if cf.KubernetesCluster != "" {
if !apiutils.SliceContainsStr(kubeStatus.kubeClusters, cf.KubernetesCluster) {
return nil, trace.BadParameter("Kubernetes cluster %q is not registered in this Teleport cluster; you can list registered Kubernetes clusters using 'tsh kube ls'.", cf.KubernetesCluster)
}
v.Exec.SelectCluster = cf.KubernetesCluster
}
return v, nil
}
// updateKubeConfig adds Teleport configuration to the users's kubeconfig based on the CLI
// parameters and the kubernetes services in the current Teleport cluster. If no path for
// the kubeconfig is given, it will use environment values or known defaults to get a path.
func updateKubeConfig(cf *CLIConf, tc *client.TeleportClient, path string) error {
// Fetch proxy's advertised ports to check for k8s support.
if _, err := tc.Ping(cf.Context); err != nil {
return trace.Wrap(err)
}
if tc.KubeProxyAddr == "" {
// Kubernetes support disabled, don't touch kubeconfig.
return nil
}
kubeStatus, err := fetchKubeStatus(cf.Context, tc)
if err != nil {
return trace.Wrap(err)
}
values, err := buildKubeConfigUpdate(cf, kubeStatus)
if err != nil {
return trace.Wrap(err)
}
if path == "" {
path = kubeconfig.PathFromEnv()
}
// If this is a profile specific kubeconfig, we only need
// to put the selected kube cluster into the kubeconfig.
isKubeConfig, err := keypaths.IsProfileKubeConfigPath(path)
if err != nil {
return trace.Wrap(err)
}
if isKubeConfig {
if !strings.Contains(path, cf.KubernetesCluster) {
return trace.BadParameter("profile specific kubeconfig is in use, run 'eval $(tsh env --unset)' to switch contexts to another kube cluster")
}
values.Exec.KubeClusters = []string{cf.KubernetesCluster}
}
return trace.Wrap(kubeconfig.Update(path, *values))
}
// 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)
}