From ea330bd767677a34a34dcaea662b78f9ff2d2630 Mon Sep 17 00:00:00 2001 From: Ben Vincent Date: Sun, 12 Jul 2026 19:42:38 +1000 Subject: [PATCH] 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 ""; } 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). --- internal/bind/render.go | 38 +++++++++++++++---- internal/bind/render_test.go | 18 +++++++++ internal/controller/bindcluster_controller.go | 9 +++-- internal/controller/bindzone_controller.go | 32 ++++++++++++++-- internal/controller/helpers.go | 18 +++++++++ 5 files changed, 100 insertions(+), 15 deletions(-) diff --git a/internal/bind/render.go b/internal/bind/render.go index 3d663bb..34f5009 100644 --- a/internal/bind/render.go +++ b/internal/bind/render.go @@ -230,15 +230,40 @@ func responsePolicyClause(policies []bindv1alpha1.BindPolicy, indent string) str return b.String() } +// transferPrimaries returns the primaries list secondaries use to AXFR the +// catalog (and, by inheritance, its member zones), each annotated with the +// catalog transfer TSIG key. The primary requires key-authenticated transfers +// (allow-transfer { key ... }), so an unkeyed primaries list is REFUSED. +func transferPrimaries(in RenderInput) []string { + primaries := in.Catalog.Spec.DefaultPrimaries + if len(primaries) == 0 && in.PrimaryAddress != "" { + primaries = []string{in.PrimaryAddress} + } + key := in.Catalog.Spec.TransferKeyRef + if key == "" { + return primaries + } + out := make([]string, 0, len(primaries)) + for _, p := range primaries { + p = strings.TrimSpace(strings.TrimRight(p, ";")) + if p == "" { + continue + } + if strings.Contains(p, " key ") { + out = append(out, p) + } else { + out = append(out, fmt.Sprintf("%s key \"%s\"", p, key)) + } + } + return out +} + func catalogZonesClause(in RenderInput, isPrimary bool, indent string) string { // Only secondaries consume the catalog to auto-provision member zones. if in.Catalog == nil || isPrimary { return "" } - primaries := in.Catalog.Spec.DefaultPrimaries - if len(primaries) == 0 && in.PrimaryAddress != "" { - primaries = []string{in.PrimaryAddress} - } + primaries := transferPrimaries(in) if len(primaries) == 0 { return "" } @@ -259,10 +284,7 @@ func renderCatalogZoneDecl(in RenderInput, isPrimary bool, indent string) string } cat := in.Catalog file := CatalogFilePath(cat.Spec.ZoneName) - primaries := cat.Spec.DefaultPrimaries - if len(primaries) == 0 && in.PrimaryAddress != "" { - primaries = []string{in.PrimaryAddress} - } + primaries := transferPrimaries(in) if len(primaries) == 0 { // Primary IP not known yet; omit the secondary catalog zone rather than // emit an invalid empty primaries list. A Pod-triggered reconcile renders diff --git a/internal/bind/render_test.go b/internal/bind/render_test.go index 8fb166f..3607de6 100644 --- a/internal/bind/render_test.go +++ b/internal/bind/render_test.go @@ -80,6 +80,24 @@ func TestRenderCatalogUsesPrimaryIP(t *testing.T) { } } +func TestRenderCatalogPrimariesCarryTransferKey(t *testing.T) { + // When the catalog declares a transfer key, secondaries must present it in + // both the catalog-zones default-primaries and the secondary catalog zone, + // or the key-authenticated primary REFUSES the AXFR. + in := RenderInput{ + Cluster: newCluster(bindv1alpha1.ModeAuthoritative), + Catalog: &bindv1alpha1.BindCatalogZone{Spec: bindv1alpha1.BindCatalogZoneSpec{ZoneName: "catalog.internal", TransferKeyRef: "transfer-key"}}, + PrimaryAddress: "10.43.0.5", + } + _, secondary := RenderNamedConf(in) + if !strings.Contains(secondary, `default-primaries { 10.43.0.5 key "transfer-key"; }`) { + t.Fatalf("catalog-zones default-primaries must carry the transfer key:\n%s", secondary) + } + if !strings.Contains(secondary, `primaries { 10.43.0.5 key "transfer-key"; }`) { + t.Fatalf("secondary catalog zone primaries must carry the transfer key:\n%s", secondary) + } +} + func TestRenderForwardZoneInView(t *testing.T) { rec := true in := RenderInput{ diff --git a/internal/controller/bindcluster_controller.go b/internal/controller/bindcluster_controller.go index 9889d43..061c39e 100644 --- a/internal/controller/bindcluster_controller.go +++ b/internal/controller/bindcluster_controller.go @@ -168,10 +168,11 @@ func (r *BindClusterReconciler) reconcileKeysSecret(ctx context.Context, c *bind } func (r *BindClusterReconciler) reconcileConfigMap(ctx context.Context, c *bindv1alpha1.BindCluster) error { - // BIND primaries/default-primaries need the primary's IP address, not a DNS - // name, so render with pod-0's current IP (empty until it is scheduled; the - // Pod watch re-renders when it appears or changes). - in := bind.RenderInput{Cluster: c, PrimaryAddress: primaryPodIP(ctx, r.Client, c)} + // BIND primaries/default-primaries need an IP address, not a DNS name. Use + // the stable primary Service ClusterIP so secondaries keep transferring + // across primary pod restarts (falls back to the pod IP when no primary + // Service exists; the Pod/Service watches re-render when it changes). + in := bind.RenderInput{Cluster: c, PrimaryAddress: primaryTransferAddress(ctx, r.Client, c)} var acls bindv1alpha1.BindACLList if err := r.List(ctx, &acls, client.InNamespace(c.Namespace)); err == nil { diff --git a/internal/controller/bindzone_controller.go b/internal/controller/bindzone_controller.go index 311195b..69bb6f2 100644 --- a/internal/controller/bindzone_controller.go +++ b/internal/controller/bindzone_controller.go @@ -80,7 +80,7 @@ func (r *BindZoneReconciler) Reconcile(ctx context.Context, req ctrl.Request) (c return r.setPhase(ctx, &zone, "Pending", "PrimaryNotReady", "waiting for cluster primary to be ready") } - zoneConfig, err := r.buildZoneConfig(ctx, &zone) + zoneConfig, err := r.buildZoneConfig(ctx, &zone, r.zoneTransferKeyRef(ctx, &zone, cluster)) if err != nil { return r.setPhase(ctx, &zone, "Error", "ConfigError", err.Error()) } @@ -133,7 +133,9 @@ func (r *BindZoneReconciler) Reconcile(ctx context.Context, req ctrl.Request) (c } // buildZoneConfig renders the inner clause passed to rndc addzone/modzone. -func (r *BindZoneReconciler) buildZoneConfig(ctx context.Context, zone *bindv1alpha1.BindZone) (string, error) { +// 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 @@ -145,8 +147,12 @@ func (r *BindZoneReconciler) buildZoneConfig(ctx context.Context, zone *bindv1al if zone.Spec.DynamicUpdate && zone.Spec.UpdateKeyRef != "" { parts = append(parts, fmt.Sprintf("allow-update { key \"%s\"; }", updateKeyName(ctx, r.Client, zone))) } - if len(zone.Spec.AllowTransfer) > 0 { + 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") @@ -202,6 +208,26 @@ func (r *BindZoneReconciler) deregisterCatalog(ctx context.Context, zone *bindv1 _ = 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 { diff --git a/internal/controller/helpers.go b/internal/controller/helpers.go index 83318fe..469a1e3 100644 --- a/internal/controller/helpers.go +++ b/internal/controller/helpers.go @@ -108,6 +108,24 @@ func primaryPodIP(ctx context.Context, c client.Client, cluster *bindv1alpha1.Bi return pod.Status.PodIP } +// primaryTransferAddress returns the address secondaries use to reach the +// primary for catalog and zone AXFR. It prefers the primary Service ClusterIP, +// which is stable across primary pod restarts (the pod IP is not: it changes on +// every restart, leaving secondaries pointed at a dead address). It falls back +// to the primary pod IP when no primary Service is configured or its ClusterIP +// is not yet assigned. +func primaryTransferAddress(ctx context.Context, c client.Client, cluster *bindv1alpha1.BindCluster) string { + if cluster.Spec.PrimaryService != nil { + var svc corev1.Service + if err := c.Get(ctx, client.ObjectKey{Namespace: cluster.Namespace, Name: primaryServiceName(cluster.Name)}, &svc); err == nil { + if ip := svc.Spec.ClusterIP; ip != "" && ip != corev1.ClusterIPNone { + return ip + } + } + } + return primaryPodIP(ctx, c, cluster) +} + // resolveTSIG reads the material of a BindTSIGKey into TSIG credentials. func resolveTSIG(ctx context.Context, c client.Client, namespace, keyRef string) (bind.TSIGCreds, error) { var creds bind.TSIGCreds -- 2.47.3