fe5fbdaf6d
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
290 lines
8.9 KiB
Go
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
|
|
}
|