package controller import ( "context" "fmt" "strings" "k8s.io/apimachinery/pkg/runtime" ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/log" bindv1alpha1 "git.unkin.net/unkin/bind-operator/api/v1alpha1" "git.unkin.net/unkin/bind-operator/internal/bind" ) // BindPolicyReconciler provisions a Response Policy Zone (RPZ) on a cluster // primary and seeds its rules. The cluster controller renders the matching // response-policy clause into named.conf. type BindPolicyReconciler struct { client.Client Scheme *runtime.Scheme Exec *bind.Executor } // +kubebuilder:rbac:groups=bind.unkin.net,resources=bindpolicies,verbs=get;list;watch;create;update;patch;delete // +kubebuilder:rbac:groups=bind.unkin.net,resources=bindpolicies/status,verbs=get;update;patch // +kubebuilder:rbac:groups=bind.unkin.net,resources=bindtsigkeys,verbs=get;list;watch func (r *BindPolicyReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { logger := log.FromContext(ctx) var policy bindv1alpha1.BindPolicy if err := r.Get(ctx, req.NamespacedName, &policy); err != nil { return ctrl.Result{}, client.IgnoreNotFound(err) } cluster, err := getCluster(ctx, r.Client, policy.Namespace, policy.Spec.ClusterRef) if err != nil { return r.fail(ctx, &policy, "ClusterMissing", err.Error()) } primaryPod := primaryPodName(cluster.Name) if !primaryReady(ctx, r.Client, cluster) || r.Exec == nil { return r.fail(ctx, &policy, "PrimaryNotReady", "waiting for cluster primary") } // Externally-fed RPZ: configure as a secondary of the feed. Otherwise host a // locally-populated primary RPZ zone. if len(policy.Spec.Primaries) > 0 { creds, _ := resolveTSIG(ctx, r.Client, policy.Namespace, policy.Spec.TransferKeyRef) _ = creds cfg := fmt.Sprintf("{ type secondary; file \"%s\"; primaries { %s }; };", bind.ZoneFilePath(policy.Spec.ZoneName), terminateInline(policy.Spec.Primaries)) if err := r.Exec.AddZone(ctx, policy.Namespace, primaryPod, policy.Spec.ZoneName, policy.Spec.ViewRef, cfg); err != nil { return r.fail(ctx, &policy, "AddZoneFailed", err.Error()) } return r.ready(ctx, &policy, int32(0)) } creds, err := resolveTSIG(ctx, r.Client, policy.Namespace, policy.Spec.TransferKeyRef) if err != nil { return r.fail(ctx, &policy, "NoUpdateKey", "spec.transferKeyRef required to seed RPZ rules") } if !r.Exec.ZoneExists(ctx, policy.Namespace, primaryPod, policy.Spec.ZoneName, policy.Spec.ViewRef) { if err := r.Exec.WriteSeedZone(ctx, policy.Namespace, primaryPod, policy.Spec.ZoneName, bind.ZoneFilePath(policy.Spec.ZoneName), "", 1); err != nil { return r.fail(ctx, &policy, "SeedFailed", err.Error()) } } cfg := fmt.Sprintf("{ type primary; file \"%s\"; allow-update { key \"%s\"; }; };", bind.ZoneFilePath(policy.Spec.ZoneName), policy.Spec.TransferKeyRef) if err := r.Exec.AddZone(ctx, policy.Namespace, primaryPod, policy.Spec.ZoneName, policy.Spec.ViewRef, cfg); err != nil { return r.fail(ctx, &policy, "AddZoneFailed", err.Error()) } updates := rpzRulesToUpdates(policy.Spec.ZoneName, policy.Spec.Rules) if len(updates) > 0 { if err := r.Exec.NSUpdate(ctx, policy.Namespace, primaryPod, policy.Spec.ZoneName, creds, updates); err != nil { return r.fail(ctx, &policy, "RuleUpdateFailed", err.Error()) } } logger.Info("policy reconciled", "zone", policy.Spec.ZoneName, "rules", len(updates)) return r.ready(ctx, &policy, int32(len(updates))) } // rpzRulesToUpdates maps RPZ rules to the CNAME records that encode them. func rpzRulesToUpdates(rpzZone string, rules []bindv1alpha1.RPZRule) []bind.RecordUpdate { var updates []bind.RecordUpdate origin := strings.TrimSuffix(rpzZone, ".") + "." for _, rule := range rules { trigger := rule.Trigger if trigger == "" { trigger = "qname" } match := strings.TrimSuffix(strings.TrimSpace(rule.Match), ".") var owner string switch trigger { case "qname": owner = match + "." + origin case "client-ip": owner = match + ".rpz-client-ip." + origin case "ip": owner = match + ".rpz-ip." + origin case "nsdname": owner = match + ".rpz-nsdname." + origin case "nsip": owner = match + ".rpz-nsip." + origin default: owner = match + "." + origin } action := rule.Action if action == "" { action = "nxdomain" } var rdata string switch action { case "nxdomain": rdata = "." case "nodata": rdata = "*." case "passthru": rdata = "rpz-passthru." case "drop": rdata = "rpz-drop." case "tcp-only": rdata = "rpz-tcp-only." case "cname": rdata = strings.TrimSuffix(rule.Target, ".") + "." default: rdata = "." } updates = append(updates, bind.RecordUpdate{FQDN: owner, Type: "CNAME", TTL: 3600, Values: []string{rdata}}) } return updates } func (r *BindPolicyReconciler) ready(ctx context.Context, policy *bindv1alpha1.BindPolicy, rules int32) (ctrl.Result, error) { policy.Status.Ready = true policy.Status.RuleCount = rules policy.Status.ObservedGeneration = policy.Generation setReady(&policy.Status.Conditions, policy.Generation, true, "Ready", "RPZ provisioned") if err := r.Status().Update(ctx, policy); err != nil { return ctrl.Result{}, err } return ctrl.Result{RequeueAfter: requeueLong}, nil } func (r *BindPolicyReconciler) fail(ctx context.Context, policy *bindv1alpha1.BindPolicy, reason, msg string) (ctrl.Result, error) { policy.Status.Ready = false policy.Status.ObservedGeneration = policy.Generation setReady(&policy.Status.Conditions, policy.Generation, false, reason, msg) if err := r.Status().Update(ctx, policy); err != nil { return ctrl.Result{}, err } return ctrl.Result{RequeueAfter: requeueShort}, nil } func (r *BindPolicyReconciler) SetupWithManager(mgr ctrl.Manager) error { return ctrl.NewControllerManagedBy(mgr). For(&bindv1alpha1.BindPolicy{}). Complete(r) }