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 // NotifyKeyName is the TSIG key name secondaries accept intra-cluster NOTIFYs // under. 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 stable ClusterIP), REFUSES // them as "non-primary" and replication falls back to the SOA refresh timer. // // The primary signs its outgoing NOTIFYs with this key (the zone-scope // also-notify entries, applied via rndc, carry `key ""`), and // secondaries render `allow-notify { key ""; }` — an address-match-list // with a *key* element and NO IPs. Because it names only the key, this clause // is fully static: it never changes when a pod IP churns, so it cannot feed a // changed config-hash and cannot roll the StatefulSet (the v0.2.5 loop). Empty // leaves BIND's default behaviour unchanged. NotifyKeyName 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 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") // Secondaries accept NOTIFY authenticated by the cluster's NOTIFY TSIG key. // 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 they are refused as "non-primary" // unless an explicit allow-notify covers them. Rather than enumerate pod IPs // (restart-scoped config that depends on pod IPs — the v0.2.5 roll loop), we // accept any NOTIFY signed with the cluster key: `allow-notify { key "X"; }`. b.WriteString(allowNotifyClause(in, isPrimary, " ")) 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 } // allowNotifyClause renders an options-scope allow-notify on secondaries that // accepts intra-cluster NOTIFYs authenticated by the cluster's NOTIFY TSIG key. // 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". The primary signs those // NOTIFYs with NotifyKeyName (see the also-notify entries applied via rndc), and // this clause admits them by key. // // The rendered clause is `allow-notify { key ""; };` — a key element with // NO IP addresses. It is therefore fully static: it depends only on the key // name, never on any pod IP, so it can never change the config-hash and can // never trigger a rolling restart. This is the fix for the v0.2.5 loop, where an // options-scope allow-notify enumerating the primary pod IP re-rendered on every // pod churn, flipped the hash, rolled the pods, changed the IP, and looped. // // Emitted only on secondaries and only when the NOTIFY key name is known. func allowNotifyClause(in RenderInput, isPrimary bool, indent string) string { if isPrimary { return "" } key := strings.TrimSpace(in.NotifyKeyName) if key == "" { return "" } return fmt.Sprintf("%sallow-notify { key \"%s\"; };\n", indent, key) } 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 }