Files
bind-operator/internal/controller/bindzone_controller.go
T
unkinben ea330bd767
ci/woodpecker/pr/pre-commit Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/build Pipeline was successful
Fix authoritative secondary replication (TSIG transfer + stable primary)
Secondaries never replicated any member zone: the master's catalog zone
requires key-authenticated AXFR (allow-transfer { key "transfer-key"; }),
but the rendered secondary config transferred without presenting the key,
so every catalog transfer was REFUSED and no member zones provisioned.
Two further gaps compounded it: member zones had no allow-transfer at all,
and secondaries pointed at the primary's pod IP, which dies on restart.

- Render the catalog transfer key into the secondary catalog-zones
  default-primaries and the secondary catalog zone primaries, so
  key-authenticated AXFR from the primary is accepted.
- Add allow-transfer { key "<transfer-key>"; } to catalog member primary
  zones (when the zone does not set an explicit allow-transfer), so
  secondaries can pull them; applied to existing zones via modzone.
- Point secondaries at the stable primary Service ClusterIP instead of the
  primary pod IP, so replication survives primary pod restarts (falls back
  to the pod IP when no primary Service exists).
2026-07-12 19:42:38 +10:00

267 lines
11 KiB
Go

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/controller/controllerutil"
"sigs.k8s.io/controller-runtime/pkg/log"
bindv1alpha1 "git.unkin.net/unkin/bind-operator/api/v1alpha1"
"git.unkin.net/unkin/bind-operator/internal/bind"
)
// BindZoneReconciler provisions zones on a cluster primary via rndc addzone and
// seeds records via dynamic update.
type BindZoneReconciler struct {
client.Client
Scheme *runtime.Scheme
Exec *bind.Executor
}
// +kubebuilder:rbac:groups=bind.unkin.net,resources=bindzones,verbs=get;list;watch;create;update;patch;delete
// +kubebuilder:rbac:groups=bind.unkin.net,resources=bindzones/status,verbs=get;update;patch
// +kubebuilder:rbac:groups=bind.unkin.net,resources=bindcatalogzones;bindtsigkeys,verbs=get;list;watch
func (r *BindZoneReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
logger := log.FromContext(ctx)
var zone bindv1alpha1.BindZone
if err := r.Get(ctx, req.NamespacedName, &zone); err != nil {
return ctrl.Result{}, client.IgnoreNotFound(err)
}
// Forward zones are pure configuration rendered into named.conf by the
// BindCluster controller (on every pod), not added dynamically to the
// primary. Nothing to do here beyond reporting readiness.
if zone.Spec.Type == bindv1alpha1.ZoneForward {
zone.Status.Phase = "Ready"
zone.Status.ObservedGeneration = zone.Generation
setReady(&zone.Status.Conditions, zone.Generation, true, "Configured", "forward zone rendered into named.conf")
if err := r.Status().Update(ctx, &zone); err != nil {
return ctrl.Result{}, err
}
return ctrl.Result{}, nil
}
cluster, err := getCluster(ctx, r.Client, zone.Namespace, zone.Spec.ClusterRef)
if err != nil {
return r.setPhase(ctx, &zone, "Error", "ClusterMissing", err.Error())
}
primaryPod := primaryPodName(cluster.Name)
// Handle deletion via finalizer: remove the zone from the primary and catalog.
if !zone.DeletionTimestamp.IsZero() {
if controllerutil.ContainsFinalizer(&zone, finalizer) {
if primaryReady(ctx, r.Client, cluster) && r.Exec != nil {
_ = r.Exec.DelZone(ctx, zone.Namespace, primaryPod, zone.Spec.ZoneName, zone.Spec.ViewRef)
r.deregisterCatalog(ctx, &zone, cluster, primaryPod)
}
controllerutil.RemoveFinalizer(&zone, finalizer)
if err := r.Update(ctx, &zone); err != nil {
return ctrl.Result{}, err
}
}
return ctrl.Result{}, nil
}
if !controllerutil.ContainsFinalizer(&zone, finalizer) {
controllerutil.AddFinalizer(&zone, finalizer)
if err := r.Update(ctx, &zone); err != nil {
return ctrl.Result{}, err
}
}
if !primaryReady(ctx, r.Client, cluster) || r.Exec == nil {
return r.setPhase(ctx, &zone, "Pending", "PrimaryNotReady", "waiting for cluster primary to be ready")
}
zoneConfig, err := r.buildZoneConfig(ctx, &zone, r.zoneTransferKeyRef(ctx, &zone, cluster))
if err != nil {
return r.setPhase(ctx, &zone, "Error", "ConfigError", err.Error())
}
created := !r.Exec.ZoneExists(ctx, zone.Namespace, primaryPod, zone.Spec.ZoneName, zone.Spec.ViewRef)
if created && (zone.Spec.Type == bindv1alpha1.ZonePrimary || zone.Spec.Type == "") {
primaryIP := primaryPodIP(ctx, r.Client, cluster)
if primaryIP == "" {
return r.setPhase(ctx, &zone, "Pending", "PrimaryNoIP", "waiting for primary pod IP")
}
if err := r.Exec.WriteSeedZone(ctx, zone.Namespace, primaryPod, zone.Spec.ZoneName, bind.ZoneFilePath(zone.Spec.ZoneName), primaryIP, 1); err != nil {
return r.setPhase(ctx, &zone, "Error", "SeedFailed", err.Error())
}
}
if err := r.Exec.AddZone(ctx, zone.Namespace, primaryPod, zone.Spec.ZoneName, zone.Spec.ViewRef, zoneConfig); err != nil {
return r.setPhase(ctx, &zone, "Error", "AddZoneFailed", err.Error())
}
// Seed static records (primary zones only).
recordCount := 0
if isPrimaryType(zone.Spec.Type) && len(zone.Spec.Records) > 0 {
creds, err := r.zoneUpdateCreds(ctx, &zone)
if err != nil {
return r.setPhase(ctx, &zone, "Error", "NoUpdateKey", err.Error())
}
updates := recordsToUpdates(zone.Spec.ZoneName, zone.Spec.Records, zone.Spec.DefaultTTL)
if err := r.Exec.NSUpdate(ctx, zone.Namespace, primaryPod, zone.Spec.ZoneName, creds, updates); err != nil {
return r.setPhase(ctx, &zone, "Error", "RecordUpdateFailed", err.Error())
}
recordCount = len(updates)
}
// Register in the catalog so secondaries auto-provision.
if catalogEnabled(&zone) {
r.registerCatalog(ctx, &zone, cluster, primaryPod)
}
serial, _ := r.Exec.ZoneSerial(ctx, zone.Namespace, primaryPod, zone.Spec.ZoneName, zone.Spec.ViewRef)
zone.Status.Phase = "Ready"
zone.Status.Serial = serial
zone.Status.RecordCount = int32(recordCount)
zone.Status.Signed = zone.Spec.DNSSECPolicyRef != ""
zone.Status.ObservedGeneration = zone.Generation
setReady(&zone.Status.Conditions, zone.Generation, true, "Provisioned", "zone provisioned on primary")
if err := r.Status().Update(ctx, &zone); err != nil {
return ctrl.Result{}, err
}
logger.Info("zone reconciled", "zone", zone.Spec.ZoneName, "serial", serial)
return ctrl.Result{RequeueAfter: requeueLong}, nil
}
// buildZoneConfig renders the inner clause passed to rndc addzone/modzone.
// transferKey, when set, is the catalog transfer TSIG key name; catalog member
// primary zones must allow AXFR with it so secondaries can pull them.
func (r *BindZoneReconciler) buildZoneConfig(ctx context.Context, zone *bindv1alpha1.BindZone, transferKey string) (string, error) {
zType := zone.Spec.Type
if zType == "" {
zType = bindv1alpha1.ZonePrimary
}
var parts []string
switch zType {
case bindv1alpha1.ZonePrimary:
parts = append(parts, "type primary", fmt.Sprintf("file \"%s\"", bind.ZoneFilePath(zone.Spec.ZoneName)))
if zone.Spec.DynamicUpdate && zone.Spec.UpdateKeyRef != "" {
parts = append(parts, fmt.Sprintf("allow-update { key \"%s\"; }", updateKeyName(ctx, r.Client, zone)))
}
switch {
case len(zone.Spec.AllowTransfer) > 0:
parts = append(parts, fmt.Sprintf("allow-transfer { %s }", matchListInline(zone.Spec.AllowTransfer)))
case transferKey != "":
// Catalog member: permit key-authenticated AXFR from secondaries.
parts = append(parts, fmt.Sprintf("allow-transfer { key \"%s\"; }", transferKey))
}
if zone.Spec.DNSSECPolicyRef != "" {
parts = append(parts, fmt.Sprintf("dnssec-policy \"%s\"", zone.Spec.DNSSECPolicyRef), "inline-signing yes")
}
case bindv1alpha1.ZoneSecondary:
parts = append(parts, "type secondary", fmt.Sprintf("file \"%s\"", bind.ZoneFilePath(zone.Spec.ZoneName)))
if len(zone.Spec.Primaries) > 0 {
parts = append(parts, fmt.Sprintf("primaries { %s }", terminateInline(zone.Spec.Primaries)))
}
case bindv1alpha1.ZoneForward:
parts = append(parts, "type forward", "forward only")
if len(zone.Spec.Forwarders) > 0 {
parts = append(parts, fmt.Sprintf("forwarders { %s }", terminateInline(zone.Spec.Forwarders)))
}
case bindv1alpha1.ZoneStub:
parts = append(parts, "type stub", fmt.Sprintf("file \"%s\"", bind.ZoneFilePath(zone.Spec.ZoneName)))
if len(zone.Spec.Primaries) > 0 {
parts = append(parts, fmt.Sprintf("primaries { %s }", terminateInline(zone.Spec.Primaries)))
}
}
return "{ " + strings.Join(parts, "; ") + "; };", nil
}
func (r *BindZoneReconciler) zoneUpdateCreds(ctx context.Context, zone *bindv1alpha1.BindZone) (bind.TSIGCreds, error) {
keyRef := zone.Spec.UpdateKeyRef
if keyRef == "" {
keyRef = zone.Spec.TransferKeyRef
}
if keyRef == "" {
// Fall back to local (non-TSIG) update when the zone allows it; most
// seeded primaries permit localhost updates.
return bind.TSIGCreds{}, fmt.Errorf("records require spec.updateKeyRef")
}
return resolveTSIG(ctx, r.Client, zone.Namespace, keyRef)
}
func (r *BindZoneReconciler) registerCatalog(ctx context.Context, zone *bindv1alpha1.BindZone, cluster *bindv1alpha1.BindCluster, primaryPod string) {
logger := log.FromContext(ctx)
catalog, creds, ok := r.catalogFor(ctx, zone, cluster)
if !ok {
return
}
if err := r.Exec.AddCatalogMember(ctx, zone.Namespace, primaryPod, catalog.Spec.ZoneName, zone.Spec.ZoneName, creds); err != nil {
logger.V(1).Info("catalog register failed", "zone", zone.Spec.ZoneName, "err", err.Error())
}
}
func (r *BindZoneReconciler) deregisterCatalog(ctx context.Context, zone *bindv1alpha1.BindZone, cluster *bindv1alpha1.BindCluster, primaryPod string) {
catalog, creds, ok := r.catalogFor(ctx, zone, cluster)
if !ok {
return
}
_ = r.Exec.RemoveCatalogMember(ctx, zone.Namespace, primaryPod, catalog.Spec.ZoneName, zone.Spec.ZoneName, creds)
}
// zoneTransferKeyRef returns the catalog transfer TSIG key name that a catalog
// member primary zone must allow AXFR with, so secondaries (which present that
// key) can pull it. Returns "" for non-member zones, non-primary zones, or when
// the cluster has no catalog.
func (r *BindZoneReconciler) zoneTransferKeyRef(ctx context.Context, zone *bindv1alpha1.BindZone, cluster *bindv1alpha1.BindCluster) string {
if !isPrimaryType(zone.Spec.Type) || !catalogEnabled(zone) {
return ""
}
var catalogs bindv1alpha1.BindCatalogZoneList
if err := r.List(ctx, &catalogs, client.InNamespace(zone.Namespace)); err != nil {
return ""
}
for i := range catalogs.Items {
if catalogs.Items[i].Spec.ClusterRef == cluster.Name {
return catalogs.Items[i].Spec.TransferKeyRef
}
}
return ""
}
func (r *BindZoneReconciler) catalogFor(ctx context.Context, zone *bindv1alpha1.BindZone, cluster *bindv1alpha1.BindCluster) (*bindv1alpha1.BindCatalogZone, bind.TSIGCreds, bool) {
var catalogs bindv1alpha1.BindCatalogZoneList
if err := r.List(ctx, &catalogs, client.InNamespace(zone.Namespace)); err != nil {
return nil, bind.TSIGCreds{}, false
}
for i := range catalogs.Items {
if catalogs.Items[i].Spec.ClusterRef == cluster.Name {
cat := &catalogs.Items[i]
creds, err := resolveTSIG(ctx, r.Client, zone.Namespace, cat.Spec.TransferKeyRef)
if err != nil {
return nil, bind.TSIGCreds{}, false
}
return cat, creds, true
}
}
return nil, bind.TSIGCreds{}, false
}
func (r *BindZoneReconciler) setPhase(ctx context.Context, zone *bindv1alpha1.BindZone, phase, reason, msg string) (ctrl.Result, error) {
zone.Status.Phase = phase
zone.Status.ObservedGeneration = zone.Generation
setReady(&zone.Status.Conditions, zone.Generation, phase == "Ready", reason, msg)
if err := r.Status().Update(ctx, zone); err != nil {
return ctrl.Result{}, err
}
if phase == "Error" || phase == "Pending" {
return ctrl.Result{RequeueAfter: requeueShort}, nil
}
return ctrl.Result{}, nil
}
func (r *BindZoneReconciler) SetupWithManager(mgr ctrl.Manager) error {
return ctrl.NewControllerManagedBy(mgr).
For(&bindv1alpha1.BindZone{}).
Complete(r)
}