dc57ac1b2d
An unsigned localhost query silently returns nothing for a zone behind a BindView, which would strand the placeholder. Record what was published instead.
242 lines
8.1 KiB
Go
242 lines
8.1 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, 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 !strings.EqualFold(rec.Type, "NS") || fqdn(rec.Name, zone.Spec.ZoneName) != fqdn("@", 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
|
|
}
|
|
|
|
// publishedNameservers is what the operator has already put in the apex NS RRset.
|
|
// It retracts only these, never a name someone else added, and needs no query
|
|
// against the pod: reading the live RRset back would take a view-scoped lookup,
|
|
// and an unsigned one silently returns nothing for a zone behind a BindView.
|
|
func publishedNameservers(zone *bindv1alpha1.BindZone, cluster *bindv1alpha1.BindCluster) []string {
|
|
if len(zone.Status.Nameservers) > 0 {
|
|
return zone.Status.Nameservers
|
|
}
|
|
// Nothing recorded yet, so the only names in the RRset are what a seed can
|
|
// write: an in-zone ns1 glued to the primary pod's IP (older seeds) or the
|
|
// stable in-cluster name (current ones).
|
|
return append([]string{fqdn("ns1", zone.Spec.ZoneName)}, clusterNameservers(cluster)...)
|
|
}
|
|
|
|
// apexNSUpdates moves a zone's apex NS RRset from published to 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.
|
|
func apexNSUpdates(zone *bindv1alpha1.BindZone, desired, published []string, ttl int32) []bind.RecordUpdate {
|
|
apex := fqdn("@", zone.Spec.ZoneName)
|
|
add := missing(desired, published)
|
|
del := missing(published, 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 {
|
|
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
|
|
}
|