Initial bind-operator: 9 CRDs + controllers
Implements a Kubernetes operator that manages fleets of BIND9 servers declaratively, using controller-runtime (matching forgebot conventions). - add BindCluster reconciler: StatefulSet (pod-0 primary, secondaries), headless + client Services, rendered named.conf ConfigMap, TSIG keys Secret and rndc control Secret; watches dependent CRs to re-render - add BindTSIGKey reconciler that generates key material into a Secret - add BindZone/DNSRecord reconcilers using fully-dynamic delivery (rndc addzone + TSIG nsupdate against the primary pod) - add BindCatalogZone reconciler so secondaries auto-provision zones - add BindPolicy (RPZ), BindDNSSECPolicy, BindView, BindACL reconcilers - render primary/secondary named.conf variants selected by pod ordinal - generate CRDs, deepcopy and RBAC; add samples mapping the three Puppet roles (authoritative/resolver/external-dns) to three BindClusters - add Makefile, Dockerfile.operator, Woodpecker CI and kind manifests
This commit is contained in:
@@ -0,0 +1,163 @@
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user