6 Commits

Author SHA1 Message Date
benvin 547d168c12 Merge pull request 'Render forward zones into named.conf on every pod' (#5) from benvin/forward-zones-config into main
ci/woodpecker/tag/docker Pipeline was successful
Reviewed-on: #5
2026-07-04 21:16:03 +10:00
unkinben 9bc4436c79 Render forward zones into named.conf on every pod
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
type:forward zones are pure configuration (conditional forwarding), not
replicated data, so a resolver needs them on all pods. They were being
rndc-addzone'd on the primary only, so queries hitting a secondary pod
missed the forwarding. Render them into named.conf instead.

- render forward zones inside their view (or top-level when no views)
- BindCluster lists type:forward zones and watches BindZone to re-render
- BindZone controller skips forward zones (config-managed, no addzone)
- unit test for forward-zone-in-view rendering
2026-07-04 11:55:03 +10:00
benvin f0e851c0bc Merge pull request 'Add clusterRef to BindTSIGKey' (#4) from benvin/tsigkey-clusterref into main
ci/woodpecker/tag/docker Pipeline was successful
Reviewed-on: #4
2026-07-04 00:03:42 +10:00
unkinben 55e80b467c Add clusterRef to BindTSIGKey
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
TSIG keys were included in every cluster's keys.conf namespace-wide. When
multiple clusters share a namespace, that leaks keys across clusters. Add
spec.clusterRef so a key can target a specific BindCluster; empty keeps
the shared (all-clusters-in-namespace) behaviour.

- api: BindTSIGKey.spec.clusterRef (optional)
- BindCluster keys.conf now includes only keys with matching or empty
  clusterRef
- regenerate CRDs + install.yaml bundle
2026-07-03 23:44:35 +10:00
benvin cd25c94efc Merge pull request 'Fix zone provisioning: seed glue + IP primaries' (#3) from benvin/fix-zone-seed into main
ci/woodpecker/tag/docker Pipeline was successful
Reviewed-on: #3
2026-07-03 21:39:45 +10:00
unkinben fb103a9e95 Fix zone provisioning: seed glue + IP primaries
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful
Two bugs made every provisioned zone fail to load:

1. The seed zone's apex NS (ns1.<zone>) is in-zone but had no address
   record, so BIND check-integrity refused to load it and rndc addzone
   reverted. Add a glue A record pointing at the primary pod IP.
2. Secondaries rendered primaries/default-primaries with the primary's
   DNS name, but BIND only accepts IP addresses there (it read the name
   as a remote-servers list and failed config load, crash-looping the
   secondary). Render the primary pod IP instead, and watch Pods so the
   config re-renders when that IP appears or changes.

- bind.WriteSeedZone writes 'ns1 IN A <primaryIP>' glue
- controllers resolve primaryPodIP and pass it to the seed (requeue if
  the primary has no IP yet)
- BindCluster renders PrimaryAddress from pod-0's IP and watches Pods
- render omits catalog primaries when the IP is unknown (no empty list)
2026-07-03 21:33:31 +10:00
11 changed files with 200 additions and 14 deletions
+6
View File
@@ -16,6 +16,12 @@ const (
// BindTSIGKeySpec defines a TSIG key. If no existing key material is imported,
// the operator generates a random key and stores it in a Secret.
type BindTSIGKeySpec struct {
// ClusterRef names the BindCluster this key is included in. When empty the
// key is shared with every cluster in the namespace (useful when multiple
// clusters share one namespace).
// +optional
ClusterRef string `json:"clusterRef,omitempty"`
// Algorithm is the HMAC algorithm. Defaults to hmac-sha256.
// +kubebuilder:default="hmac-sha256"
// +optional
@@ -66,6 +66,12 @@ spec:
- hmac-sha1
- hmac-md5
type: string
clusterRef:
description: |-
ClusterRef names the BindCluster this key is included in. When empty the
key is shared with every cluster in the namespace (useful when multiple
clusters share one namespace).
type: string
importExisting:
description: |-
ImportExisting, when true, means the referenced Secret already contains a
+6
View File
@@ -2043,6 +2043,12 @@ spec:
- hmac-sha1
- hmac-md5
type: string
clusterRef:
description: |-
ClusterRef names the BindCluster this key is included in. When empty the
key is shared with every cluster in the namespace (useful when multiple
clusters share one namespace).
type: string
importExisting:
description: |-
ImportExisting, when true, means the referenced Secret already contains a
+42
View File
@@ -16,6 +16,10 @@ type RenderInput struct {
Policies []bindv1alpha1.BindPolicy
DNSSECPolicies []bindv1alpha1.BindDNSSECPolicy
Catalog *bindv1alpha1.BindCatalogZone
// Forwards are type:forward BindZones. They are pure configuration (no
// replicated data), so they are rendered into named.conf on every pod
// rather than added dynamically to the primary.
Forwards []bindv1alpha1.BindZone
// PrimaryAddress is the in-cluster address secondaries transfer from.
PrimaryAddress string
}
@@ -90,6 +94,29 @@ func render(in RenderInput, isPrimary bool) string {
b.WriteString(renderCatalogZoneDecl(in, isPrimary, ""))
}
// Top-level forward zones (BIND only allows top-level zones when no views
// are defined; in-view forward zones are rendered inside renderView).
if len(in.Views) == 0 {
for _, z := range in.Forwards {
if z.Spec.ViewRef == "" {
b.WriteString(renderForwardZone(z, ""))
}
}
}
return b.String()
}
// renderForwardZone renders a type:forward zone clause.
func renderForwardZone(z bindv1alpha1.BindZone, indent string) string {
var b strings.Builder
b.WriteString(fmt.Sprintf("%szone \"%s\" {\n", indent, z.Spec.ZoneName))
b.WriteString(indent + " type forward;\n")
b.WriteString(indent + " forward only;\n")
if len(z.Spec.Forwarders) > 0 {
b.WriteString(fmt.Sprintf("%s forwarders { %s };\n", indent, terminate(z.Spec.Forwarders)))
}
b.WriteString(indent + "};\n")
return b.String()
}
@@ -122,6 +149,12 @@ func renderView(v bindv1alpha1.BindView, in RenderInput, isPrimary bool) string
if in.Catalog != nil {
b.WriteString(renderCatalogZoneDecl(in, isPrimary, " "))
}
// Forward zones bound to this view.
for _, z := range in.Forwards {
if z.Spec.ViewRef == v.Name {
b.WriteString(renderForwardZone(z, " "))
}
}
b.WriteString("};\n\n")
return b.String()
}
@@ -206,6 +239,9 @@ func catalogZonesClause(in RenderInput, isPrimary bool, indent string) string {
if len(primaries) == 0 && in.PrimaryAddress != "" {
primaries = []string{in.PrimaryAddress}
}
if len(primaries) == 0 {
return ""
}
var b strings.Builder
b.WriteString(indent + "catalog-zones {\n")
b.WriteString(fmt.Sprintf("%s zone \"%s\" default-primaries { %s };\n", indent, in.Catalog.Spec.ZoneName, terminate(primaries)))
@@ -227,6 +263,12 @@ func renderCatalogZoneDecl(in RenderInput, isPrimary bool, indent string) string
if len(primaries) == 0 && in.PrimaryAddress != "" {
primaries = []string{in.PrimaryAddress}
}
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
// it once the primary pod has an IP.
return ""
}
var b strings.Builder
b.WriteString(fmt.Sprintf("%szone \"%s\" {\n", indent, cat.Spec.ZoneName))
b.WriteString(indent + " type secondary;\n")
+50
View File
@@ -53,6 +53,56 @@ func TestRenderCatalogOnSecondaryOnly(t *testing.T) {
}
}
func TestRenderCatalogOmittedWhenPrimaryIPUnknown(t *testing.T) {
// Primary IP not known yet and no explicit default-primaries: the secondary
// must not emit a catalog-zones / secondary catalog zone with an empty
// primaries list (which BIND rejects at config load).
in := RenderInput{
Cluster: newCluster(bindv1alpha1.ModeAuthoritative),
Catalog: &bindv1alpha1.BindCatalogZone{Spec: bindv1alpha1.BindCatalogZoneSpec{ZoneName: "catalog.internal"}},
PrimaryAddress: "",
}
_, secondary := RenderNamedConf(in)
if strings.Contains(secondary, "catalog-zones") || strings.Contains(secondary, "primaries {") {
t.Fatalf("secondary must omit catalog primaries when the primary IP is unknown:\n%s", secondary)
}
}
func TestRenderCatalogUsesPrimaryIP(t *testing.T) {
in := RenderInput{
Cluster: newCluster(bindv1alpha1.ModeAuthoritative),
Catalog: &bindv1alpha1.BindCatalogZone{Spec: bindv1alpha1.BindCatalogZoneSpec{ZoneName: "catalog.internal"}},
PrimaryAddress: "10.42.0.7",
}
_, secondary := RenderNamedConf(in)
if !strings.Contains(secondary, "primaries { 10.42.0.7; }") {
t.Fatalf("secondary should point primaries at the primary pod IP:\n%s", secondary)
}
}
func TestRenderForwardZoneInView(t *testing.T) {
rec := true
in := RenderInput{
Cluster: newCluster(bindv1alpha1.ModeResolver),
Views: []bindv1alpha1.BindView{{
ObjectMeta: metav1.ObjectMeta{Name: "openforwarder"},
Spec: bindv1alpha1.BindViewSpec{ClusterRef: "auth", MatchClients: []string{"acl-main"}, Recursion: &rec},
}},
Forwards: []bindv1alpha1.BindZone{{
Spec: bindv1alpha1.BindZoneSpec{ClusterRef: "auth", ZoneName: "unkin.net", Type: bindv1alpha1.ZoneForward, ViewRef: "openforwarder", Forwarders: []string{"198.18.19.15"}},
}},
}
primary, secondary := RenderNamedConf(in)
for _, out := range []string{primary, secondary} {
if !strings.Contains(out, `view "openforwarder"`) {
t.Fatalf("view missing:\n%s", out)
}
if !strings.Contains(out, `zone "unkin.net" {`) || !strings.Contains(out, "type forward;") || !strings.Contains(out, "forwarders { 198.18.19.15; }") {
t.Fatalf("forward zone not rendered inside view (must be on all pods):\n%s", out)
}
}
}
func TestRenderACL(t *testing.T) {
in := RenderInput{
Cluster: newCluster(bindv1alpha1.ModeAuthoritative),
+10 -8
View File
@@ -26,14 +26,15 @@ func (e *Executor) ZoneExists(ctx context.Context, namespace, pod, zone, view st
return err == nil
}
// WriteSeedZone writes a minimal loadable zone file (SOA + apex NS) to path,
// creating parent directories. It is only safe to call when creating a zone, as
// it overwrites any existing file.
func (e *Executor) WriteSeedZone(ctx context.Context, namespace, pod, zone, path, primaryNS string, serial int64) error {
// WriteSeedZone writes a minimal loadable zone file (SOA + apex NS + glue) to
// path, creating parent directories. The apex NS is the in-zone name ns1, and a
// glue A record pointing at primaryIP is included so BIND's check-integrity
// accepts the zone (an in-zone NS without an address record is a load error).
// It is only safe to call when creating a zone, as it overwrites any existing
// file. This is a placeholder that is replaced once real records are loaded.
func (e *Executor) WriteSeedZone(ctx context.Context, namespace, pod, zone, path, primaryIP string, serial int64) error {
origin := dot(zone)
if primaryNS == "" {
primaryNS = "ns1." + origin
}
ns := "ns1." + origin
content := fmt.Sprintf(`$TTL 3600
@ IN SOA %s hostmaster.%s (
%d ; serial
@@ -42,7 +43,8 @@ func (e *Executor) WriteSeedZone(ctx context.Context, namespace, pod, zone, path
1209600 ; expire
300 ) ; minimum
@ IN NS %s
`, dot(primaryNS), origin, serial, dot(primaryNS))
ns1 IN A %s
`, ns, origin, serial, ns, primaryIP)
cmd := []string{"sh", "-c", fmt.Sprintf("mkdir -p \"$(dirname '%s')\" && cat > '%s'", path, path)}
if out, err := e.Exec(ctx, namespace, pod, cmd, content); err != nil {
@@ -50,7 +50,11 @@ func (r *BindCatalogZoneReconciler) Reconcile(ctx context.Context, req ctrl.Requ
// Ensure the catalog zone exists on the primary.
if !r.Exec.ZoneExists(ctx, catalog.Namespace, primaryPod, catalog.Spec.ZoneName, "") {
if err := r.Exec.WriteSeedZone(ctx, catalog.Namespace, primaryPod, catalog.Spec.ZoneName, bind.CatalogFilePath(catalog.Spec.ZoneName), "", 1); err != nil {
primaryIP := primaryPodIP(ctx, r.Client, cluster)
if primaryIP == "" {
return r.fail(ctx, &catalog, "PrimaryNoIP", "waiting for primary pod IP")
}
if err := r.Exec.WriteSeedZone(ctx, catalog.Namespace, primaryPod, catalog.Spec.ZoneName, bind.CatalogFilePath(catalog.Spec.ZoneName), primaryIP, 1); err != nil {
return r.fail(ctx, &catalog, "SeedFailed", err.Error())
}
}
+39 -2
View File
@@ -128,7 +128,15 @@ func (r *BindClusterReconciler) reconcileKeysSecret(ctx context.Context, c *bind
if err := r.List(ctx, &keys, client.InNamespace(c.Namespace)); err != nil {
return err
}
items := append([]bindv1alpha1.BindTSIGKey(nil), keys.Items...)
// Include keys scoped to this cluster (spec.clusterRef == name) and shared
// keys (empty clusterRef). This keeps keys from leaking across clusters that
// share a namespace.
var items []bindv1alpha1.BindTSIGKey
for _, k := range keys.Items {
if k.Spec.ClusterRef == "" || k.Spec.ClusterRef == c.Name {
items = append(items, k)
}
}
sort.Slice(items, func(i, j int) bool { return items[i].Name < items[j].Name })
var b strings.Builder
@@ -160,7 +168,10 @@ func (r *BindClusterReconciler) reconcileKeysSecret(ctx context.Context, c *bind
}
func (r *BindClusterReconciler) reconcileConfigMap(ctx context.Context, c *bindv1alpha1.BindCluster) error {
in := bind.RenderInput{Cluster: c, PrimaryAddress: primaryAddress(c.Name, c.Namespace)}
// 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)}
var acls bindv1alpha1.BindACLList
if err := r.List(ctx, &acls, client.InNamespace(c.Namespace)); err == nil {
@@ -195,6 +206,17 @@ func (r *BindClusterReconciler) reconcileConfigMap(ctx context.Context, c *bindv
}
}
// Forward zones are configuration (no data), so they are rendered into
// named.conf on every pod rather than added dynamically to the primary.
var zones bindv1alpha1.BindZoneList
if err := r.List(ctx, &zones, client.InNamespace(c.Namespace)); err == nil {
for _, z := range zones.Items {
if z.Spec.ClusterRef == c.Name && z.Spec.Type == bindv1alpha1.ZoneForward {
in.Forwards = append(in.Forwards, z)
}
}
}
var catalogs bindv1alpha1.BindCatalogZoneList
if err := r.List(ctx, &catalogs, client.InNamespace(c.Namespace)); err == nil {
for i := range catalogs.Items {
@@ -387,6 +409,12 @@ func (r *BindClusterReconciler) SetupWithManager(mgr ctrl.Manager) error {
Owns(&corev1.Service{}).
Owns(&corev1.ConfigMap{}).
Owns(&corev1.Secret{}).
Watches(&corev1.Pod{}, handler.EnqueueRequestsFromMapFunc(func(ctx context.Context, o client.Object) []reconcile.Request {
// Re-render named.conf when a cluster pod's IP appears or changes, so
// secondaries always point primaries/default-primaries at the current
// primary pod IP.
return mapToCluster(o.GetLabels()[clusterLabel], o.GetNamespace())
})).
Watches(&bindv1alpha1.BindACL{}, handler.EnqueueRequestsFromMapFunc(func(ctx context.Context, o client.Object) []reconcile.Request {
return mapToCluster(o.(*bindv1alpha1.BindACL).Spec.ClusterRef, o.GetNamespace())
})).
@@ -399,6 +427,15 @@ func (r *BindClusterReconciler) SetupWithManager(mgr ctrl.Manager) error {
Watches(&bindv1alpha1.BindDNSSECPolicy{}, handler.EnqueueRequestsFromMapFunc(func(ctx context.Context, o client.Object) []reconcile.Request {
return mapToCluster(o.(*bindv1alpha1.BindDNSSECPolicy).Spec.ClusterRef, o.GetNamespace())
})).
Watches(&bindv1alpha1.BindZone{}, handler.EnqueueRequestsFromMapFunc(func(ctx context.Context, o client.Object) []reconcile.Request {
// Only forward zones affect named.conf; primary/secondary zones are
// managed dynamically by the BindZone controller.
z := o.(*bindv1alpha1.BindZone)
if z.Spec.Type != bindv1alpha1.ZoneForward {
return nil
}
return mapToCluster(z.Spec.ClusterRef, o.GetNamespace())
})).
Watches(&bindv1alpha1.BindCatalogZone{}, handler.EnqueueRequestsFromMapFunc(func(ctx context.Context, o client.Object) []reconcile.Request {
return mapToCluster(o.(*bindv1alpha1.BindCatalogZone).Spec.ClusterRef, o.GetNamespace())
})).
+5 -1
View File
@@ -63,7 +63,11 @@ func (r *BindPolicyReconciler) Reconcile(ctx context.Context, req ctrl.Request)
}
if !r.Exec.ZoneExists(ctx, policy.Namespace, primaryPod, policy.Spec.ZoneName, policy.Spec.ViewRef) {
if err := r.Exec.WriteSeedZone(ctx, policy.Namespace, primaryPod, policy.Spec.ZoneName, bind.ZoneFilePath(policy.Spec.ZoneName), "", 1); err != nil {
primaryIP := primaryPodIP(ctx, r.Client, cluster)
if primaryIP == "" {
return r.fail(ctx, &policy, "PrimaryNoIP", "waiting for primary pod IP")
}
if err := r.Exec.WriteSeedZone(ctx, policy.Namespace, primaryPod, policy.Spec.ZoneName, bind.ZoneFilePath(policy.Spec.ZoneName), primaryIP, 1); err != nil {
return r.fail(ctx, &policy, "SeedFailed", err.Error())
}
}
+19 -2
View File
@@ -35,6 +35,19 @@ func (r *BindZoneReconciler) Reconcile(ctx context.Context, req ctrl.Request) (c
return ctrl.Result{}, client.IgnoreNotFound(err)
}
// Forward zones are pure configuration rendered into named.conf by the
// BindCluster controller (on every pod), not added dynamically to the
// primary. Nothing to do here beyond reporting readiness.
if zone.Spec.Type == bindv1alpha1.ZoneForward {
zone.Status.Phase = "Ready"
zone.Status.ObservedGeneration = zone.Generation
setReady(&zone.Status.Conditions, zone.Generation, true, "Configured", "forward zone rendered into named.conf")
if err := r.Status().Update(ctx, &zone); err != nil {
return ctrl.Result{}, err
}
return ctrl.Result{}, nil
}
cluster, err := getCluster(ctx, r.Client, zone.Namespace, zone.Spec.ClusterRef)
if err != nil {
return r.setPhase(ctx, &zone, "Error", "ClusterMissing", err.Error())
@@ -73,8 +86,12 @@ func (r *BindZoneReconciler) Reconcile(ctx context.Context, req ctrl.Request) (c
}
created := !r.Exec.ZoneExists(ctx, zone.Namespace, primaryPod, zone.Spec.ZoneName, zone.Spec.ViewRef)
if created && zone.Spec.Type == bindv1alpha1.ZonePrimary || (created && zone.Spec.Type == "") {
if err := r.Exec.WriteSeedZone(ctx, zone.Namespace, primaryPod, zone.Spec.ZoneName, bind.ZoneFilePath(zone.Spec.ZoneName), "", 1); err != nil {
if created && (zone.Spec.Type == bindv1alpha1.ZonePrimary || zone.Spec.Type == "") {
primaryIP := primaryPodIP(ctx, r.Client, cluster)
if primaryIP == "" {
return r.setPhase(ctx, &zone, "Pending", "PrimaryNoIP", "waiting for primary pod IP")
}
if err := r.Exec.WriteSeedZone(ctx, zone.Namespace, primaryPod, zone.Spec.ZoneName, bind.ZoneFilePath(zone.Spec.ZoneName), primaryIP, 1); err != nil {
return r.setPhase(ctx, &zone, "Error", "SeedFailed", err.Error())
}
}
+12
View File
@@ -87,6 +87,18 @@ func primaryReady(ctx context.Context, c client.Client, cluster *bindv1alpha1.Bi
return false
}
// primaryPodIP returns the pod IP of a cluster's primary pod (ordinal 0), or an
// empty string if the pod has no IP yet. BIND's primaries/default-primaries
// only accept IP addresses (not hostnames), and zone seeding needs the address
// for glue, so the operator resolves the pod IP rather than using a DNS name.
func primaryPodIP(ctx context.Context, c client.Client, cluster *bindv1alpha1.BindCluster) string {
var pod corev1.Pod
if err := c.Get(ctx, client.ObjectKey{Namespace: cluster.Namespace, Name: primaryPodName(cluster.Name)}, &pod); err != nil {
return ""
}
return pod.Status.PodIP
}
// 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