4 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
7 changed files with 116 additions and 1 deletions
+6
View File
@@ -16,6 +16,12 @@ const (
// BindTSIGKeySpec defines a TSIG key. If no existing key material is imported, // BindTSIGKeySpec defines a TSIG key. If no existing key material is imported,
// the operator generates a random key and stores it in a Secret. // the operator generates a random key and stores it in a Secret.
type BindTSIGKeySpec struct { 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. // Algorithm is the HMAC algorithm. Defaults to hmac-sha256.
// +kubebuilder:default="hmac-sha256" // +kubebuilder:default="hmac-sha256"
// +optional // +optional
@@ -66,6 +66,12 @@ spec:
- hmac-sha1 - hmac-sha1
- hmac-md5 - hmac-md5
type: string 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: importExisting:
description: |- description: |-
ImportExisting, when true, means the referenced Secret already contains a ImportExisting, when true, means the referenced Secret already contains a
+6
View File
@@ -2043,6 +2043,12 @@ spec:
- hmac-sha1 - hmac-sha1
- hmac-md5 - hmac-md5
type: string 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: importExisting:
description: |- description: |-
ImportExisting, when true, means the referenced Secret already contains a ImportExisting, when true, means the referenced Secret already contains a
+33
View File
@@ -16,6 +16,10 @@ type RenderInput struct {
Policies []bindv1alpha1.BindPolicy Policies []bindv1alpha1.BindPolicy
DNSSECPolicies []bindv1alpha1.BindDNSSECPolicy DNSSECPolicies []bindv1alpha1.BindDNSSECPolicy
Catalog *bindv1alpha1.BindCatalogZone 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 is the in-cluster address secondaries transfer from.
PrimaryAddress string PrimaryAddress string
} }
@@ -90,6 +94,29 @@ func render(in RenderInput, isPrimary bool) string {
b.WriteString(renderCatalogZoneDecl(in, isPrimary, "")) 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() return b.String()
} }
@@ -122,6 +149,12 @@ func renderView(v bindv1alpha1.BindView, in RenderInput, isPrimary bool) string
if in.Catalog != nil { if in.Catalog != nil {
b.WriteString(renderCatalogZoneDecl(in, isPrimary, " ")) 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") b.WriteString("};\n\n")
return b.String() return b.String()
} }
+23
View File
@@ -80,6 +80,29 @@ func TestRenderCatalogUsesPrimaryIP(t *testing.T) {
} }
} }
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) { func TestRenderACL(t *testing.T) {
in := RenderInput{ in := RenderInput{
Cluster: newCluster(bindv1alpha1.ModeAuthoritative), Cluster: newCluster(bindv1alpha1.ModeAuthoritative),
+29 -1
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 { if err := r.List(ctx, &keys, client.InNamespace(c.Namespace)); err != nil {
return err 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 }) sort.Slice(items, func(i, j int) bool { return items[i].Name < items[j].Name })
var b strings.Builder var b strings.Builder
@@ -198,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 var catalogs bindv1alpha1.BindCatalogZoneList
if err := r.List(ctx, &catalogs, client.InNamespace(c.Namespace)); err == nil { if err := r.List(ctx, &catalogs, client.InNamespace(c.Namespace)); err == nil {
for i := range catalogs.Items { for i := range catalogs.Items {
@@ -408,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 { Watches(&bindv1alpha1.BindDNSSECPolicy{}, handler.EnqueueRequestsFromMapFunc(func(ctx context.Context, o client.Object) []reconcile.Request {
return mapToCluster(o.(*bindv1alpha1.BindDNSSECPolicy).Spec.ClusterRef, o.GetNamespace()) 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 { Watches(&bindv1alpha1.BindCatalogZone{}, handler.EnqueueRequestsFromMapFunc(func(ctx context.Context, o client.Object) []reconcile.Request {
return mapToCluster(o.(*bindv1alpha1.BindCatalogZone).Spec.ClusterRef, o.GetNamespace()) return mapToCluster(o.(*bindv1alpha1.BindCatalogZone).Spec.ClusterRef, o.GetNamespace())
})). })).
@@ -35,6 +35,19 @@ func (r *BindZoneReconciler) Reconcile(ctx context.Context, req ctrl.Request) (c
return ctrl.Result{}, client.IgnoreNotFound(err) 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) cluster, err := getCluster(ctx, r.Client, zone.Namespace, zone.Spec.ClusterRef)
if err != nil { if err != nil {
return r.setPhase(ctx, &zone, "Error", "ClusterMissing", err.Error()) return r.setPhase(ctx, &zone, "Error", "ClusterMissing", err.Error())