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
This commit is contained in:
2026-07-03 15:48:13 +10:00
parent b3a5b4d0b7
commit fe5fbdaf6d
63 changed files with 8240 additions and 1 deletions
+66
View File
@@ -0,0 +1,66 @@
// Package bind contains helpers for driving BIND9 pods: executing rndc and
// nsupdate over the Kubernetes exec subresource, and rendering named.conf.
package bind
import (
"bytes"
"context"
"fmt"
corev1 "k8s.io/api/core/v1"
"k8s.io/client-go/kubernetes"
"k8s.io/client-go/kubernetes/scheme"
"k8s.io/client-go/rest"
"k8s.io/client-go/tools/remotecommand"
)
// ContainerName is the BIND container name within each pod.
const ContainerName = "bind"
// Executor runs commands inside BIND pods via the exec subresource.
type Executor struct {
config *rest.Config
clientset kubernetes.Interface
}
// NewExecutor builds an Executor from a controller-runtime rest config.
func NewExecutor(cfg *rest.Config) (*Executor, error) {
cs, err := kubernetes.NewForConfig(cfg)
if err != nil {
return nil, fmt.Errorf("build clientset: %w", err)
}
return &Executor{config: cfg, clientset: cs}, nil
}
// Exec runs command in the BIND container of pod, optionally feeding stdin, and
// returns stdout. A non-zero exit or transport error yields an error that
// includes stderr.
func (e *Executor) Exec(ctx context.Context, namespace, pod string, command []string, stdin string) (string, error) {
req := e.clientset.CoreV1().RESTClient().Post().
Resource("pods").
Name(pod).
Namespace(namespace).
SubResource("exec").
VersionedParams(&corev1.PodExecOptions{
Container: ContainerName,
Command: command,
Stdin: stdin != "",
Stdout: true,
Stderr: true,
}, scheme.ParameterCodec)
exec, err := remotecommand.NewSPDYExecutor(e.config, "POST", req.URL())
if err != nil {
return "", fmt.Errorf("spdy executor: %w", err)
}
var stdout, stderr bytes.Buffer
opts := remotecommand.StreamOptions{Stdout: &stdout, Stderr: &stderr}
if stdin != "" {
opts.Stdin = bytes.NewBufferString(stdin)
}
if err := exec.StreamWithContext(ctx, opts); err != nil {
return stdout.String(), fmt.Errorf("exec %v: %w (stderr: %s)", command, err, stderr.String())
}
return stdout.String(), nil
}
+29
View File
@@ -0,0 +1,29 @@
package bind
import (
"crypto/sha1"
"encoding/hex"
"strings"
)
// catalogHash returns the unique member label for a catalog zone entry: the
// hex-encoded SHA-1 digest of the member zone name in DNS wire format, per the
// BIND catalog-zone schema (RFC 9432).
func catalogHash(zone string) string {
sum := sha1.Sum(wireName(zone))
return hex.EncodeToString(sum[:])
}
// wireName encodes a domain name into uncompressed DNS wire format: each label
// length-prefixed, terminated by a zero-length root label. Names are lowercased.
func wireName(name string) []byte {
name = strings.TrimSuffix(strings.ToLower(strings.TrimSpace(name)), ".")
var out []byte
if name != "" {
for _, label := range strings.Split(name, ".") {
out = append(out, byte(len(label)))
out = append(out, []byte(label)...)
}
}
return append(out, 0)
}
+34
View File
@@ -0,0 +1,34 @@
package bind
import (
"crypto/rand"
"encoding/base64"
"fmt"
)
// GenerateSecret returns a base64-encoded cryptographically-random key of n
// bytes, suitable for a TSIG or rndc HMAC secret.
func GenerateSecret(n int) (string, error) {
buf := make([]byte, n)
if _, err := rand.Read(buf); err != nil {
return "", fmt.Errorf("read random: %w", err)
}
return base64.StdEncoding.EncodeToString(buf), nil
}
// KeyClause renders a named.conf `key` block for inclusion.
func KeyClause(name, algorithm, secret string) string {
return fmt.Sprintf("key \"%s\" {\n algorithm %s;\n secret \"%s\";\n};\n", name, algorithm, secret)
}
// SecretBytesForAlgorithm returns a reasonable key length for a TSIG algorithm.
func SecretBytesForAlgorithm(algorithm string) int {
switch algorithm {
case "hmac-sha512", "hmac-sha384":
return 64
case "hmac-sha256":
return 32
default:
return 32
}
}
+60
View File
@@ -0,0 +1,60 @@
package bind
import (
"context"
"fmt"
"strings"
)
// TSIGCreds carries the material needed to authenticate a dynamic update.
type TSIGCreds struct {
Name string // TSIG key name
Algorithm string // e.g. hmac-sha256
Secret string // base64-encoded key
}
// RecordUpdate describes a desired record set to apply to a zone.
type RecordUpdate struct {
FQDN string // fully-qualified owner name, trailing dot recommended
Type string // RR type
TTL int32 // record TTL
Values []string // RDATA entries
Delete bool // when true, delete the RRset instead of replacing it
}
// NSUpdate applies a set of record changes to zone by executing nsupdate on the
// primary pod, targeting the local server and authenticating with creds. All
// changes are sent in a single atomic transaction.
func (e *Executor) NSUpdate(ctx context.Context, namespace, pod, zone string, creds TSIGCreds, updates []RecordUpdate) error {
var b strings.Builder
b.WriteString("server 127.0.0.1\n")
b.WriteString(fmt.Sprintf("zone %s\n", dot(zone)))
for _, u := range updates {
// Replace semantics: clear the RRset first, then add the desired values.
b.WriteString(fmt.Sprintf("update delete %s %s\n", dot(u.FQDN), u.Type))
if u.Delete {
continue
}
for _, v := range u.Values {
b.WriteString(fmt.Sprintf("update add %s %d %s %s\n", dot(u.FQDN), u.TTL, u.Type, v))
}
}
b.WriteString("send\n")
cmd := []string{"nsupdate", "-y", fmt.Sprintf("%s:%s:%s", creds.Algorithm, creds.Name, creds.Secret)}
if out, err := e.Exec(ctx, namespace, pod, cmd, b.String()); err != nil {
return fmt.Errorf("nsupdate zone %s: %w (out: %s)", zone, err, out)
}
return nil
}
// dot ensures a name is fully qualified with a trailing dot.
func dot(name string) string {
if name == "" || name == "@" {
return "@"
}
if strings.HasSuffix(name, ".") {
return name
}
return name + "."
}
+289
View File
@@ -0,0 +1,289 @@
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
}
+80
View File
@@ -0,0 +1,80 @@
package bind
import (
"strings"
"testing"
bindv1alpha1 "git.unkin.net/unkin/bind-operator/api/v1alpha1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)
func newCluster(mode bindv1alpha1.BindMode) *bindv1alpha1.BindCluster {
return &bindv1alpha1.BindCluster{
ObjectMeta: metav1.ObjectMeta{Name: "auth", Namespace: "dns"},
Spec: bindv1alpha1.BindClusterSpec{Mode: mode, Replicas: 3},
}
}
func TestRenderResolverEnablesRecursion(t *testing.T) {
primary, secondary := RenderNamedConf(RenderInput{Cluster: newCluster(bindv1alpha1.ModeResolver)})
if !strings.Contains(primary, "recursion yes;") {
t.Fatalf("resolver primary should enable recursion:\n%s", primary)
}
if !strings.Contains(secondary, "recursion yes;") {
t.Fatalf("resolver secondary should enable recursion")
}
}
func TestRenderAuthoritativeDisablesRecursion(t *testing.T) {
primary, _ := RenderNamedConf(RenderInput{Cluster: newCluster(bindv1alpha1.ModeAuthoritative)})
if !strings.Contains(primary, "recursion no;") {
t.Fatalf("authoritative should disable recursion:\n%s", primary)
}
if !strings.Contains(primary, "allow-new-zones yes;") {
t.Fatalf("authoritative should allow new zones for dynamic provisioning")
}
}
func TestRenderCatalogOnSecondaryOnly(t *testing.T) {
in := RenderInput{
Cluster: newCluster(bindv1alpha1.ModeAuthoritative),
Catalog: &bindv1alpha1.BindCatalogZone{Spec: bindv1alpha1.BindCatalogZoneSpec{ZoneName: "catalog.internal", DefaultPrimaries: []string{"10.0.0.1"}}},
PrimaryAddress: "auth-0.auth-headless.dns.svc.cluster.local",
}
primary, secondary := RenderNamedConf(in)
if strings.Contains(primary, "catalog-zones") {
t.Fatalf("primary must not consume the catalog it publishes:\n%s", primary)
}
if !strings.Contains(secondary, "catalog-zones") {
t.Fatalf("secondary must consume the catalog zone:\n%s", secondary)
}
if !strings.Contains(secondary, "type secondary;") {
t.Fatalf("secondary must declare the catalog zone as a secondary")
}
}
func TestRenderACL(t *testing.T) {
in := RenderInput{
Cluster: newCluster(bindv1alpha1.ModeAuthoritative),
ACLs: []bindv1alpha1.BindACL{{
ObjectMeta: metav1.ObjectMeta{Name: "internal"},
Spec: bindv1alpha1.BindACLSpec{Entries: []string{"10.0.0.0/8", "192.168.0.0/16"}},
}},
}
primary, _ := RenderNamedConf(in)
if !strings.Contains(primary, `acl "internal" { 10.0.0.0/8; 192.168.0.0/16; };`) {
t.Fatalf("ACL not rendered correctly:\n%s", primary)
}
}
func TestCatalogHashStable(t *testing.T) {
// SHA-1 of the wire format of "example.com" is well-defined and stable.
h1 := catalogHash("example.com")
h2 := catalogHash("example.com.")
if h1 != h2 {
t.Fatalf("trailing dot should not change hash: %s vs %s", h1, h2)
}
if len(h1) != 40 {
t.Fatalf("expected 40-char hex sha1, got %d: %s", len(h1), h1)
}
}
+89
View File
@@ -0,0 +1,89 @@
package bind
import (
"context"
"fmt"
"strings"
)
// RndcConfPath is the operator-managed rndc client config mounted in each pod.
const RndcConfPath = "/etc/bind/rndc.conf"
// Rndc runs `rndc <args...>` on a pod and returns its output.
func (e *Executor) Rndc(ctx context.Context, namespace, pod string, args ...string) (string, error) {
base := []string{"rndc", "-c", RndcConfPath}
return e.Exec(ctx, namespace, pod, append(base, args...), "")
}
// Reconfig reloads named.conf and any newly added/removed zones without a full
// restart.
func (e *Executor) Reconfig(ctx context.Context, namespace, pod string) error {
_, err := e.Rndc(ctx, namespace, pod, "reconfig")
return err
}
// AddZone provisions a zone at runtime via `rndc addzone`. config is the inner
// zone clause, e.g. `{ type primary; file "db.example"; allow-update { key k; }; };`.
func (e *Executor) AddZone(ctx context.Context, namespace, pod, zone, view, config string) error {
args := []string{"addzone", zone}
if view != "" {
args = append(args, "in", view)
}
args = append(args, config)
out, err := e.Rndc(ctx, namespace, pod, args...)
if err != nil {
// addzone fails if the zone already exists; fall back to modzone so the
// operation is idempotent.
if strings.Contains(err.Error(), "already exists") || strings.Contains(out, "already exists") {
return e.ModZone(ctx, namespace, pod, zone, view, config)
}
return err
}
return nil
}
// ModZone updates an existing runtime-added zone's configuration.
func (e *Executor) ModZone(ctx context.Context, namespace, pod, zone, view, config string) error {
args := []string{"modzone", zone}
if view != "" {
args = append(args, "in", view)
}
args = append(args, config)
_, err := e.Rndc(ctx, namespace, pod, args...)
return err
}
// DelZone removes a runtime-added zone. A missing zone is treated as success.
func (e *Executor) DelZone(ctx context.Context, namespace, pod, zone, view string) error {
args := []string{"delzone", zone}
if view != "" {
args = append(args, "in", view)
}
out, err := e.Rndc(ctx, namespace, pod, args...)
if err != nil && (strings.Contains(err.Error(), "not found") || strings.Contains(out, "not found")) {
return nil
}
return err
}
// ZoneSerial returns the current SOA serial for a zone via `rndc zonestatus`.
func (e *Executor) ZoneSerial(ctx context.Context, namespace, pod, zone, view string) (int64, error) {
args := []string{"zonestatus", zone}
if view != "" {
args = append(args, "in", view)
}
out, err := e.Rndc(ctx, namespace, pod, args...)
if err != nil {
return 0, err
}
for _, line := range strings.Split(out, "\n") {
line = strings.TrimSpace(line)
if strings.HasPrefix(line, "serial:") {
var serial int64
if _, err := fmt.Sscanf(line, "serial: %d", &serial); err == nil {
return serial, nil
}
}
}
return 0, nil
}
+74
View File
@@ -0,0 +1,74 @@
package bind
import (
"context"
"fmt"
"strings"
)
// ZoneFilePath returns the on-pod path of a zone database file.
func ZoneFilePath(zone string) string {
return fmt.Sprintf("%s/zones/db.%s", DataDir, strings.TrimSuffix(zone, "."))
}
// CatalogFilePath returns the on-pod path of a catalog zone database file.
func CatalogFilePath(zone string) string {
return fmt.Sprintf("%s/catalog/db.%s", DataDir, strings.TrimSuffix(zone, "."))
}
// ZoneExists reports whether a zone is currently loaded on the pod.
func (e *Executor) ZoneExists(ctx context.Context, namespace, pod, zone, view string) bool {
args := []string{"zonestatus", zone}
if view != "" {
args = append(args, "in", view)
}
_, err := e.Rndc(ctx, namespace, pod, args...)
return err == nil
}
// WriteSeedZone writes a minimal loadable zone file (SOA + apex NS) to path,
// creating parent directories. It is only safe to call when creating a zone, as
// it overwrites any existing file.
func (e *Executor) WriteSeedZone(ctx context.Context, namespace, pod, zone, path, primaryNS string, serial int64) error {
origin := dot(zone)
if primaryNS == "" {
primaryNS = "ns1." + origin
}
content := fmt.Sprintf(`$TTL 3600
@ IN SOA %s hostmaster.%s (
%d ; serial
3600 ; refresh
900 ; retry
1209600 ; expire
300 ) ; minimum
@ IN NS %s
`, dot(primaryNS), origin, serial, dot(primaryNS))
cmd := []string{"sh", "-c", fmt.Sprintf("mkdir -p \"$(dirname '%s')\" && cat > '%s'", path, path)}
if out, err := e.Exec(ctx, namespace, pod, cmd, content); err != nil {
return fmt.Errorf("seed zone %s: %w (out: %s)", zone, err, out)
}
return nil
}
// AddCatalogMember registers a member zone in a catalog zone by adding the
// catalog PTR record, so secondaries auto-provision it.
func (e *Executor) AddCatalogMember(ctx context.Context, namespace, pod, catalogZone, memberZone string, creds TSIGCreds) error {
hash := catalogHash(memberZone)
owner := fmt.Sprintf("%s.zones.%s", hash, dot(catalogZone))
updates := []RecordUpdate{{
FQDN: owner,
Type: "PTR",
TTL: 3600,
Values: []string{dot(memberZone)},
}}
return e.NSUpdate(ctx, namespace, pod, catalogZone, creds, updates)
}
// RemoveCatalogMember deregisters a member zone from a catalog zone.
func (e *Executor) RemoveCatalogMember(ctx context.Context, namespace, pod, catalogZone, memberZone string, creds TSIGCreds) error {
hash := catalogHash(memberZone)
owner := fmt.Sprintf("%s.zones.%s", hash, dot(catalogZone))
updates := []RecordUpdate{{FQDN: owner, Type: "PTR", Delete: true}}
return e.NSUpdate(ctx, namespace, pod, catalogZone, creds, updates)
}