ea330bd767
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).
352 lines
11 KiB
Go
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
|
|
}
|