afb4fe2631
BIND ignores an RRset-wide delete at a zone apex, so a DNSRecord for the apex NS can only add to what the zone was seeded with while reporting success. BindZone.spec.nameservers converges it per rdata.
245 lines
8.2 KiB
Go
245 lines
8.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
|
|
}
|
|
|
|
// isApexNS reports whether an owner/type pair addresses a zone's apex NS RRset.
|
|
// BIND ignores an RRset-wide delete there, so such a record can only append:
|
|
// the apex NS is converged per rdata from BindZone.spec.nameservers.
|
|
func isApexNS(name, typ, zone string) bool {
|
|
return strings.EqualFold(typ, "NS") && strings.EqualFold(fqdn(name, zone), fqdn("@", 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 isApexNS(rec.Name, rec.Type, 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, the
|
|
// TTL to publish them with, and 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, ttl int32, declared bool) {
|
|
ttl = zone.Spec.DefaultTTL
|
|
for _, ns := range zone.Spec.Nameservers {
|
|
names = append(names, absolute(ns))
|
|
}
|
|
for _, rec := range zone.Spec.Records {
|
|
if len(names) > 0 {
|
|
break
|
|
}
|
|
if !isApexNS(rec.Name, rec.Type, zone.Spec.ZoneName) {
|
|
continue
|
|
}
|
|
for _, v := range rec.Values {
|
|
names = append(names, absolute(v))
|
|
}
|
|
if rec.TTL != nil {
|
|
ttl = *rec.TTL
|
|
}
|
|
}
|
|
if ttl <= 0 {
|
|
ttl = 3600
|
|
}
|
|
if len(names) > 0 {
|
|
return names, ttl, true
|
|
}
|
|
return clusterNameservers(cluster), ttl, false
|
|
}
|
|
|
|
// apexNSUpdates moves a zone's apex NS RRset from live onto desired, and retires
|
|
// the glue of any in-zone name it retracts. Adds come first: BIND refuses to
|
|
// leave an apex with no NS record, so the replacement must exist before the old
|
|
// name goes, and deleting glue still referenced by an in-zone NS fails named's
|
|
// post-update nameserver sanity check.
|
|
//
|
|
// An empty live set means the query could not see the zone, not that the apex has
|
|
// no NS records — a primary always has one. Nothing is retracted in that case:
|
|
// retracting blind is what turns a delete into "delete the last NS", which named
|
|
// rejects outright.
|
|
// ponytail: names already published are diffed by name only, so an edit to just
|
|
// the TTL never republishes them (dig +short cannot report a TTL). Re-add the
|
|
// whole desired set each pass if TTL edits need to converge.
|
|
func apexNSUpdates(zone *bindv1alpha1.BindZone, desired, live []string, ttl int32) []bind.RecordUpdate {
|
|
apex := fqdn("@", zone.Spec.ZoneName)
|
|
add := missing(desired, live)
|
|
|
|
var updates []bind.RecordUpdate
|
|
if len(add) > 0 {
|
|
updates = append(updates, bind.RecordUpdate{FQDN: apex, Type: "NS", TTL: ttl, Values: add, PerValue: true})
|
|
}
|
|
// missing() yields nothing against an empty live set, so an unreadable RRset
|
|
// retracts nothing on its own.
|
|
del := missing(live, desired)
|
|
if len(del) == 0 {
|
|
return updates
|
|
}
|
|
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 that address with the name, unless
|
|
// spec.records owns it (then it is real data, not the placeholder).
|
|
for _, ns := range del {
|
|
owner, in := bind.InZoneOwner(ns, zone.Spec.ZoneName)
|
|
if in && owner != "@" && !recordsOwn(zone, owner, "A") {
|
|
updates = append(updates, bind.RecordUpdate{FQDN: fqdn(owner, zone.Spec.ZoneName), Type: "A", Delete: true})
|
|
}
|
|
}
|
|
return updates
|
|
}
|
|
|
|
// 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) + "."}
|
|
}
|
|
|
|
// missing returns the names in want with no match in have. DNS names compare
|
|
// case-insensitively.
|
|
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
|
|
}
|