diff --git a/cmd/main.go b/cmd/main.go index c7167f01..28430d07 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -22,16 +22,17 @@ import ( // Import all Kubernetes client auth plugins (e.g. Azure, GCP, OIDC, etc.) // to ensure that exec-entrypoint and run can make use of them. - _ "k8s.io/client-go/plugin/pkg/client/auth" "k8s.io/apimachinery/pkg/runtime" utilruntime "k8s.io/apimachinery/pkg/util/runtime" clientgoscheme "k8s.io/client-go/kubernetes/scheme" + _ "k8s.io/client-go/plugin/pkg/client/auth" ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/healthz" "sigs.k8s.io/controller-runtime/pkg/log/zap" "github.com/giantswarm/teleport-operator/internal/controller" + "github.com/giantswarm/teleport-operator/internal/pkg/teleportclient" capi "sigs.k8s.io/cluster-api/api/v1beta1" //+kubebuilder:scaffold:imports ) @@ -52,11 +53,13 @@ func main() { var metricsAddr string var enableLeaderElection bool var probeAddr string + var namespace string flag.StringVar(&metricsAddr, "metrics-bind-address", ":8080", "The address the metric endpoint binds to.") flag.StringVar(&probeAddr, "health-probe-bind-address", ":8081", "The address the probe endpoint binds to.") flag.BoolVar(&enableLeaderElection, "leader-elect", false, "Enable leader election for controller manager. "+ "Enabling this will ensure there is only one active controller manager.") + flag.StringVar(&namespace, "namespace", "", "Namespace where operator is deployed") opts := zap.Options{ Development: true, } @@ -89,10 +92,17 @@ func main() { os.Exit(1) } + teleportClient, err := teleportclient.New(namespace) + if err != nil { + setupLog.Error(err, "unable to create teleport client") + os.Exit(1) + } + if err = (&controller.ClusterReconciler{ - Client: mgr.GetClient(), - Log: ctrl.Log.WithName("controllers").WithName("Cluster"), - Scheme: mgr.GetScheme(), + Client: mgr.GetClient(), + Log: ctrl.Log.WithName("controllers").WithName("Cluster"), + Scheme: mgr.GetScheme(), + TeleportClient: teleportClient, }).SetupWithManager(mgr); err != nil { setupLog.Error(err, "unable to create controller", "controller", "Cluster") os.Exit(1) diff --git a/go.mod b/go.mod index 5b65e06e..22dd2740 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module github.com/giantswarm/teleport-operator go 1.19 require ( + github.com/giantswarm/microerror v0.4.0 github.com/go-logr/logr v1.2.4 github.com/gravitational/teleport/api v0.0.0-20230606022908-5e60f9001626 github.com/onsi/ginkgo/v2 v2.9.2 diff --git a/go.sum b/go.sum index 55590a1e..28dc473b 100644 --- a/go.sum +++ b/go.sum @@ -99,6 +99,8 @@ github.com/flowstack/go-jsonschema v0.1.1/go.mod h1:yL7fNggx1o8rm9RlgXv7hTBWxdBM github.com/fsnotify/fsnotify v1.6.0 h1:n+5WquG0fcWoWp6xPWfHdbskMCQaFnG6PfBrh1Ky4HY= github.com/fsnotify/fsnotify v1.6.0/go.mod h1:sl3t1tCWJFWoRz9R8WJCbQihKKwmorjAbSClcnxKAGw= github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= +github.com/giantswarm/microerror v0.4.0 h1:QeU+UZL0rRlVXKqYOHMxS0L7g8UD+dn84NT7myWVh4U= +github.com/giantswarm/microerror v0.4.0/go.mod h1:Ju1YdC6TX/8witv7fIlkgiRr5FQUNyq3f4TX2QYnO7c= github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU= github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= diff --git a/internal/controller/cluster_controller.go b/internal/controller/cluster_controller.go index c17c9f0a..5d70cee5 100644 --- a/internal/controller/cluster_controller.go +++ b/internal/controller/cluster_controller.go @@ -19,6 +19,7 @@ package controller import ( "context" "fmt" + "time" "github.com/go-logr/logr" corev1 "k8s.io/api/core/v1" @@ -29,9 +30,8 @@ import ( capi "sigs.k8s.io/cluster-api/api/v1beta1" ctrl "sigs.k8s.io/controller-runtime" - "github.com/giantswarm/teleport-operator/pkg/teleportclient" + "github.com/giantswarm/teleport-operator/internal/pkg/teleportclient" - "github.com/gravitational/teleport/api/client" "sigs.k8s.io/controller-runtime/pkg/client" ) @@ -59,6 +59,11 @@ type ClusterReconciler struct { func (r *ClusterReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { log := r.Log.WithValues("cluster", req.NamespacedName) + if _, err := r.TeleportClient.GetClient(ctx); err != nil { + return ctrl.Result{}, fmt.Errorf("failed to get Teleport client: %w", err) + } + log.Info("Teleport client connected") + cluster := &capi.Cluster{} if err := r.Client.Get(ctx, req.NamespacedName, cluster); err != nil { if apierrors.IsNotFound(err) { @@ -68,20 +73,13 @@ func (r *ClusterReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ct return ctrl.Result{}, err } - log.Info("Found cluster") - - // Skip if teleport secret already exists for the cluster secretName := "teleport-kube-agent-join-token" secret := &corev1.Secret{ ObjectMeta: metav1.ObjectMeta{ Name: secretName, Namespace: cluster.Namespace, }, - StringData: map[string]string{ - "joinToken": "join-token-here", - }, } - secretNamespacedName := types.NamespacedName{ Name: secretName, Namespace: cluster.Namespace, @@ -92,10 +90,14 @@ func (r *ClusterReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ct if apierrors.IsNotFound(err) { log.Info(fmt.Sprintf("Secret does not exist: %s", secretName)) // Generate token from Teleport - // Here you can add the code to create the Secret + joinToken, err := r.TeleportClient.GetToken(ctx, cluster.Name) + if err != nil { + return ctrl.Result{}, fmt.Errorf("failed to generate join token: %w", err) + } + log.Info("Join token generated") secret.StringData = map[string]string{ - "joinToken": "short-lived-join-token-goes-here", + "joinToken": joinToken, } if err := r.Create(ctx, secret); err != nil { return ctrl.Result{}, fmt.Errorf("failed to create Secret: %w", err) @@ -107,11 +109,34 @@ func (r *ClusterReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ct return ctrl.Result{}, fmt.Errorf("failed to get Secret: %w", err) } } else { - log.Info(fmt.Sprintf("Secret exists: %s. Cluster will be ignored", secretName)) + log.Info(fmt.Sprintf("Secret exists: %s", secretName)) + // Update secret if token expired or is expiring + hasExpired, err := r.TeleportClient.HasTokenExpired(ctx, cluster.Name) + if err != nil { + return ctrl.Result{}, fmt.Errorf("failed to verify token expiry: %w", err) + } + if hasExpired { + log.Info("Join token expired") + joinToken, err := r.TeleportClient.GetToken(ctx, cluster.Name) + if err != nil { + return ctrl.Result{}, fmt.Errorf("failed to generate teleport token: %w", err) + } + log.Info("Join token generated") + secret.StringData = map[string]string{ + "joinToken": joinToken, + } + if err := r.Update(ctx, secret); err != nil { + return ctrl.Result{}, fmt.Errorf("failed to update Secret: %w", err) + } else { + log.Info(fmt.Sprintf("Secret updated: %s", secretName)) + } + } else { + log.Info("Join token is valid, nothing to do.") + } } // Here you can add the code to handle the case where the Secret exists - return ctrl.Result{}, nil + return ctrl.Result{RequeueAfter: 1 * time.Minute}, nil } // SetupWithManager sets up the controller with the Manager. diff --git a/internal/pkg/teleportclient/random.go b/internal/pkg/teleportclient/random.go new file mode 100644 index 00000000..39a634bd --- /dev/null +++ b/internal/pkg/teleportclient/random.go @@ -0,0 +1,19 @@ +package teleportclient + +import ( + "time" + + "k8s.io/apimachinery/pkg/util/rand" +) + +var letters = []rune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789") + +func randSeq(n int) string { + rand.Seed(time.Now().UnixNano()) + + b := make([]rune, n) + for i := range b { + b[i] = letters[rand.Intn(len(letters))] + } + return string(b) +} diff --git a/internal/pkg/teleportclient/teleportclient.go b/internal/pkg/teleportclient/teleportclient.go index 2ebc1816..e72498ab 100644 --- a/internal/pkg/teleportclient/teleportclient.go +++ b/internal/pkg/teleportclient/teleportclient.go @@ -1 +1,153 @@ package teleportclient + +import ( + "context" + "fmt" + "time" + + "github.com/giantswarm/microerror" + tc "github.com/gravitational/teleport/api/client" + tt "github.com/gravitational/teleport/api/types" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/config" +) + +type TeleportClient struct { + ProxyAddr string + IdentityFile string +} + +const TELEPORT_JOIN_TOKEN_VALIDITY = 1 * time.Hour + +func New(namespace string) (*TeleportClient, error) { + // Get a config to talk to the apiserver + cfg, err := config.GetConfig() + if err != nil { + fmt.Println("unable to get config to talk to the apiserver:", err) + return nil, err + } + + // Create a new client + c, err := client.New(cfg, client.Options{}) + if err != nil { + fmt.Println("unable to create a new client:", err) + return nil, err + } + + // Check if the Secret exists + secret := &corev1.Secret{} + secretNamespacedName := types.NamespacedName{ + Name: "teleport-operator", + Namespace: namespace, // Replace with the correct namespace + } + if err := c.Get(context.Background(), secretNamespacedName, secret); err != nil { + return nil, err + } + + proxyAddrBytes, proxyAddrOk := secret.Data["proxyAddr"] + identityFileBytes, identityFileOk := secret.Data["identityFile"] + if !proxyAddrOk && !identityFileOk { + return nil, fmt.Errorf("malformed Secret: `identityFile` or `proxyAddr` key not found") + } + identityFile := string(identityFileBytes) + proxyAddr := string(proxyAddrBytes) + + return &TeleportClient{ + IdentityFile: identityFile, + ProxyAddr: proxyAddr, + }, nil +} + +func (t *TeleportClient) GetClient(ctx context.Context) (*tc.Client, error) { + c, err := tc.New(ctx, tc.Config{ + Addrs: []string{ + t.ProxyAddr, + }, + Credentials: []tc.Credentials{ + tc.LoadIdentityFileFromString(t.IdentityFile), + }, + }) + + if err != nil { + return nil, microerror.Mask(err) + } + + _, err = c.Ping(ctx) + if err != nil { + return nil, microerror.Mask(err) + } + + return c, nil +} + +func (t *TeleportClient) GetToken(ctx context.Context, clusterName string) (string, error) { + clt, err := t.GetClient(ctx) + if err != nil { + return "", microerror.Mask(err) + } + + // Look for an existing token or generate one + { + tokens, err := clt.GetTokens(ctx) + if err != nil { + return "", microerror.Mask(err) + } + + for _, t := range tokens { + if t.GetMetadata().Labels["cluster"] == clusterName { + err = clt.DeleteToken(ctx, t.GetName()) + if err != nil { + return "", microerror.Mask(err) + } + break + } + } + + // Generate a token + expiration := time.Now().Add(TELEPORT_JOIN_TOKEN_VALIDITY) + + token := randSeq(32) + newToken, err := tt.NewProvisionToken(token, []tt.SystemRole{tt.RoleKube, tt.RoleNode}, expiration) + if err != nil { + return "", microerror.Mask(err) + } + oldMeta := newToken.GetMetadata() + oldMeta.Labels = map[string]string{ + "cluster": clusterName, + } + newToken.SetMetadata(oldMeta) + err = clt.UpsertToken(ctx, newToken) + if err != nil { + return "", microerror.Mask(err) + } + + return token, nil + } +} + +func (t *TeleportClient) HasTokenExpired(ctx context.Context, clusterName string) (bool, error) { + clt, err := t.GetClient(ctx) + if err != nil { + return false, microerror.Mask(err) + } + + { + tokens, err := clt.GetTokens(ctx) + if err != nil { + return false, microerror.Mask(err) + } + + for _, t := range tokens { + if t.GetMetadata().Labels["cluster"] == clusterName { + // Nearing expiry, less than an hour, refresh it + // if time.Since(*t.GetMetadata().Expires).Hours() < -1 { + // return true, nil + // } + return false, nil + } + } + return true, nil + } +}