Files
bind-operator/internal/controller/zone_helpers.go
T
unkin-agent e4ed6c8052
ci/woodpecker/pr/pre-commit Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/build Pipeline was successful
converge apex NS per record, not by RRset replace
BIND ignores an RRset-wide delete of apex NS, so the previous replace only
appended to the seed placeholder.
2026-09-26 18:50:55 +10:00

219 lines
7.2 KiB
Go

package controller
import (
"context"
"fmt"
"strings"
"sigs.k8s.io/controller-runtime/pkg/client"
bindv1alpha1 "git.unkin.net/unkin/bind-operator/api/v1alpha1"
"git.unkin.net/unkin/bind-operator/internal/bind"
)
func isPrimaryType(t bindv1alpha1.ZoneType) bool {
return t == bindv1alpha1.ZonePrimary || t == ""
}
// catalogEnabled reports whether a primary zone should be registered in the
// cluster catalog zone.
func catalogEnabled(zone *bindv1alpha1.BindZone) bool {
if !isPrimaryType(zone.Spec.Type) {
return false
}
if zone.Spec.Catalog == nil {
return true
}
return *zone.Spec.Catalog
}
// fqdn resolves a record owner name relative to a zone origin.
func fqdn(name, zone string) string {
zone = strings.TrimSuffix(zone, ".") + "."
if name == "" || name == "@" {
return zone
}
if strings.HasSuffix(name, ".") {
return name
}
return name + "." + zone
}
func recordsToUpdates(zone string, records []bindv1alpha1.Record, defaultTTL int32) []bind.RecordUpdate {
updates := make([]bind.RecordUpdate, 0, len(records))
for _, rec := range records {
// The apex NS RRset is converged by apexNSUpdates: an RRset-wide delete
// here is ignored by BIND and would only append to the live set.
if strings.EqualFold(rec.Type, "NS") && fqdn(rec.Name, zone) == fqdn("@", zone) {
continue
}
ttl := defaultTTL
if rec.TTL != nil {
ttl = *rec.TTL
}
updates = append(updates, bind.RecordUpdate{
FQDN: fqdn(rec.Name, zone),
Type: rec.Type,
TTL: ttl,
Values: rec.Values,
})
}
return updates
}
// updateKeyName returns the TSIG key name (as used in named.conf) for a zone's
// update key, falling back to the object name.
func updateKeyName(ctx context.Context, c client.Client, zone *bindv1alpha1.BindZone) string {
return tsigKeyName(ctx, c, zone.Namespace, zone.Spec.UpdateKeyRef)
}
// tsigKeyName resolves a BindTSIGKey object reference to the TSIG key name used
// in named.conf (the KeyName override when set, otherwise the object name).
// Returns "" for an empty ref, and falls back to the ref if the object cannot be
// read.
func tsigKeyName(ctx context.Context, c client.Client, namespace, ref string) string {
if ref == "" {
return ""
}
var key bindv1alpha1.BindTSIGKey
if err := c.Get(ctx, client.ObjectKey{Namespace: namespace, Name: ref}, &key); err != nil {
return ref
}
if key.Spec.KeyName != "" {
return key.Spec.KeyName
}
return ref
}
// matchListInline renders address-match-list entries on one line.
func matchListInline(entries []string) string { return terminateInline(entries) }
func terminateInline(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, " ")
}
// alsoNotifyList renders also-notify entries, each optionally annotated with a
// TSIG key so the primary signs its NOTIFYs and secondaries can accept them by
// key (`allow-notify { key ... }`) rather than by pod IP. An entry that already
// carries a `key` clause is left untouched.
func alsoNotifyList(addrs []string, key string) string {
key = strings.TrimSpace(key)
var parts []string
for _, a := range addrs {
a = strings.TrimSpace(strings.TrimRight(a, ";"))
if a == "" {
continue
}
if key != "" && !strings.Contains(a, " key ") {
a = fmt.Sprintf("%s key \"%s\"", a, key)
}
parts = append(parts, a+";")
}
return strings.Join(parts, " ")
}
// absolute qualifies a nameserver name. Unlike record owner names, a
// spec.nameservers entry is always a full domain name, never relative to the
// zone: an in-zone nameserver is spelled out in full.
func absolute(name string) string { return strings.TrimSuffix(name, ".") + "." }
// zoneNameservers resolves the names to publish in a zone's apex NS RRset and
// reports whether the zone declared them. An apex NS in spec.records counts as a
// declaration: BIND ignores an RRset-wide delete at the apex, so records alone
// can only append to what the zone was seeded with, never replace it. Undeclared
// zones fall back to the primary's stable in-cluster name, which is deliberately
// out-of-zone so no pod IP is needed as glue.
func zoneNameservers(zone *bindv1alpha1.BindZone, cluster *bindv1alpha1.BindCluster) (names []string, declared bool) {
for _, ns := range zone.Spec.Nameservers {
names = append(names, absolute(ns))
}
if len(names) > 0 {
return names, true
}
for _, rec := range zone.Spec.Records {
if strings.EqualFold(rec.Type, "NS") && fqdn(rec.Name, zone.Spec.ZoneName) == fqdn("@", zone.Spec.ZoneName) {
for _, v := range rec.Values {
names = append(names, absolute(v))
}
}
}
if len(names) > 0 {
return names, true
}
return []string{primaryAddress(cluster.Name, cluster.Namespace) + "."}, false
}
// apexNSUpdates converges a zone's live apex NS RRset onto desired, and retires
// the seed's ns1 glue once no published nameserver needs it. Adds come first:
// BIND refuses to leave an apex with no NS record, so the replacement must exist
// before the old name goes.
func apexNSUpdates(zone *bindv1alpha1.BindZone, desired, live []string, ttl int32) []bind.RecordUpdate {
if ttl <= 0 {
ttl = 3600
}
apex := fqdn("@", zone.Spec.ZoneName)
add := missing(desired, live)
del := missing(live, desired)
var updates []bind.RecordUpdate
if len(add) > 0 {
updates = append(updates, bind.RecordUpdate{FQDN: apex, Type: "NS", TTL: ttl, Values: add, PerValue: true})
}
if len(del) > 0 {
updates = append(updates, bind.RecordUpdate{FQDN: apex, Type: "NS", Values: del, PerValue: true, Delete: true})
}
// The seed glues an in-zone nameserver to the primary pod's IP, which goes
// stale on the first reschedule. Drop it once no published nameserver is that
// name, unless spec.records owns the address itself. Deleting it while an
// in-zone NS still points at it would fail named's post-update sanity check.
glue := fqdn("ns1", zone.Spec.ZoneName)
if containsName(del, glue) && !containsName(desired, glue) && !recordsOwn(zone, "ns1", "A") {
updates = append(updates, bind.RecordUpdate{FQDN: glue, Type: "A", Delete: true})
}
return updates
}
// missing returns the names in want that have no case-insensitive match in have.
func missing(want, have []string) (out []string) {
for _, w := range want {
if !containsName(have, w) {
out = append(out, w)
}
}
return out
}
func containsName(names []string, name string) bool {
for _, n := range names {
if strings.EqualFold(absolute(n), absolute(name)) {
return true
}
}
return false
}
// recordsOwn reports whether spec.records already manages an owner/type pair, in
// which case the apex sync must leave it alone.
func recordsOwn(zone *bindv1alpha1.BindZone, name, typ string) bool {
for _, rec := range zone.Spec.Records {
if strings.EqualFold(rec.Type, typ) && strings.EqualFold(fqdn(rec.Name, zone.Spec.ZoneName), fqdn(name, zone.Spec.ZoneName)) {
return true
}
}
return false
}
// clusterNameservers is the apex NS for the operator's own internal zones
// (catalog, policy): the primary's stable in-cluster name, never a pod IP.
func clusterNameservers(cluster *bindv1alpha1.BindCluster) []string {
return []string{primaryAddress(cluster.Name, cluster.Namespace) + "."}
}