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:
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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 + "."
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user