Make intra-cluster NOTIFY loop-free (TSIG-keyed, no pod IPs in restart config)

v0.2.5 (PR #14) added an options-scope allow-notify enumerating the primary
pod IP on secondaries. Options-scope config feeds the config-hash annotation
that rolls the StatefulSet, so any config change rolled the pods, the primary
came back on a new pod IP, the operator re-rendered with the new IP, the hash
changed, the pods rolled again — an infinite roll loop across every
BindCluster. The prod deployment was reverted to v0.2.4.

Replace the pod-IP allow-notify with TSIG-authenticated NOTIFY:

- Secondaries render `allow-notify { key "<name>"; };` — a static key element
  with NO IPs. It depends only on the key name, so pod-IP churn can never
  change the render, the config-hash, or trigger a restart.
- The primary signs its outgoing NOTIFYs: the zone-scope also-notify entries
  (already enumerating replica pod IPs, applied via rndc addzone/modzone with
  NO restart) now carry `key "<name>"`.
- Key choice: reuse the cluster's catalog transfer TSIG key (TransferKeyRef).
  Secondaries already present it for AXFR and it is in keys.conf on every pod,
  so no new key plumbing is needed.

Add a permanent regression guard for the loop class:
- controller: reconcile the ConfigMap with the primary pod on two different
  IPs and assert the config-hash is byte-identical.
- render: render restart-scoped input and assert no pod IP appears in
  allow-notify; RenderInput no longer has any pod-IP field.

Zone-scope also-notify (rndc, no restart) legitimately still lists pod IPs;
only restart-scoped config must be pod-IP-independent.
This commit is contained in:
2026-07-25 23:22:45 +10:00
parent 671c43b05b
commit 61324ae89a
7 changed files with 295 additions and 70 deletions
+10 -8
View File
@@ -176,14 +176,6 @@ 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 {
@@ -239,6 +231,16 @@ func (r *BindClusterReconciler) reconcileConfigMap(ctx context.Context, c *bindv
}
}
// Secondaries accept intra-cluster NOTIFYs signed with the cluster's catalog
// transfer TSIG key (the primary signs its also-notify NOTIFYs with it — see
// bindzone_controller). Render `allow-notify { key "<name>"; }` — a static key
// element, NO pod IPs — so it never changes on pod churn and cannot re-render
// the restart-scoped config (the v0.2.5 roll loop). Resolve the catalog's
// TransferKeyRef to the BIND key name (KeyName override, else the ref).
if in.Catalog != nil && in.Catalog.Spec.TransferKeyRef != "" {
in.NotifyKeyName = tsigKeyName(ctx, r.Client, c.Namespace, in.Catalog.Spec.TransferKeyRef)
}
primaryConf, secondaryConf := bind.RenderNamedConf(in)
data := map[string]string{
"named.conf.primary": primaryConf,