12 Commits

Author SHA1 Message Date
benvin 671c43b05b Merge pull request 'Accept intra-cluster NOTIFY on secondaries via allow-notify' (#14) from benvin/allow-notify-intra-cluster into main
ci/woodpecker/tag/docker Pipeline was successful
Reviewed-on: #14
2026-07-25 22:54:17 +10:00
unkinben 7771711682 Accept intra-cluster NOTIFY on secondaries via allow-notify
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful
ci/woodpecker/pr/build Pipeline was successful
Secondaries transfer catalog member and plain secondary zones from the
primary Service ClusterIP (stable across primary pod restarts), and BIND
derives a zone's implicit allow-notify from its primaries list. But the
primary pod's NOTIFYs egress with its *pod* IP as source — k8s Services
NAT only the inbound direction — so BIND refuses them as "refused notify
from non-primary" and replication falls back to the SOA refresh timer, a
1-hour propagation delay on every dynamic zone (external-dns RFC2136 and
dns-updater nsupdates alike).

Render an options-scope allow-notify on secondaries covering the primary
pod IP (and the transfer address, since an explicit allow-notify replaces
the primaries-derived default). The cluster controller resolves the
primary pod IP the same way it already does for seeding/also-notify, and
the existing Pod watch re-renders the ConfigMap when the pod IP changes.
2026-07-25 22:48:35 +10:00
benvin 439aa9ea6b Merge pull request 'Notify secondaries immediately on primary zone changes' (#13) from benvin/notify-secondaries into main
ci/woodpecker/tag/docker Pipeline was successful
Reviewed-on: #13
2026-07-21 00:23:13 +10:00
unkinben 6a07f91ea1 Notify secondaries immediately on primary zone changes
ci/woodpecker/pr/pre-commit Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/build Pipeline was successful
Dynamically-updated primary zones were only reaching the secondary pods on
the hardcoded 1h SOA refresh: the operator emitted no NOTIFY, and a zone's
only apex NS is the primary itself, so default 'notify yes' reached no one.
Queries load-balanced across the serve VIP hit stale secondaries and
returned NXDOMAIN (negatively cached downstream for the 300s SOA minimum),
so records flapped for up to an hour after every update.

Add 'notify explicit' + 'also-notify' with the secondary pod IPs to primary
zone stanzas so an update NOTIFYs the secondaries for an immediate IXFR.
Applied via modzone, so existing zones pick it up on the next reconcile.
Also shorten the seed SOA refresh/retry/minimum as a fallback for missed
NOTIFYs and to shrink stale-NXDOMAIN negative caching.
2026-07-21 00:15:43 +10:00
benvin e4ed9cfdb2 Merge pull request 'BindTSIGKey: add secretTemplate for Secret labels/annotations' (#12) from benvin/tsigkey-secret-annotations into main
Reviewed-on: #12
2026-07-20 23:47:21 +10:00
unkinben 9c81320df8 BindTSIGKey: add secretTemplate for labels/annotations on the managed Secret
The operator-generated TSIG Secret previously carried only the managed-by
label, so it could not be mirrored to another namespace by emberstack
reflector (which requires reflection-allowed annotations on the source).

Add spec.secretTemplate.{annotations,labels}, applied both when the Secret
is first generated and reconciled onto the existing Secret when the CR
changes (imported secrets are left untouched so we don't fight their
external manager). This lets the external-dns TSIG key be managed in
bind-internal and reflected into the externaldns namespace.
2026-07-20 23:45:41 +10:00
benvin 243e776b59 Merge pull request 'Sort render inputs so config is deterministic (stop restart loop)' (#11) from benvin/deterministic-render into main
ci/woodpecker/tag/docker Pipeline was successful
Reviewed-on: #11
2026-07-12 23:09:34 +10:00
unkinben 59612f157a Sort render inputs so config is deterministic (stop restart loop)
ci/woodpecker/pr/pre-commit Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/build Pipeline was successful
client.List returns cache-ordered (non-deterministic) results, so the
forward zones (and DNSSEC policies) reshuffled between reconciles. Before
the config-hash change this was harmless, but now a reshuffled render
rewrites the ConfigMap, flips the pod-template config hash, and the
StatefulSet rolls forever (observed: resolver forward zones churn every
reconcile, pod endlessly recreated).

Sort every list rendered into named.conf (ACLs, views, forward zones,
policies, DNSSEC policies) before rendering, so identical inputs always
produce byte-identical config and the hash is stable.
2026-07-12 23:03:00 +10:00
benvin 8fac152537 Merge pull request 'Roll pods on config change via a pod-template config hash' (#10) from benvin/config-hash-rollout into main
ci/woodpecker/tag/docker Pipeline was successful
Reviewed-on: #10
2026-07-12 22:46:51 +10:00
unkinben 2deea3e023 Roll pods on config change via a pod-template config hash
ci/woodpecker/pr/pre-commit Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/build Pipeline was successful
Pods copy config from the projected volume into an emptyDir once at
startup, so a ConfigMap or keys.conf change never reaches a running pod:
rndc reconfig re-reads the stale startup copy, and a manual
`kubectl rollout restart` is reverted because the operator overwrites the
pod template every reconcile. The only thing that applies new config is a
restart, and nothing triggered one.

Stamp a hash of the projected config (rendered ConfigMap + keys.conf
Secret) onto the pod template as bind.unkin.net/config-hash. When config
changes the hash flips, the template changes, and the StatefulSet does a
normal rolling restart so every pod re-copies fresh config. The operator
owns the template, so the restart is operator-driven and not reverted; a
stable hash means no spurious restarts.

Covers named.conf changes (ACLs, views, forwarders, validate-except,
primary address) and TSIG key rotation.
2026-07-12 22:40:02 +10:00
benvin ef8c41cb0f Merge pull request 'Fix authoritative secondary replication (TSIG transfer + stable primary)' (#9) from benvin/fix-secondary-replication into main
ci/woodpecker/tag/docker Pipeline was successful
Reviewed-on: #9
2026-07-12 21:33:39 +10:00
unkinben ea330bd767 Fix authoritative secondary replication (TSIG transfer + stable primary)
ci/woodpecker/pr/pre-commit Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/build Pipeline was successful
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 "<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).
2026-07-12 19:42:38 +10:00
17 changed files with 669 additions and 24 deletions
+1 -1
View File
@@ -42,7 +42,7 @@ script picks one based on the pod ordinal.
| `BindZone` | A forward/reverse zone (`primary`/`secondary`/`forward`/`stub`), records inline, optional dynamic-update + DNSSEC + catalog membership. |
| `DNSRecord` | A single record set applied via TSIG `nsupdate` — external-dns as a CRD. |
| `BindView` | A split-horizon view (`match-clients`, ordering, per-view recursion). |
| `BindTSIGKey` | A TSIG key; the operator generates material into a Secret (never stored in the CR). |
| `BindTSIGKey` | A TSIG key; the operator generates material into a Secret (never stored in the CR). `spec.secretTemplate` stamps extra labels/annotations onto that Secret (e.g. reflection hints to mirror it into another namespace). |
| `BindACL` | A reusable named `address_match_list`. |
| `BindCatalogZone` | A BIND catalog zone so secondaries auto-provision member zones. |
| `BindPolicy` | A Response Policy Zone (RPZ) / DNS firewall. |
+18
View File
@@ -41,6 +41,24 @@ type BindTSIGKeySpec struct {
// `secret` key and the operator will not generate new material.
// +optional
ImportExisting bool `json:"importExisting,omitempty"`
// SecretTemplate customizes metadata written onto the managed key Secret.
// Useful, for example, to let secret-reflection tooling mirror the key into
// another namespace. Operator-managed labels are always preserved.
// +optional
SecretTemplate *SecretMetadata `json:"secretTemplate,omitempty"`
}
// SecretMetadata carries extra labels and annotations to stamp onto a
// Secret managed by the operator.
type SecretMetadata struct {
// Annotations to set on the Secret.
// +optional
Annotations map[string]string `json:"annotations,omitempty"`
// Labels to set on the Secret.
// +optional
Labels map[string]string `json:"labels,omitempty"`
}
// BindTSIGKeyStatus reports observed TSIG key state.
+35 -1
View File
@@ -691,7 +691,7 @@ func (in *BindTSIGKey) DeepCopyInto(out *BindTSIGKey) {
*out = *in
out.TypeMeta = in.TypeMeta
in.ObjectMeta.DeepCopyInto(&out.ObjectMeta)
out.Spec = in.Spec
in.Spec.DeepCopyInto(&out.Spec)
in.Status.DeepCopyInto(&out.Status)
}
@@ -748,6 +748,11 @@ func (in *BindTSIGKeyList) DeepCopyObject() runtime.Object {
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *BindTSIGKeySpec) DeepCopyInto(out *BindTSIGKeySpec) {
*out = *in
if in.SecretTemplate != nil {
in, out := &in.SecretTemplate, &out.SecretTemplate
*out = new(SecretMetadata)
(*in).DeepCopyInto(*out)
}
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new BindTSIGKeySpec.
@@ -1208,3 +1213,32 @@ func (in *Record) DeepCopy() *Record {
in.DeepCopyInto(out)
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *SecretMetadata) DeepCopyInto(out *SecretMetadata) {
*out = *in
if in.Annotations != nil {
in, out := &in.Annotations, &out.Annotations
*out = make(map[string]string, len(*in))
for key, val := range *in {
(*out)[key] = val
}
}
if in.Labels != nil {
in, out := &in.Labels, &out.Labels
*out = make(map[string]string, len(*in))
for key, val := range *in {
(*out)[key] = val
}
}
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new SecretMetadata.
func (in *SecretMetadata) DeepCopy() *SecretMetadata {
if in == nil {
return nil
}
out := new(SecretMetadata)
in.DeepCopyInto(out)
return out
}
@@ -87,6 +87,23 @@ spec:
SecretName is the Secret the key material is written to (or read from when
ImportExisting is set). Defaults to "<name>-tsig".
type: string
secretTemplate:
description: |-
SecretTemplate customizes metadata written onto the managed key Secret.
Useful, for example, to let secret-reflection tooling mirror the key into
another namespace. Operator-managed labels are always preserved.
properties:
annotations:
additionalProperties:
type: string
description: Annotations to set on the Secret.
type: object
labels:
additionalProperties:
type: string
description: Labels to set on the Secret.
type: object
type: object
type: object
status:
description: BindTSIGKeyStatus reports observed TSIG key state.
+17
View File
@@ -2376,6 +2376,23 @@ spec:
SecretName is the Secret the key material is written to (or read from when
ImportExisting is set). Defaults to "<name>-tsig".
type: string
secretTemplate:
description: |-
SecretTemplate customizes metadata written onto the managed key Secret.
Useful, for example, to let secret-reflection tooling mirror the key into
another namespace. Operator-managed labels are always preserved.
properties:
annotations:
additionalProperties:
type: string
description: Annotations to set on the Secret.
type: object
labels:
additionalProperties:
type: string
description: Labels to set on the Secret.
type: object
type: object
type: object
status:
description: BindTSIGKeyStatus reports observed TSIG key state.
+9 -1
View File
@@ -11,7 +11,9 @@ spec:
algorithm: hmac-sha256
---
# TSIG key permitting external-dns (and DNSRecord objects) to send RFC2136
# dynamic updates to the dynamic cluster's primary.
# dynamic updates to the dynamic cluster's primary. secretTemplate mirrors the
# generated Secret into the external-dns namespace via emberstack reflector, so
# external-dns presents exactly the key the primary's allow-update accepts.
apiVersion: bind.unkin.net/v1alpha1
kind: BindTSIGKey
metadata:
@@ -19,3 +21,9 @@ metadata:
namespace: bind-externaldns
spec:
algorithm: hmac-sha256
secretTemplate:
annotations:
reflector.v1.k8s.emberstack.com/reflection-allowed: "true"
reflector.v1.k8s.emberstack.com/reflection-allowed-namespaces: "externaldns"
reflector.v1.k8s.emberstack.com/reflection-auto-enabled: "true"
reflector.v1.k8s.emberstack.com/reflection-auto-namespaces: "externaldns"
+1 -1
View File
@@ -3,6 +3,7 @@ module git.unkin.net/unkin/bind-operator
go 1.25
require (
github.com/go-logr/logr v1.4.2
k8s.io/api v0.34.4
k8s.io/apimachinery v0.34.4
k8s.io/client-go v0.34.4
@@ -17,7 +18,6 @@ require (
github.com/evanphx/json-patch/v5 v5.9.11 // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/fxamacker/cbor/v2 v2.9.0 // indirect
github.com/go-logr/logr v1.4.2 // indirect
github.com/go-logr/zapr v1.3.0 // indirect
github.com/go-openapi/jsonpointer v0.21.0 // indirect
github.com/go-openapi/jsonreference v0.20.2 // indirect
+97 -8
View File
@@ -22,15 +22,43 @@ 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
// cluster. Both variants are shipped in the ConfigMap; the entrypoint selects
// one based on the pod ordinal.
func RenderNamedConf(in RenderInput) (primary string, secondary string) {
// client.List returns cache-ordered (non-deterministic) results, so sort
// every input slice before rendering. Otherwise the rendered config
// reshuffles between reconciles, churning the ConfigMap — and with the
// pod-template config hash that means an endless rolling restart.
sortInput(&in)
return render(in, true), render(in, false)
}
// sortInput orders every list rendered into named.conf deterministically.
func sortInput(in *RenderInput) {
sort.Slice(in.ACLs, func(i, j int) bool { return in.ACLs[i].Name < in.ACLs[j].Name })
sort.Slice(in.Views, func(i, j int) bool {
if in.Views[i].Spec.Order != in.Views[j].Spec.Order {
return in.Views[i].Spec.Order < in.Views[j].Spec.Order
}
return in.Views[i].Name < in.Views[j].Name
})
sort.Slice(in.Forwards, func(i, j int) bool { return in.Forwards[i].Spec.ZoneName < in.Forwards[j].Spec.ZoneName })
sort.Slice(in.Policies, func(i, j int) bool { return in.Policies[i].Spec.ZoneName < in.Policies[j].Spec.ZoneName })
sort.Slice(in.DNSSECPolicies, func(i, j int) bool { return in.DNSSECPolicies[i].Name < in.DNSSECPolicies[j].Name })
}
func render(in RenderInput, isPrimary bool) string {
c := in.Cluster
var b strings.Builder
@@ -67,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")
}
@@ -230,15 +264,73 @@ 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
}
// 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 {
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 +351,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
+77
View File
@@ -80,6 +80,52 @@ 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 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{
@@ -128,3 +174,34 @@ func TestCatalogHashStable(t *testing.T) {
t.Fatalf("expected 40-char hex sha1, got %d: %s", len(h1), h1)
}
}
func TestRenderDeterministicWithShuffledForwards(t *testing.T) {
// client.List order is non-deterministic; the render must not depend on
// input order, or the ConfigMap churns and (with the config hash) the
// StatefulSet rolls forever.
mkFwd := func(zone, fwd string) bindv1alpha1.BindZone {
return bindv1alpha1.BindZone{
ObjectMeta: metav1.ObjectMeta{Name: zone},
Spec: bindv1alpha1.BindZoneSpec{
ClusterRef: "r", Type: bindv1alpha1.ZoneForward,
ZoneName: zone, Forwarders: []string{fwd},
},
}
}
base := RenderInput{Cluster: newCluster(bindv1alpha1.ModeResolver)}
orderA := base
orderA.Forwards = []bindv1alpha1.BindZone{
mkFwd("unkin.net", "198.18.200.6"), mkFwd("consul", "198.18.19.14"),
mkFwd("k8s.syd1.au.unkin.net", "198.18.200.8"), mkFwd("13.18.198.in-addr.arpa", "198.18.200.6"),
}
orderB := base
orderB.Forwards = []bindv1alpha1.BindZone{
mkFwd("13.18.198.in-addr.arpa", "198.18.200.6"), mkFwd("k8s.syd1.au.unkin.net", "198.18.200.8"),
mkFwd("consul", "198.18.19.14"), mkFwd("unkin.net", "198.18.200.6"),
}
pa, _ := RenderNamedConf(orderA)
pb, _ := RenderNamedConf(orderB)
if pa != pb {
t.Fatalf("render must be independent of forward-zone input order:\n--- A ---\n%s\n--- B ---\n%s", pa, pb)
}
}
+9 -3
View File
@@ -35,13 +35,19 @@ func (e *Executor) ZoneExists(ctx context.Context, namespace, pod, zone, view st
func (e *Executor) WriteSeedZone(ctx context.Context, namespace, pod, zone, path, primaryIP string, serial int64) error {
origin := dot(zone)
ns := "ns1." + origin
// Short refresh/retry so a secondary that misses a NOTIFY (e.g. its pod IP
// changed and the primary's also-notify was briefly stale) still converges
// in minutes, not the hour a 3600s refresh would impose. minimum is the
// negative-cache TTL: keep it low so a stale-secondary NXDOMAIN does not
// stick in downstream resolvers for long. NOTIFY (also-notify on the
// primary) remains the fast path; these are the fallback.
content := fmt.Sprintf(`$TTL 3600
@ IN SOA %s hostmaster.%s (
%d ; serial
3600 ; refresh
900 ; retry
300 ; refresh
60 ; retry
1209600 ; expire
300 ) ; minimum
60 ) ; minimum
@ IN NS %s
ns1 IN A %s
`, ns, origin, serial, ns, primaryIP)
+62 -5
View File
@@ -1,7 +1,10 @@
package controller
import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"fmt"
"sort"
"strings"
@@ -168,10 +171,19 @@ 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)}
// 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 {
@@ -350,6 +362,12 @@ func (r *BindClusterReconciler) reconcileStatefulSet(ctx context.Context, c *bin
}}},
}
// Pods copy config from the projected volume into an emptyDir once at
// startup; a ConfigMap/keys change never reaches a running pod (rndc
// reconfig re-reads the stale startup copy). Stamp a hash of the projected
// config onto the pod template so a config change rolls the StatefulSet,
// which is the only way the change takes effect. The operator owns the
// template, so this restart is operator-driven and not reverted.
sts := &appsv1.StatefulSet{
ObjectMeta: metav1.ObjectMeta{Name: c.Name, Namespace: c.Namespace, Labels: labels},
Spec: appsv1.StatefulSetSpec{
@@ -357,7 +375,7 @@ func (r *BindClusterReconciler) reconcileStatefulSet(ctx context.Context, c *bin
Replicas: &replicas,
Selector: &metav1.LabelSelector{MatchLabels: labels},
Template: corev1.PodTemplateSpec{
ObjectMeta: metav1.ObjectMeta{Labels: labels},
ObjectMeta: metav1.ObjectMeta{Labels: labels, Annotations: map[string]string{configHashAnnotation: r.configHash(ctx, c)}},
Spec: corev1.PodSpec{
NodeSelector: c.Spec.NodeSelector,
Tolerations: c.Spec.Tolerations,
@@ -425,6 +443,45 @@ func (r *BindClusterReconciler) reconcileStatefulSet(ctx context.Context, c *bin
return &existing, nil
}
// configHashAnnotation carries a hash of the projected config on the pod
// template; changing it triggers a rolling restart so pods pick up new config.
const configHashAnnotation = "bind.unkin.net/config-hash"
// configHash returns a deterministic hash of the config projected into the pods
// (the rendered ConfigMap and the keys.conf Secret). It is read after those are
// reconciled, so it reflects the current desired config. A stable hash means no
// spurious restarts; any config or TSIG-key change flips it and rolls the pods.
func (r *BindClusterReconciler) configHash(ctx context.Context, c *bindv1alpha1.BindCluster) string {
var buf bytes.Buffer
var cm corev1.ConfigMap
if err := r.Get(ctx, types.NamespacedName{Namespace: c.Namespace, Name: configMapName(c.Name)}, &cm); err == nil {
for _, k := range sortedKeys(cm.Data) {
fmt.Fprintf(&buf, "%s\x00%s\x00", k, cm.Data[k])
}
}
var keys corev1.Secret
if err := r.Get(ctx, types.NamespacedName{Namespace: c.Namespace, Name: keysSecretName(c.Name)}, &keys); err == nil {
data := make(map[string]string, len(keys.Data))
for k, v := range keys.Data {
data[k] = string(v)
}
for _, k := range sortedKeys(data) {
fmt.Fprintf(&buf, "%s\x00%s\x00", k, data[k])
}
}
sum := sha256.Sum256(buf.Bytes())
return hex.EncodeToString(sum[:])
}
func sortedKeys(m map[string]string) []string {
keys := make([]string, 0, len(m))
for k := range m {
keys = append(keys, k)
}
sort.Strings(keys)
return keys
}
func (r *BindClusterReconciler) reloadReadyPods(ctx context.Context, c *bindv1alpha1.BindCluster) {
if r.Exec == nil {
return
+53 -1
View File
@@ -60,7 +60,7 @@ func (r *BindTSIGKeyReconciler) Reconcile(ctx context.Context, req ctrl.Request)
return ctrl.Result{}, genErr
}
newSecret := &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{Name: secretName, Namespace: key.Namespace, Labels: map[string]string{managedByLabel: managedByValue}},
ObjectMeta: metav1.ObjectMeta{Name: secretName, Namespace: key.Namespace},
Data: map[string][]byte{
"algorithm": []byte(algorithm),
"keyName": []byte(keyName),
@@ -68,6 +68,7 @@ func (r *BindTSIGKeyReconciler) Reconcile(ctx context.Context, req ctrl.Request)
"key.conf": []byte(bind.KeyClause(keyName, algorithm, material)),
},
}
applySecretTemplate(&newSecret.ObjectMeta, key.Spec.SecretTemplate)
if err := ctrl.SetControllerReference(&key, newSecret, r.Scheme); err != nil {
return ctrl.Result{}, err
}
@@ -77,6 +78,21 @@ func (r *BindTSIGKeyReconciler) Reconcile(ctx context.Context, req ctrl.Request)
logger.Info("generated TSIG key", "key", key.Name, "secret", secretName)
case err != nil:
return ctrl.Result{}, err
default:
// Secret already exists: reconcile the template-managed metadata so that
// annotation/label changes on the CR (e.g. reflection hints) propagate
// without regenerating key material. Skip imported secrets, which are
// owned by an external manager (Vault/VSO, reflector) that we must not
// fight over metadata.
if key.Spec.ImportExisting {
break
}
if updated := applySecretTemplate(&secret.ObjectMeta, key.Spec.SecretTemplate); updated {
if err := r.Update(ctx, &secret); err != nil {
return ctrl.Result{}, err
}
logger.Info("updated TSIG key secret metadata", "key", key.Name, "secret", secretName)
}
}
key.Status.SecretName = secretName
@@ -90,6 +106,42 @@ func (r *BindTSIGKeyReconciler) Reconcile(ctx context.Context, req ctrl.Request)
return ctrl.Result{}, nil
}
// applySecretTemplate stamps the operator-managed label plus any
// user-supplied labels/annotations onto the Secret's metadata. It returns true
// if it mutated meta, so callers can decide whether an update is needed.
func applySecretTemplate(meta *metav1.ObjectMeta, tmpl *bindv1alpha1.SecretMetadata) bool {
changed := false
setLabel := func(k, v string) {
if meta.Labels == nil {
meta.Labels = map[string]string{}
}
if meta.Labels[k] != v {
meta.Labels[k] = v
changed = true
}
}
setAnnotation := func(k, v string) {
if meta.Annotations == nil {
meta.Annotations = map[string]string{}
}
if meta.Annotations[k] != v {
meta.Annotations[k] = v
changed = true
}
}
setLabel(managedByLabel, managedByValue)
if tmpl != nil {
for k, v := range tmpl.Labels {
setLabel(k, v)
}
for k, v := range tmpl.Annotations {
setAnnotation(k, v)
}
}
return changed
}
func (r *BindTSIGKeyReconciler) fail(ctx context.Context, key *bindv1alpha1.BindTSIGKey, reason, msg string) (ctrl.Result, error) {
key.Status.Ready = false
key.Status.ObservedGeneration = key.Generation
+71
View File
@@ -0,0 +1,71 @@
package controller
import (
"testing"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
bindv1alpha1 "git.unkin.net/unkin/bind-operator/api/v1alpha1"
)
func TestApplySecretTemplate(t *testing.T) {
t.Run("nil template still stamps managed-by label", func(t *testing.T) {
var meta metav1.ObjectMeta
if !applySecretTemplate(&meta, nil) {
t.Fatal("expected change on empty meta")
}
if meta.Labels[managedByLabel] != managedByValue {
t.Errorf("managed-by label = %q, want %q", meta.Labels[managedByLabel], managedByValue)
}
if meta.Annotations != nil {
t.Errorf("annotations = %v, want nil", meta.Annotations)
}
})
t.Run("applies labels and annotations", func(t *testing.T) {
meta := metav1.ObjectMeta{Labels: map[string]string{managedByLabel: managedByValue}}
tmpl := &bindv1alpha1.SecretMetadata{
Annotations: map[string]string{"reflector.v1.k8s.emberstack.com/reflection-allowed": "true"},
Labels: map[string]string{"team": "dns"},
}
if !applySecretTemplate(&meta, tmpl) {
t.Fatal("expected change when adding template metadata")
}
if got := meta.Annotations["reflector.v1.k8s.emberstack.com/reflection-allowed"]; got != "true" {
t.Errorf("reflection annotation = %q, want true", got)
}
if meta.Labels["team"] != "dns" {
t.Errorf("team label = %q, want dns", meta.Labels["team"])
}
// managed-by must survive user-supplied labels.
if meta.Labels[managedByLabel] != managedByValue {
t.Errorf("managed-by label dropped: %v", meta.Labels)
}
})
t.Run("idempotent when already applied", func(t *testing.T) {
tmpl := &bindv1alpha1.SecretMetadata{
Annotations: map[string]string{"a": "1"},
Labels: map[string]string{"b": "2"},
}
meta := metav1.ObjectMeta{}
applySecretTemplate(&meta, tmpl)
if applySecretTemplate(&meta, tmpl) {
t.Error("expected no change on second apply")
}
})
t.Run("updates drifted annotation value", func(t *testing.T) {
meta := metav1.ObjectMeta{
Labels: map[string]string{managedByLabel: managedByValue},
Annotations: map[string]string{"a": "old"},
}
tmpl := &bindv1alpha1.SecretMetadata{Annotations: map[string]string{"a": "new"}}
if !applySecretTemplate(&meta, tmpl) {
t.Fatal("expected change when annotation value drifts")
}
if meta.Annotations["a"] != "new" {
t.Errorf("annotation a = %q, want new", meta.Annotations["a"])
}
})
}
+43 -3
View File
@@ -80,7 +80,15 @@ 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)
// Primary zones replicated to secondaries (catalog members) get an
// also-notify pointing at the secondary pods, so a dynamic update NOTIFYs
// them immediately rather than waiting for the SOA refresh.
var notifyTargets []string
if isPrimaryType(zone.Spec.Type) && catalogEnabled(&zone) {
notifyTargets = secondaryPodIPs(ctx, r.Client, cluster)
}
zoneConfig, err := r.buildZoneConfig(ctx, &zone, r.zoneTransferKeyRef(ctx, &zone, cluster), notifyTargets)
if err != nil {
return r.setPhase(ctx, &zone, "Error", "ConfigError", err.Error())
}
@@ -133,7 +141,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, notifyTargets []string) (string, error) {
zType := zone.Spec.Type
if zType == "" {
zType = bindv1alpha1.ZonePrimary
@@ -145,8 +155,18 @@ 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))
}
// NOTIFY only the secondaries we know about (their apex NS is the primary
// itself, so default `notify yes` would reach no one). `notify explicit`
// keeps NOTIFY off the query-serving VIP and scoped to the pod IPs.
if len(notifyTargets) > 0 {
parts = append(parts, "notify explicit", fmt.Sprintf("also-notify { %s }", terminateInline(notifyTargets)))
}
if zone.Spec.DNSSECPolicyRef != "" {
parts = append(parts, fmt.Sprintf("dnssec-policy \"%s\"", zone.Spec.DNSSECPolicyRef), "inline-signing yes")
@@ -202,6 +222,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 {
@@ -0,0 +1,48 @@
package controller
import (
"context"
"strings"
"testing"
bindv1alpha1 "git.unkin.net/unkin/bind-operator/api/v1alpha1"
)
// A primary zone with known secondaries renders notify explicit + also-notify
// so a dynamic update NOTIFYs the secondaries immediately.
func TestBuildZoneConfigPrimaryAlsoNotify(t *testing.T) {
r := &BindZoneReconciler{}
zone := &bindv1alpha1.BindZone{
Spec: bindv1alpha1.BindZoneSpec{
ZoneName: "main.unkin.net",
Type: bindv1alpha1.ZonePrimary,
},
}
cfg, err := r.buildZoneConfig(context.Background(), zone, "transfer-key", []string{"10.42.2.6", "10.42.1.5"})
if err != nil {
t.Fatalf("buildZoneConfig: %v", err)
}
if !strings.Contains(cfg, "notify explicit") {
t.Errorf("expected notify explicit in %q", cfg)
}
if !strings.Contains(cfg, "also-notify { 10.42.2.6; 10.42.1.5; }") {
t.Errorf("expected also-notify with the secondary IPs in %q", cfg)
}
}
// With no secondaries, no also-notify is emitted (single-replica cluster).
func TestBuildZoneConfigPrimaryNoNotifyTargets(t *testing.T) {
r := &BindZoneReconciler{}
zone := &bindv1alpha1.BindZone{
Spec: bindv1alpha1.BindZoneSpec{ZoneName: "main.unkin.net", Type: bindv1alpha1.ZonePrimary},
}
cfg, err := r.buildZoneConfig(context.Background(), zone, "transfer-key", nil)
if err != nil {
t.Fatalf("buildZoneConfig: %v", err)
}
if strings.Contains(cfg, "also-notify") || strings.Contains(cfg, "notify explicit") {
t.Errorf("did not expect notify clauses with no targets: %q", cfg)
}
}
+64
View File
@@ -0,0 +1,64 @@
package controller
import (
"context"
"testing"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
clientgoscheme "k8s.io/client-go/kubernetes/scheme"
"sigs.k8s.io/controller-runtime/pkg/client/fake"
bindv1alpha1 "git.unkin.net/unkin/bind-operator/api/v1alpha1"
)
func TestConfigHashStableAndSensitive(t *testing.T) {
scheme := runtime.NewScheme()
if err := clientgoscheme.AddToScheme(scheme); err != nil {
t.Fatal(err)
}
if err := bindv1alpha1.AddToScheme(scheme); err != nil {
t.Fatal(err)
}
cluster := &bindv1alpha1.BindCluster{ObjectMeta: metav1.ObjectMeta{Name: "c", Namespace: "ns"}}
cm := &corev1.ConfigMap{
ObjectMeta: metav1.ObjectMeta{Name: configMapName("c"), Namespace: "ns"},
Data: map[string]string{"named.conf.secondary": "options { recursion yes; };"},
}
keys := &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{Name: keysSecretName("c"), Namespace: "ns"},
Data: map[string][]byte{"keys.conf": []byte("key x {};")},
}
c := fake.NewClientBuilder().WithScheme(scheme).WithObjects(cm, keys).Build()
r := &BindClusterReconciler{Client: c, Scheme: scheme}
ctx := context.Background()
h1 := r.configHash(ctx, cluster)
if h1 == "" {
t.Fatal("hash should not be empty when config exists")
}
// Stable across calls when nothing changes.
if h2 := r.configHash(ctx, cluster); h2 != h1 {
t.Fatalf("hash not stable: %s != %s", h1, h2)
}
// A config change flips the hash (this is what rolls the StatefulSet).
cm.Data["named.conf.secondary"] = "options { recursion yes; validate-except { unkin.net; }; };"
if err := c.Update(ctx, cm); err != nil {
t.Fatal(err)
}
if h3 := r.configHash(ctx, cluster); h3 == h1 {
t.Fatal("hash must change when the ConfigMap changes")
}
// A TSIG key (keys.conf) change also flips it.
afterCM := r.configHash(ctx, cluster)
keys.Data["keys.conf"] = []byte("key x { algorithm hmac-sha256; };")
if err := c.Update(ctx, keys); err != nil {
t.Fatal(err)
}
if h4 := r.configHash(ctx, cluster); h4 == afterCM {
t.Fatal("hash must change when keys.conf changes")
}
}
+47
View File
@@ -3,6 +3,7 @@ package controller
import (
"context"
"fmt"
"sort"
"time"
corev1 "k8s.io/api/core/v1"
@@ -108,6 +109,52 @@ 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)
}
// secondaryPodIPs returns the pod IPs of a cluster's secondary pods (every pod
// except the ordinal-0 primary) that currently have an address. The primary
// uses this list as its zone `also-notify` set, so a change to a primary zone
// (in particular a dynamic update) triggers an immediate NOTIFY -> IXFR to the
// secondaries instead of leaving them stale until the next SOA refresh. The
// list is sorted so the rendered zone config is stable and does not churn
// modzone on every reconcile. Pod IPs change across restarts, so the caller
// relies on the zone controller's periodic requeue to refresh the set (a
// restarted secondary re-transfers the whole zone on load regardless). Returns
// nil for a single-replica cluster.
func secondaryPodIPs(ctx context.Context, c client.Client, cluster *bindv1alpha1.BindCluster) []string {
var pods corev1.PodList
if err := c.List(ctx, &pods, client.InNamespace(cluster.Namespace), client.MatchingLabels(commonLabels(cluster.Name))); err != nil {
return nil
}
primary := primaryPodName(cluster.Name)
var ips []string
for i := range pods.Items {
p := &pods.Items[i]
if p.Name == primary || p.Status.PodIP == "" {
continue
}
ips = append(ips, p.Status.PodIP)
}
sort.Strings(ips)
return ips
}
// 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