aab11457af
v0.2.5 (PR #14) added an options-scope allow-notify enumerating the primary pod IP on secondaries. Options-scope config feeds the config-hash annotation that rolls the StatefulSet, so any config change rolled the pods, the primary came back on a new pod IP, the operator re-rendered with the new IP, the hash changed, the pods rolled again — an infinite roll loop across every BindCluster. The prod deployment was reverted to v0.2.4. Replace the pod-IP allow-notify with TSIG-authenticated NOTIFY: - Secondaries render `allow-notify { key "<name>"; };` — a static key element with NO IPs. It depends only on the key name, so pod-IP churn can never change the render, the config-hash, or trigger a restart. - The primary signs its outgoing NOTIFYs: the zone-scope also-notify entries (already enumerating replica pod IPs, applied via rndc addzone/modzone with NO restart) now carry `key "<name>"`. - Key choice: reuse the cluster's catalog transfer TSIG key (TransferKeyRef). Secondaries already present it for AXFR and it is in keys.conf on every pod, so no new key plumbing is needed. Add a permanent regression guard for the loop class: - controller: reconcile the ConfigMap with the primary pod on two different IPs and assert the config-hash is byte-identical. - render: render restart-scoped input and assert no pod IP appears in allow-notify; RenderInput no longer has any pod-IP field. Zone-scope also-notify (rndc, no restart) legitimately still lists pod IPs; only restart-scoped config must be pod-IP-independent.
421 lines
15 KiB
Go
421 lines
15 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
|
|
// 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 "<name>"`), and
|
|
// secondaries render `allow-notify { key "<name>"; }` — 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 "<name>"; };` — 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
|
|
}
|