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