// Copyright (c) Tailscale Inc & contributors // SPDX-License-Identifier: BSD-3-Clause //go:build !plan9 // Package proxygrouppolicy provides reconciliation logic for the ProxyGroupPolicy custom resource definition. It is // responsible for generating ValidatingAdmissionPolicy resources that limit users to a set number of ProxyGroup // names that can be used within Service and Ingress resources via the "tailscale.com/proxy-group" annotation. package proxygrouppolicy import ( "context" "fmt" "sort" "strconv" "strings" admr "k8s.io/api/admissionregistration/v1" apierrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "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" tsapi "tailscale.com/k8s-operator/apis/v1alpha1" "tailscale.com/util/set" ) type ( // The Reconciler type is a reconcile.TypedReconciler implementation used to manage the reconciliation of // ProxyGroupPolicy custom resources. Reconciler struct { client.Client } // The ReconcilerOptions type contains configuration values for the Reconciler. ReconcilerOptions struct { // The client for interacting with the Kubernetes API. Client client.Client } ) const reconcilerName = "proxygrouppolicy-reconciler" // NewReconciler returns a new instance of the Reconciler type. It watches specifically for changes to ProxyGroupPolicy // custom resources. The ReconcilerOptions can be used to modify the behaviour of the Reconciler. func NewReconciler(options ReconcilerOptions) *Reconciler { return &Reconciler{ Client: options.Client, } } // Register the Reconciler onto the given manager.Manager implementation. func (r *Reconciler) Register(mgr manager.Manager) error { return builder. ControllerManagedBy(mgr). For(&tsapi.ProxyGroupPolicy{}). Named(reconcilerName). Complete(r) } func (r *Reconciler) Reconcile(ctx context.Context, req reconcile.Request) (reconcile.Result, error) { // Rather than working on a single ProxyGroupPolicy resource, we list all that exist within the // same namespace as the one we're reconciling so that we can merge them into a single pair of // ValidatingAdmissionPolicy resources. var policies tsapi.ProxyGroupPolicyList if err := r.List(ctx, &policies, client.InNamespace(req.Namespace)); err != nil { return reconcile.Result{}, fmt.Errorf("failed to list ProxyGroupPolicy resources %q: %w", req.NamespacedName, err) } if len(policies.Items) == 0 { // If we've got no items in the list, we go and delete any policies and bindings that // may exist. return r.delete(ctx, req.Namespace) } return r.createOrUpdate(ctx, req.Namespace, policies) } func (r *Reconciler) delete(ctx context.Context, namespace string) (reconcile.Result, error) { ingress := "ts-ingress-" + namespace egress := "ts-egress-" + namespace objects := []client.Object{ &admr.ValidatingAdmissionPolicy{ ObjectMeta: metav1.ObjectMeta{ Name: ingress, }, }, &admr.ValidatingAdmissionPolicyBinding{ ObjectMeta: metav1.ObjectMeta{ Name: ingress, }, }, &admr.ValidatingAdmissionPolicy{ ObjectMeta: metav1.ObjectMeta{ Name: egress, }, }, &admr.ValidatingAdmissionPolicyBinding{ ObjectMeta: metav1.ObjectMeta{ Name: egress, }, }, } for _, obj := range objects { err := r.Delete(ctx, obj) switch { case apierrors.IsNotFound(err): // A resource may have already been deleted in a previous reconciliation that failed for // some reason, so we'll ignore it if it doesn't exist. continue case err != nil: return reconcile.Result{}, fmt.Errorf("failed to delete %s %q: %w", obj.GetObjectKind().GroupVersionKind().Kind, obj.GetName(), err) } } return reconcile.Result{}, nil } func (r *Reconciler) createOrUpdate(ctx context.Context, namespace string, policies tsapi.ProxyGroupPolicyList) (reconcile.Result, error) { ingressNames := set.Set[string]{} egressNames := set.Set[string]{} // If this namespace has multiple ProxyGroupPolicy resources, we'll reduce them down to just their distinct // egress/ingress names. for _, policy := range policies.Items { ingressNames.AddSlice(policy.Spec.Ingress) egressNames.AddSlice(policy.Spec.Egress) } ingress, err := r.generateIngressPolicy(ctx, namespace, ingressNames) if err != nil { return reconcile.Result{}, fmt.Errorf("failed to generate ingress policy: %w", err) } ingressBinding, err := r.generatePolicyBinding(ctx, namespace, ingress) if err != nil { return reconcile.Result{}, fmt.Errorf("failed to generate ingress policy binding: %w", err) } egress, err := r.generateEgressPolicy(ctx, namespace, egressNames) if err != nil { return reconcile.Result{}, fmt.Errorf("failed to generate egress policy: %w", err) } egressBinding, err := r.generatePolicyBinding(ctx, namespace, egress) if err != nil { return reconcile.Result{}, fmt.Errorf("failed to generate egress policy binding: %w", err) } objects := []client.Object{ ingress, ingressBinding, egress, egressBinding, } for _, obj := range objects { // Attempt to perform an update first as we'll only create these once and continually update them, so it's // more likely that an update is needed instead of creation. If the resource does not exist, we'll // create it. err = r.Update(ctx, obj) switch { case apierrors.IsNotFound(err): if err = r.Create(ctx, obj); err != nil { return reconcile.Result{}, fmt.Errorf("failed to create %s %q: %w", obj.GetObjectKind().GroupVersionKind().Kind, obj.GetName(), err) } case err != nil: return reconcile.Result{}, fmt.Errorf("failed to update %s %q: %w", obj.GetObjectKind().GroupVersionKind().Kind, obj.GetName(), err) } } return reconcile.Result{}, nil } const ( // ingressCEL enforces proxy-group annotation rules for Ingress resources. // // Logic: // // - If the object is NOT an Ingress → allow (this validation is irrelevant) // - If the annotation is absent → allow (annotation is optional) // - If the annotation is present → its value must be in the allowlist // // Empty allowlist behavior: // If the list is empty, any present annotation will fail membership, // effectively acting as "deny-all". ingressCEL = `request.kind.kind != "Ingress" || !("tailscale.com/proxy-group" in object.metadata.annotations) || object.metadata.annotations["tailscale.com/proxy-group"] in [%s]` // ingressServiceCEL enforces proxy-group annotation rules for Services // that are using the tailscale load balancer. // // Logic: // // - If the object is NOT a Service → allow // - If Service does NOT use loadBalancerClass "tailscale" → allow // (egress policy will handle those) // - If annotation is absent → allow // - If annotation is present → must be in allowlist // // This makes ingress policy apply ONLY to tailscale Services. ingressServiceCEL = `request.kind.kind != "Service" || !((has(object.spec.loadBalancerClass) && object.spec.loadBalancerClass == "tailscale") || ("tailscale.com/expose" in object.metadata.annotations && object.metadata.annotations["tailscale.com/expose"] == "true")) || (!("tailscale.com/proxy-group" in object.metadata.annotations) || object.metadata.annotations["tailscale.com/proxy-group"] in [%s])` // egressCEL enforces proxy-group annotation rules for Services that // are NOT using the tailscale load balancer. // // Logic: // // - If Service uses loadBalancerClass "tailscale" → allow // (ingress policy handles those) // - If Service uses "tailscale.com/expose" → allow // (ingress policy handles those) // - If annotation is absent → allow // - If annotation is present → must be in allowlist // // Empty allowlist behavior: // Any present annotation is rejected ("deny-all"). // // This expression is mutually exclusive with ingressServiceCEL, // preventing policy conflicts. egressCEL = `((has(object.spec.loadBalancerClass) && object.spec.loadBalancerClass == "tailscale") || ("tailscale.com/expose" in object.metadata.annotations && object.metadata.annotations["tailscale.com/expose"] == "true")) || !("tailscale.com/proxy-group" in object.metadata.annotations) || object.metadata.annotations["tailscale.com/proxy-group"] in [%s]` ) func (r *Reconciler) generateIngressPolicy(ctx context.Context, namespace string, names set.Set[string]) (*admr.ValidatingAdmissionPolicy, error) { name := "ts-ingress-" + namespace var policy admr.ValidatingAdmissionPolicy err := r.Get(ctx, client.ObjectKey{Name: name}, &policy) switch { case apierrors.IsNotFound(err): // If it's not found, we can create a new one. We only want the existing one for // its resource version. case err != nil: return nil, fmt.Errorf("failed to get ValidatingAdmissionPolicy %q: %w", name, err) } return &admr.ValidatingAdmissionPolicy{ ObjectMeta: metav1.ObjectMeta{ Name: name, ResourceVersion: policy.ResourceVersion, }, Spec: admr.ValidatingAdmissionPolicySpec{ FailurePolicy: new(admr.Fail), MatchConstraints: &admr.MatchResources{ // The operator allows ingress via Ingress resources & Service resources (that use the "tailscale" load // balancer class), so we have two resource rules here with multiple validation expressions that attempt // to keep out of each other's way. ResourceRules: []admr.NamedRuleWithOperations{ { RuleWithOperations: admr.RuleWithOperations{ Operations: []admr.OperationType{ admr.Create, admr.Update, }, Rule: admr.Rule{ APIGroups: []string{"networking.k8s.io"}, APIVersions: []string{"*"}, Resources: []string{"ingresses"}, }, }, }, { RuleWithOperations: admr.RuleWithOperations{ Operations: []admr.OperationType{ admr.Create, admr.Update, }, Rule: admr.Rule{ APIGroups: []string{""}, APIVersions: []string{"v1"}, Resources: []string{"services"}, }, }, }, }, }, Validations: []admr.Validation{ generateValidation(names, ingressCEL), generateValidation(names, ingressServiceCEL), }, }, }, nil } func (r *Reconciler) generateEgressPolicy(ctx context.Context, namespace string, names set.Set[string]) (*admr.ValidatingAdmissionPolicy, error) { name := "ts-egress-" + namespace var policy admr.ValidatingAdmissionPolicy err := r.Get(ctx, client.ObjectKey{Name: name}, &policy) switch { case apierrors.IsNotFound(err): // If it's not found, we can create a new one. We only want the existing one for // its resource version. case err != nil: return nil, fmt.Errorf("failed to get ValidatingAdmissionPolicy %q: %w", name, err) } return &admr.ValidatingAdmissionPolicy{ ObjectMeta: metav1.ObjectMeta{ Name: name, ResourceVersion: policy.ResourceVersion, }, Spec: admr.ValidatingAdmissionPolicySpec{ FailurePolicy: new(admr.Fail), MatchConstraints: &admr.MatchResources{ ResourceRules: []admr.NamedRuleWithOperations{ { RuleWithOperations: admr.RuleWithOperations{ Operations: []admr.OperationType{ admr.Create, admr.Update, }, Rule: admr.Rule{ APIGroups: []string{""}, APIVersions: []string{"v1"}, Resources: []string{"services"}, }, }, }, }, }, Validations: []admr.Validation{ generateValidation(names, egressCEL), }, }, }, nil } const ( denyMessage = `Annotation "tailscale.com/proxy-group" cannot be used on this resource in this namespace` messageFormat = `If set, annotation "tailscale.com/proxy-group" must be one of [%s]` ) func generateValidation(names set.Set[string], format string) admr.Validation { values := names.Slice() // We use a sort here so that the order of the proxy-group names are consistent // across reconciliation loops. sort.Strings(values) quoted := make([]string, len(values)) for i, v := range values { quoted[i] = strconv.Quote(v) } joined := strings.Join(quoted, ",") message := fmt.Sprintf(messageFormat, strings.Join(values, ", ")) if len(values) == 0 { message = denyMessage } return admr.Validation{ Expression: fmt.Sprintf(format, joined), Message: message, } } func (r *Reconciler) generatePolicyBinding(ctx context.Context, namespace string, policy *admr.ValidatingAdmissionPolicy) (*admr.ValidatingAdmissionPolicyBinding, error) { var binding admr.ValidatingAdmissionPolicyBinding err := r.Get(ctx, client.ObjectKey{Name: policy.Name}, &binding) switch { case apierrors.IsNotFound(err): // If it's not found, we can create a new one. We only want the existing one for // its resource version. case err != nil: return nil, fmt.Errorf("failed to get ValidatingAdmissionPolicyBinding %q: %w", policy.Name, err) } return &admr.ValidatingAdmissionPolicyBinding{ ObjectMeta: metav1.ObjectMeta{ Name: policy.Name, ResourceVersion: binding.ResourceVersion, }, Spec: admr.ValidatingAdmissionPolicyBindingSpec{ PolicyName: policy.Name, ValidationActions: []admr.ValidationAction{ admr.Deny, }, MatchResources: &admr.MatchResources{ NamespaceSelector: &metav1.LabelSelector{ MatchLabels: map[string]string{ "kubernetes.io/metadata.name": namespace, }, }, }, }, }, nil }