Files
bind-operator/internal/bind/render.go
T
unkinben ea330bd767
ci/woodpecker/pr/pre-commit Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/build Pipeline was successful
Fix authoritative secondary replication (TSIG transfer + stable primary)
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

352 lines
11 KiB
Go

package bind
import (
"fmt"
"sort"
"strings"
bindv1alpha1 "git.unkin.net/unkin/bind-operator/api/v1alpha1"
)
// RenderInput aggregates everything needed to render a cluster's named.conf.
type RenderInput struct {
Cluster *bindv1alpha1.BindCluster
ACLs []bindv1alpha1.BindACL
Views []bindv1alpha1.BindView
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
}
// 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) {
return render(in, true), render(in, false)
}
func render(in RenderInput, isPrimary bool) string {
c := in.Cluster
var b strings.Builder
b.WriteString("// Managed by bind-operator. Do not edit.\n")
b.WriteString(fmt.Sprintf("include \"%s\";\n", RndcKeyPath))
b.WriteString(fmt.Sprintf("include \"%s\";\n\n", KeysConfPath))
// Named ACLs (global scope).
acls := append([]bindv1alpha1.BindACL(nil), in.ACLs...)
sort.Slice(acls, func(i, j int) bool { return acls[i].Name < acls[j].Name })
for _, a := range acls {
b.WriteString(fmt.Sprintf("acl \"%s\" { %s };\n", a.Name, matchList(a.Spec.Entries)))
}
if len(acls) > 0 {
b.WriteString("\n")
}
// DNSSEC policies (must precede zones that reference them).
for _, p := range in.DNSSECPolicies {
b.WriteString(renderDNSSECPolicy(p))
}
// options.
b.WriteString("options {\n")
b.WriteString(fmt.Sprintf(" directory \"%s\";\n", DataDir))
b.WriteString(" listen-on port 53 { any; };\n")
b.WriteString(" listen-on-v6 port 53 { any; };\n")
b.WriteString(fmt.Sprintf(" recursion %s;\n", yesno(recursionFor(c))))
if len(c.Spec.Forwarders) > 0 {
b.WriteString(fmt.Sprintf(" forwarders { %s };\n", terminate(c.Spec.Forwarders)))
}
if allowNewZones(c) {
b.WriteString(" allow-new-zones yes;\n")
}
b.WriteString(" dnssec-validation auto;\n")
for _, o := range c.Spec.ExtraOptions {
b.WriteString(" " + strings.TrimRight(o, ";") + ";\n")
}
// When there are no views, response-policy and catalog-zones live in options.
if len(in.Views) == 0 {
b.WriteString(responsePolicyClause(in.Policies, " "))
b.WriteString(catalogZonesClause(in, isPrimary, " "))
}
b.WriteString("};\n\n")
// controls (rndc).
b.WriteString("controls {\n")
b.WriteString(" inet 127.0.0.1 port 953 allow { 127.0.0.1; } keys { \"rndc-key\"; };\n")
b.WriteString("};\n\n")
// Views, if any.
views := append([]bindv1alpha1.BindView(nil), in.Views...)
sort.Slice(views, func(i, j int) bool { return views[i].Spec.Order < views[j].Spec.Order })
for _, v := range views {
b.WriteString(renderView(v, in, isPrimary))
}
// Catalog zone declaration lives at top level when there are no views.
if in.Catalog != nil && len(in.Views) == 0 {
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()
}
func renderView(v bindv1alpha1.BindView, in RenderInput, isPrimary bool) string {
var b strings.Builder
b.WriteString(fmt.Sprintf("view \"%s\" {\n", v.Name))
mc := v.Spec.MatchClients
if len(mc) == 0 {
mc = []string{"any"}
}
b.WriteString(fmt.Sprintf(" match-clients { %s };\n", matchList(mc)))
if len(v.Spec.MatchDestinations) > 0 {
b.WriteString(fmt.Sprintf(" match-destinations { %s };\n", matchList(v.Spec.MatchDestinations)))
}
rec := recursionFor(in.Cluster)
if v.Spec.Recursion != nil {
rec = *v.Spec.Recursion
}
b.WriteString(fmt.Sprintf(" recursion %s;\n", yesno(rec)))
if len(v.Spec.AllowQuery) > 0 {
b.WriteString(fmt.Sprintf(" allow-query { %s };\n", matchList(v.Spec.AllowQuery)))
}
for _, o := range v.Spec.ExtraOptions {
b.WriteString(" " + strings.TrimRight(o, ";") + ";\n")
}
// Policies and catalog scoped to this view.
viewPolicies := filterPoliciesForView(in.Policies, v.Name)
b.WriteString(responsePolicyClause(viewPolicies, " "))
b.WriteString(catalogZonesClause(in, isPrimary, " "))
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()
}
func renderDNSSECPolicy(p bindv1alpha1.BindDNSSECPolicy) string {
name := p.Spec.PolicyName
if name == "" {
name = p.Name
}
var b strings.Builder
b.WriteString(fmt.Sprintf("dnssec-policy \"%s\" {\n", name))
if p.Spec.NSEC3 {
b.WriteString(" nsec3param;\n")
}
if p.Spec.MaxZoneTTL != "" {
b.WriteString(fmt.Sprintf(" max-zone-ttl %s;\n", p.Spec.MaxZoneTTL))
}
if p.Spec.SignaturesValidity != "" {
b.WriteString(fmt.Sprintf(" signatures-validity %s;\n", p.Spec.SignaturesValidity))
}
alg := p.Spec.Algorithm
if alg == "" {
alg = "ecdsap256sha256"
}
if p.Spec.CSK != nil {
b.WriteString(" keys {\n")
b.WriteString(" csk " + keyLine(p.Spec.CSK, alg) + ";\n")
b.WriteString(" };\n")
} else {
b.WriteString(" keys {\n")
if p.Spec.KSK != nil {
b.WriteString(" ksk " + keyLine(p.Spec.KSK, alg) + ";\n")
}
if p.Spec.ZSK != nil {
b.WriteString(" zsk " + keyLine(p.Spec.ZSK, alg) + ";\n")
}
b.WriteString(" };\n")
}
for _, o := range p.Spec.ExtraOptions {
b.WriteString(" " + strings.TrimRight(o, ";") + ";\n")
}
b.WriteString("};\n\n")
return b.String()
}
func keyLine(k *bindv1alpha1.DNSSECKey, defaultAlg string) string {
lifetime := k.Lifetime
if lifetime == "" {
lifetime = "unlimited"
}
alg := k.Algorithm
if alg == "" {
alg = defaultAlg
}
if k.KeySize > 0 {
return fmt.Sprintf("lifetime %s algorithm %s %d", lifetime, alg, k.KeySize)
}
return fmt.Sprintf("lifetime %s algorithm %s", lifetime, alg)
}
func responsePolicyClause(policies []bindv1alpha1.BindPolicy, indent string) string {
if len(policies) == 0 {
return ""
}
sorted := append([]bindv1alpha1.BindPolicy(nil), policies...)
sort.Slice(sorted, func(i, j int) bool { return sorted[i].Spec.Order < sorted[j].Spec.Order })
var b strings.Builder
b.WriteString(indent + "response-policy {\n")
for _, p := range sorted {
b.WriteString(fmt.Sprintf("%s zone \"%s\";\n", indent, p.Spec.ZoneName))
}
b.WriteString(indent + "};\n")
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
}
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 := transferPrimaries(in)
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)))
b.WriteString(indent + "};\n")
return b.String()
}
// renderCatalogZoneDecl declares the catalog zone as a secondary on consumer
// pods. The primary hosts the catalog zone dynamically (created by the
// BindCatalogZone controller via rndc addzone), so nothing is emitted here for
// the primary.
func renderCatalogZoneDecl(in RenderInput, isPrimary bool, indent string) string {
if isPrimary {
return ""
}
cat := in.Catalog
file := CatalogFilePath(cat.Spec.ZoneName)
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
// 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")
b.WriteString(fmt.Sprintf("%s file \"%s\";\n", indent, file))
b.WriteString(fmt.Sprintf("%s primaries { %s };\n", indent, terminate(primaries)))
b.WriteString(indent + "};\n\n")
return b.String()
}
func filterPoliciesForView(policies []bindv1alpha1.BindPolicy, view string) []bindv1alpha1.BindPolicy {
var out []bindv1alpha1.BindPolicy
for _, p := range policies {
if p.Spec.ViewRef == view || p.Spec.ViewRef == "" {
out = append(out, p)
}
}
return out
}
// matchList renders address-match-list elements, each terminated with a
// semicolon: `10.0.0.0/8; key foo;`.
func matchList(entries []string) string {
return terminate(entries)
}
// terminate joins elements each followed by "; ".
func terminate(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, " ")
}
func yesno(b bool) string {
if b {
return "yes"
}
return "no"
}
func recursionFor(c *bindv1alpha1.BindCluster) bool {
if c.Spec.Recursion != nil {
return *c.Spec.Recursion
}
return c.Spec.Mode == bindv1alpha1.ModeResolver
}
func allowNewZones(c *bindv1alpha1.BindCluster) bool {
if c.Spec.AllowNewZones != nil {
return *c.Spec.AllowNewZones
}
return true
}