7771711682
Secondaries transfer catalog member and plain secondary zones from the primary Service ClusterIP (stable across primary pod restarts), and BIND derives a zone's implicit allow-notify from its primaries list. But the primary pod's NOTIFYs egress with its *pod* IP as source — k8s Services NAT only the inbound direction — so BIND refuses them as "refused notify from non-primary" and replication falls back to the SOA refresh timer, a 1-hour propagation delay on every dynamic zone (external-dns RFC2136 and dns-updater nsupdates alike). Render an options-scope allow-notify on secondaries covering the primary pod IP (and the transfer address, since an explicit allow-notify replaces the primaries-derived default). The cluster controller resolves the primary pod IP the same way it already does for seeding/also-notify, and the existing Pod watch re-renders the ConfigMap when the pod IP changes.
419 lines
14 KiB
Go
419 lines
14 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
|
|
// PrimaryPodAddresses are the primary pod's own IP(s). Secondaries transfer
|
|
// from PrimaryAddress (the stable primary Service ClusterIP) but 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 ClusterIP), REFUSES them as "non-primary" and
|
|
// replication falls back to the SOA refresh timer. Secondaries render these
|
|
// into an options-scope allow-notify so intra-cluster NOTIFYs are accepted
|
|
// immediately. Empty leaves BIND's default behaviour unchanged.
|
|
PrimaryPodAddresses []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 from the primary's pod IP(s). 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 an options-scope allow-notify covering the pod IP(s) is
|
|
// needed or every NOTIFY is refused and replication waits for the SOA refresh.
|
|
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
|
|
// permits the primary pod IP(s). 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".
|
|
// Emitted only on secondaries and only when the primary pod IP(s) are known.
|
|
func allowNotifyClause(in RenderInput, isPrimary bool, indent string) string {
|
|
if isPrimary {
|
|
return ""
|
|
}
|
|
addrs := make([]string, 0, len(in.PrimaryPodAddresses)+1)
|
|
seen := map[string]bool{}
|
|
for _, a := range in.PrimaryPodAddresses {
|
|
a = strings.TrimSpace(strings.TrimRight(a, ";"))
|
|
if a == "" || seen[a] {
|
|
continue
|
|
}
|
|
seen[a] = true
|
|
addrs = append(addrs, a)
|
|
}
|
|
// Keep the transfer address (the Service ClusterIP, or the pod IP when no
|
|
// primary Service exists) in the set: an explicit allow-notify replaces the
|
|
// implicit primaries-derived default, so it must still cover that source.
|
|
if a := strings.TrimSpace(strings.TrimRight(in.PrimaryAddress, ";")); a != "" && !seen[a] {
|
|
addrs = append(addrs, a)
|
|
}
|
|
if len(addrs) == 0 {
|
|
return ""
|
|
}
|
|
sort.Strings(addrs)
|
|
return fmt.Sprintf("%sallow-notify { %s };\n", indent, terminate(addrs))
|
|
}
|
|
|
|
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
|
|
}
|