diff --git a/internal/bind/render.go b/internal/bind/render.go index 4fe19a2..620f91a 100644 --- a/internal/bind/render.go +++ b/internal/bind/render.go @@ -22,6 +22,15 @@ type RenderInput struct { Forwards []bindv1alpha1.BindZone // PrimaryAddress is the in-cluster address secondaries transfer from. PrimaryAddress string + // PrimaryPodAddresses are the primary pod's own IP(s). Secondaries transfer + // from PrimaryAddress (the stable primary Service ClusterIP) but the primary + // pod's NOTIFYs egress with its *pod* IP as the source — k8s Services only + // NAT the inbound direction — so BIND, whose implicit allow-notify is the + // zone's primaries list (the ClusterIP), REFUSES them as "non-primary" and + // replication falls back to the SOA refresh timer. Secondaries render these + // into an options-scope allow-notify so intra-cluster NOTIFYs are accepted + // immediately. Empty leaves BIND's default behaviour unchanged. + PrimaryPodAddresses []string } // RenderNamedConf returns the primary and secondary named.conf contents for a @@ -86,6 +95,12 @@ func render(in RenderInput, isPrimary bool) string { b.WriteString(" allow-new-zones yes;\n") } b.WriteString(" dnssec-validation auto;\n") + // Secondaries accept NOTIFY from the primary's pod IP(s). Catalog member and + // plain secondary zones take their implicit allow-notify from their primaries + // (the primary Service ClusterIP), but the primary's NOTIFYs are sourced from + // its pod IP, so an options-scope allow-notify covering the pod IP(s) is + // needed or every NOTIFY is refused and replication waits for the SOA refresh. + b.WriteString(allowNotifyClause(in, isPrimary, " ")) for _, o := range c.Spec.ExtraOptions { b.WriteString(" " + strings.TrimRight(o, ";") + ";\n") } @@ -277,6 +292,39 @@ func transferPrimaries(in RenderInput) []string { return out } +// allowNotifyClause renders an options-scope allow-notify on secondaries that +// permits the primary pod IP(s). Zones (catalog members and plain secondaries) +// point their primaries at the primary Service ClusterIP for stable AXFR, which +// also becomes their implicit allow-notify — but NOTIFYs leave the primary pod +// with its pod IP as source, so without this they are refused as "non-primary". +// Emitted only on secondaries and only when the primary pod IP(s) are known. +func allowNotifyClause(in RenderInput, isPrimary bool, indent string) string { + if isPrimary { + return "" + } + addrs := make([]string, 0, len(in.PrimaryPodAddresses)+1) + seen := map[string]bool{} + for _, a := range in.PrimaryPodAddresses { + a = strings.TrimSpace(strings.TrimRight(a, ";")) + if a == "" || seen[a] { + continue + } + seen[a] = true + addrs = append(addrs, a) + } + // Keep the transfer address (the Service ClusterIP, or the pod IP when no + // primary Service exists) in the set: an explicit allow-notify replaces the + // implicit primaries-derived default, so it must still cover that source. + if a := strings.TrimSpace(strings.TrimRight(in.PrimaryAddress, ";")); a != "" && !seen[a] { + addrs = append(addrs, a) + } + if len(addrs) == 0 { + return "" + } + sort.Strings(addrs) + return fmt.Sprintf("%sallow-notify { %s };\n", indent, terminate(addrs)) +} + func catalogZonesClause(in RenderInput, isPrimary bool, indent string) string { // Only secondaries consume the catalog to auto-provision member zones. if in.Catalog == nil || isPrimary { diff --git a/internal/bind/render_test.go b/internal/bind/render_test.go index 520653f..2418ad8 100644 --- a/internal/bind/render_test.go +++ b/internal/bind/render_test.go @@ -98,6 +98,34 @@ func TestRenderCatalogPrimariesCarryTransferKey(t *testing.T) { } } +func TestRenderSecondaryAllowNotifyPrimaryPodIP(t *testing.T) { + // Secondaries transfer from the primary Service ClusterIP but the primary's + // NOTIFYs arrive from its pod IP, so an options allow-notify must cover the + // pod IP (and keep the transfer address) or BIND refuses them as non-primary. + in := RenderInput{ + Cluster: newCluster(bindv1alpha1.ModeAuthoritative), + PrimaryAddress: "10.43.5.5", + PrimaryPodAddresses: []string{"10.42.3.197"}, + } + primary, secondary := RenderNamedConf(in) + if !strings.Contains(secondary, "allow-notify { 10.42.3.197; 10.43.5.5; };") { + t.Fatalf("secondary allow-notify must cover the primary pod IP and transfer address:\n%s", secondary) + } + if strings.Contains(primary, "allow-notify") { + t.Fatalf("primary must not render allow-notify (it is the notifier, not a secondary):\n%s", primary) + } +} + +func TestRenderSecondaryAllowNotifyOmittedWhenPodIPUnknown(t *testing.T) { + // With no primary pod IP known there is nothing to add beyond BIND's implicit + // primaries-derived default; emit nothing rather than a bare/duplicate clause. + in := RenderInput{Cluster: newCluster(bindv1alpha1.ModeAuthoritative)} + _, secondary := RenderNamedConf(in) + if strings.Contains(secondary, "allow-notify") { + t.Fatalf("no allow-notify should be emitted when no primary addresses are known:\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 e885f4a..9918774 100644 --- a/internal/controller/bindcluster_controller.go +++ b/internal/controller/bindcluster_controller.go @@ -176,6 +176,14 @@ func (r *BindClusterReconciler) reconcileConfigMap(ctx context.Context, c *bindv // 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)} + // Secondaries transfer from the stable primary Service ClusterIP, but the + // primary pod's NOTIFYs are sourced from its pod IP, which BIND refuses as + // "non-primary" unless it appears in allow-notify. Render the primary pod IP + // so intra-cluster NOTIFYs are accepted immediately (the Pod watch re-renders + // the ConfigMap when the pod IP changes across restarts). + if ip := primaryPodIP(ctx, r.Client, c); ip != "" { + in.PrimaryPodAddresses = []string{ip} + } var acls bindv1alpha1.BindACLList if err := r.List(ctx, &acls, client.InNamespace(c.Namespace)); err == nil {