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) + "."} }