Files
bind-operator/internal/controller/zone_helpers.go
T
unkinben aab11457af
ci/woodpecker/pr/pre-commit Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/build Pipeline was successful
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.
2026-07-25 23:31:20 +10:00

117 lines
3.2 KiB
Go

package controller
import (
"context"
"fmt"
"strings"
"sigs.k8s.io/controller-runtime/pkg/client"
bindv1alpha1 "git.unkin.net/unkin/bind-operator/api/v1alpha1"
"git.unkin.net/unkin/bind-operator/internal/bind"
)
func isPrimaryType(t bindv1alpha1.ZoneType) bool {
return t == bindv1alpha1.ZonePrimary || t == ""
}
// catalogEnabled reports whether a primary zone should be registered in the
// cluster catalog zone.
func catalogEnabled(zone *bindv1alpha1.BindZone) bool {
if !isPrimaryType(zone.Spec.Type) {
return false
}
if zone.Spec.Catalog == nil {
return true
}
return *zone.Spec.Catalog
}
// fqdn resolves a record owner name relative to a zone origin.
func fqdn(name, zone string) string {
zone = strings.TrimSuffix(zone, ".") + "."
if name == "" || name == "@" {
return zone
}
if strings.HasSuffix(name, ".") {
return name
}
return name + "." + zone
}
func recordsToUpdates(zone string, records []bindv1alpha1.Record, defaultTTL int32) []bind.RecordUpdate {
updates := make([]bind.RecordUpdate, 0, len(records))
for _, rec := range records {
ttl := defaultTTL
if rec.TTL != nil {
ttl = *rec.TTL
}
updates = append(updates, bind.RecordUpdate{
FQDN: fqdn(rec.Name, zone),
Type: rec.Type,
TTL: ttl,
Values: rec.Values,
})
}
return updates
}
// updateKeyName returns the TSIG key name (as used in named.conf) for a zone's
// update key, falling back to the object name.
func updateKeyName(ctx context.Context, c client.Client, zone *bindv1alpha1.BindZone) string {
return tsigKeyName(ctx, c, zone.Namespace, zone.Spec.UpdateKeyRef)
}
// tsigKeyName resolves a BindTSIGKey object reference to the TSIG key name used
// in named.conf (the KeyName override when set, otherwise the object name).
// Returns "" for an empty ref, and falls back to the ref if the object cannot be
// read.
func tsigKeyName(ctx context.Context, c client.Client, namespace, ref string) string {
if ref == "" {
return ""
}
var key bindv1alpha1.BindTSIGKey
if err := c.Get(ctx, client.ObjectKey{Namespace: namespace, Name: ref}, &key); err != nil {
return ref
}
if key.Spec.KeyName != "" {
return key.Spec.KeyName
}
return ref
}
// matchListInline renders address-match-list entries on one line.
func matchListInline(entries []string) string { return terminateInline(entries) }
func terminateInline(entries []string) string {
var parts []string
for _, e := range entries {
e = strings.TrimSpace(strings.TrimRight(e, ";"))
if e == "" {
continue
}
parts = append(parts, e+";")
}
return strings.Join(parts, " ")
}
// alsoNotifyList renders also-notify entries, each optionally annotated with a
// TSIG key so the primary signs its NOTIFYs and secondaries can accept them by
// key (`allow-notify { key ... }`) rather than by pod IP. An entry that already
// carries a `key` clause is left untouched.
func alsoNotifyList(addrs []string, key string) string {
key = strings.TrimSpace(key)
var parts []string
for _, a := range addrs {
a = strings.TrimSpace(strings.TrimRight(a, ";"))
if a == "" {
continue
}
if key != "" && !strings.Contains(a, " key ") {
a = fmt.Sprintf("%s key \"%s\"", a, key)
}
parts = append(parts, a+";")
}
return strings.Join(parts, " ")
}