Files
bind-operator/internal/bind/render.go
T
unkinben fe5fbdaf6d Initial bind-operator: 9 CRDs + controllers
Implements a Kubernetes operator that manages fleets of BIND9 servers
declaratively, using controller-runtime (matching forgebot conventions).

- add BindCluster reconciler: StatefulSet (pod-0 primary, secondaries),
  headless + client Services, rendered named.conf ConfigMap, TSIG keys
  Secret and rndc control Secret; watches dependent CRs to re-render
- add BindTSIGKey reconciler that generates key material into a Secret
- add BindZone/DNSRecord reconcilers using fully-dynamic delivery
  (rndc addzone + TSIG nsupdate against the primary pod)
- add BindCatalogZone reconciler so secondaries auto-provision zones
- add BindPolicy (RPZ), BindDNSSECPolicy, BindView, BindACL reconcilers
- render primary/secondary named.conf variants selected by pod ordinal
- generate CRDs, deepcopy and RBAC; add samples mapping the three Puppet
  roles (authoritative/resolver/external-dns) to three BindClusters
- add Makefile, Dockerfile.operator, Woodpecker CI and kind manifests
2026-07-03 15:48:13 +10:00

290 lines
8.9 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
// PrimaryAddress is the in-cluster address secondaries transfer from.
PrimaryAddress string
}
// DataDir is where BIND keeps zone databases and journals (backed by the PVC).
const DataDir = "/var/lib/named"
// 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(`include "/etc/bind/keys/keys.conf";` + "\n\n")
// 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, ""))
}
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, " "))
}
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()
}
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 := in.Catalog.Spec.DefaultPrimaries
if len(primaries) == 0 && in.PrimaryAddress != "" {
primaries = []string{in.PrimaryAddress}
}
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 := cat.Spec.DefaultPrimaries
if len(primaries) == 0 && in.PrimaryAddress != "" {
primaries = []string{in.PrimaryAddress}
}
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
}