// Copyright (c) Tailscale Inc & contributors // SPDX-License-Identifier: BSD-3-Clause //go:build !plan9 // Package tailnet provides reconciliation logic for the Tailnet custom resource definition. It is responsible for // ensuring the referenced credentials (either an OAuth client or a workload identity federation configuration) are // valid and have the required scopes to be able to generate authentication keys, manage devices & manage VIP services. package tailnet import ( "context" "errors" "fmt" "net/url" "sync" "time" "go.uber.org/zap" authnv1 "k8s.io/api/authentication/v1" corev1 "k8s.io/api/core/v1" apierrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/types" "sigs.k8s.io/controller-runtime/pkg/builder" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/manager" "sigs.k8s.io/controller-runtime/pkg/reconcile" "tailscale.com/client/tailscale/v2" "tailscale.com/ipn" operatorutils "tailscale.com/k8s-operator" tsapi "tailscale.com/k8s-operator/apis/v1alpha1" "tailscale.com/k8s-operator/reconciler" "tailscale.com/k8s-operator/tsclient" "tailscale.com/kube/kubetypes" "tailscale.com/tstime" "tailscale.com/util/clientmetric" "tailscale.com/util/set" ) type ( // The Reconciler type is a reconcile.TypedReconciler implementation used to manage the reconciliation of // Tailnet custom resources. Reconciler struct { client.Client tailscaleNamespace string operatorSAName string clock tstime.Clock logger *zap.SugaredLogger clientFunc func(*tsapi.Tailnet, *corev1.Secret) tsclient.Client registry ClientRegistry // Metrics related fields mu sync.Mutex tailnets set.Slice[types.UID] } // The ReconcilerOptions type contains configuration values for the Reconciler. ReconcilerOptions struct { // The client for interacting with the Kubernetes API. Client client.Client // The namespace the operator is installed in. This reconciler expects Tailnet credentials to be stored // in Secret resources within this namespace. TailscaleNamespace string // The name of the ServiceAccount the operator runs as, in TailscaleNamespace. This is used as the target // ServiceAccount when minting tokens via the Kubernetes TokenRequest API for Tailnets that authenticate // using workload identity federation. OperatorSAName string // Controls which clock to use for performing time-based functions. This is typically modified for use // in tests. Clock tstime.Clock // The logger to use for this Reconciler. Logger *zap.SugaredLogger // ClientFunc is a function that takes tailscale credentials and returns an implementation for the Tailscale // HTTP API. This should generally be nil unless needed for testing. ClientFunc func(*tsapi.Tailnet, *corev1.Secret) tsclient.Client // Registry is used to store and share initialized tailscale clients for use by other reconcilers. Registry ClientRegistry } // The ClientRegistry interface describes types that can store initialized tailscale clients for use by other // reconcilers. ClientRegistry interface { // Add should store the given tsclient.Client implementation for a specified tailnet. Add(tailnet string, client tsclient.Client, ready bool) // Remove should remove any tsclient.Client implementation for a specified tailnet. Remove(tailnet string) } ) const reconcilerName = "tailnet-reconciler" // NewReconciler returns a new instance of the Reconciler type. It watches specifically for changes to Tailnet custom // resources. The ReconcilerOptions can be used to modify the behaviour of the Reconciler. func NewReconciler(options ReconcilerOptions) *Reconciler { return &Reconciler{ Client: options.Client, tailscaleNamespace: options.TailscaleNamespace, operatorSAName: options.OperatorSAName, clock: options.Clock, logger: options.Logger.Named(reconcilerName), clientFunc: options.ClientFunc, registry: options.Registry, } } // Register the Reconciler onto the given manager.Manager implementation. func (r *Reconciler) Register(mgr manager.Manager) error { return builder. ControllerManagedBy(mgr). For(&tsapi.Tailnet{}). Named(reconcilerName). Complete(r) } var ( // gaugeTailnetResources tracks the overall number of Tailnet resources currently managed by this operator instance. gaugeTailnetResources = clientmetric.NewGauge(kubetypes.MetricTailnetCount) ) // Reconcile is invoked when a change occurs to Tailnet resources within the cluster. On create/update, the Tailnet // resource is validated ensuring that the specified Secret exists and contains valid OAuth credentials that have // required permissions to perform all necessary functions by the operator. func (r *Reconciler) Reconcile(ctx context.Context, req reconcile.Request) (reconcile.Result, error) { var tailnet tsapi.Tailnet err := r.Get(ctx, req.NamespacedName, &tailnet) switch { case apierrors.IsNotFound(err): return reconcile.Result{}, nil case err != nil: return reconcile.Result{}, fmt.Errorf("failed to get Tailnet %q: %w", req.NamespacedName, err) } if !tailnet.DeletionTimestamp.IsZero() { return r.delete(ctx, &tailnet) } return r.createOrUpdate(ctx, &tailnet) } func (r *Reconciler) delete(ctx context.Context, tailnet *tsapi.Tailnet) (reconcile.Result, error) { reconciler.RemoveFinalizer(tailnet) if err := r.Update(ctx, tailnet); err != nil { return reconcile.Result{}, fmt.Errorf("failed to remove finalizer from Tailnet %q: %w", tailnet.Name, err) } r.mu.Lock() r.tailnets.Remove(tailnet.UID) r.mu.Unlock() gaugeTailnetResources.Set(int64(r.tailnets.Len())) r.registry.Remove(tailnet.Name) return reconcile.Result{}, nil } // Constants for condition reasons. const ( ReasonInvalidOAuth = "InvalidOAuth" ReasonInvalidSecret = "InvalidSecret" ReasonValid = "TailnetValid" ) func (r *Reconciler) createOrUpdate(ctx context.Context, tailnet *tsapi.Tailnet) (reconcile.Result, error) { r.mu.Lock() r.tailnets.Add(tailnet.UID) r.mu.Unlock() gaugeTailnetResources.Set(int64(r.tailnets.Len())) name := types.NamespacedName{Name: tailnet.Spec.Credentials.SecretName, Namespace: r.tailscaleNamespace} var secret corev1.Secret err := r.Get(ctx, name, &secret) // The referenced Secret does not exist within the tailscale namespace, so we'll mark the Tailnet as not ready // for use. if apierrors.IsNotFound(err) { operatorutils.SetTailnetCondition( tailnet, tsapi.TailnetReady, metav1.ConditionFalse, ReasonInvalidSecret, fmt.Sprintf("referenced secret %q does not exist in namespace %q", name.Name, r.tailscaleNamespace), r.clock, r.logger, ) if err = r.Status().Update(ctx, tailnet); err != nil { return reconcile.Result{}, fmt.Errorf("failed to update Tailnet status for %q: %w", tailnet.Name, err) } return reconcile.Result{}, nil } if err != nil { return reconcile.Result{}, fmt.Errorf("failed to get secret %q: %w", name, err) } // We first ensure that the referenced secret contains the required fields. Otherwise, we set the Tailnet as // invalid. The operator will not allow the use of this Tailnet while it is in an invalid state. if ok := r.ensureSecret(tailnet, &secret); !ok { if err = r.Status().Update(ctx, tailnet); err != nil { return reconcile.Result{}, fmt.Errorf("failed to update Tailnet status for %q: %w", tailnet.Name, err) } return reconcile.Result{RequeueAfter: time.Minute / 2}, nil } tsClient, err := r.createClient(tailnet, &secret) if err != nil { return reconcile.Result{}, fmt.Errorf("failed to create tailnet client: %w", err) } // Second, we ensure the OAuth credentials supplied in the secret are valid and have the required scopes to access // the various API endpoints required by the operator. if ok := r.ensurePermissions(ctx, tsClient, tailnet); !ok { r.registry.Add(tailnet.Name, tsClient, false) if err = r.Status().Update(ctx, tailnet); err != nil { return reconcile.Result{}, fmt.Errorf("failed to update Tailnet status for %q: %w", tailnet.Name, err) } // We provide a requeue duration here as a user will likely want to go and modify their scopes and come back. // This should save them having to delete and recreate the resource. return reconcile.Result{RequeueAfter: time.Minute / 2}, nil } operatorutils.SetTailnetCondition( tailnet, tsapi.TailnetReady, metav1.ConditionTrue, ReasonValid, ReasonValid, r.clock, r.logger, ) if err = r.Status().Update(ctx, tailnet); err != nil { return reconcile.Result{}, fmt.Errorf("failed to update Tailnet status for %q: %w", tailnet.Name, err) } reconciler.SetFinalizer(tailnet) if err = r.Update(ctx, tailnet); err != nil { return reconcile.Result{}, fmt.Errorf("failed to add finalizer to Tailnet %q: %w", tailnet.Name, err) } r.registry.Add(tailnet.Name, tsClient, true) return reconcile.Result{}, nil } // Constants for credential fields within the Secret referenced by the Tailnet. const ( clientIDKey = "client_id" clientSecretKey = "client_secret" audienceKey = "audience" ) func (r *Reconciler) createClient(tailnet *tsapi.Tailnet, secret *corev1.Secret) (tsclient.Client, error) { if r.clientFunc != nil { return r.clientFunc(tailnet, secret), nil } baseURL := ipn.DefaultControlURL if tailnet.Spec.LoginURL != "" { baseURL = tailnet.Spec.LoginURL } base, err := url.Parse(baseURL) if err != nil { return nil, fmt.Errorf("failed to parse base URL %q: %w", baseURL, err) } var auth tailscale.Auth clientID := string(secret.Data[clientIDKey]) audience := string(secret.Data[audienceKey]) clientSecret := string(secret.Data[clientSecretKey]) switch { case audience != "": // If the audience field is present, we assume workload identity as the authentication method. auth = &tailscale.IdentityFederation{ ClientID: clientID, IDTokenFunc: r.createToken(audience), } case clientSecret != "": // For a client secret, we assume oauth. auth = &tailscale.OAuth{ ClientID: clientID, ClientSecret: clientSecret, } default: // We shouldn't land here as previous functions will have ensured the validity of the secret, but we // error here anyway as we won't know what to do: return nil, errors.New("unable to determine authentication method") } return tsclient.Wrap(&tailscale.Client{ BaseURL: base, UserAgent: "tailscale-k8s-operator", Auth: auth, }), nil } func (r *Reconciler) createToken(audience string) func() (string, error) { return func() (string, error) { serviceAccount := &corev1.ServiceAccount{ ObjectMeta: metav1.ObjectMeta{ Name: r.operatorSAName, Namespace: r.tailscaleNamespace, }, } tokenRequest := &authnv1.TokenRequest{ Spec: authnv1.TokenRequestSpec{ Audiences: []string{audience}, }, } if err := r.SubResource("token").Create(context.Background(), serviceAccount, tokenRequest); err != nil { return "", fmt.Errorf("failed to mint service account token for %q in namespace %q: %w", r.operatorSAName, r.tailscaleNamespace, err) } return tokenRequest.Status.Token, nil } } func (r *Reconciler) ensurePermissions(ctx context.Context, tsClient tsclient.Client, tailnet *tsapi.Tailnet) bool { // Perform basic list requests here to confirm that the OAuth credentials referenced on the Tailnet resource // can perform the basic operations required for the operator to function. This has a caveat of only performing // read actions, as we don't want to create arbitrary keys and VIP services. However, it will catch when a user // has completely forgotten an entire scope that's required. var errs error if _, err := tsClient.Devices().List(ctx); err != nil { errs = errors.Join(errs, fmt.Errorf("failed to list devices: %w (client may be missing the devices scope)", err)) } if _, err := tsClient.Keys().List(ctx, false); err != nil { errs = errors.Join(errs, fmt.Errorf("failed to list auth keys: %w (client may be missing the keys scope)", err)) } if _, err := tsClient.VIPServices().List(ctx); err != nil { errs = errors.Join(errs, fmt.Errorf("failed to list tailscale services: %w (client may be missing the services scope)", err)) } if errs != nil { operatorutils.SetTailnetCondition( tailnet, tsapi.TailnetReady, metav1.ConditionFalse, ReasonInvalidOAuth, errs.Error(), r.clock, r.logger, ) return false } return true } func (r *Reconciler) ensureSecret(tailnet *tsapi.Tailnet, secret *corev1.Secret) bool { var message string switch { case len(secret.Data) == 0: message = fmt.Sprintf("Secret %q is empty", secret.Name) case len(secret.Data[clientIDKey]) == 0: message = fmt.Sprintf("Secret %q is missing the client_id field", secret.Name) case len(secret.Data[clientSecretKey]) == 0 && len(secret.Data[audienceKey]) == 0: message = fmt.Sprintf("Secret %q must contain either a client_secret or an audience field", secret.Name) } if message == "" { return true } operatorutils.SetTailnetCondition( tailnet, tsapi.TailnetReady, metav1.ConditionFalse, ReasonInvalidSecret, message, r.clock, r.logger, ) return false }