The console could not see local zones at all. `blocklist.local_zones` compiled a store that nothing read back: there was no API for a zone, its records, its delegations or the file a record came from, and no way to change one without editing YAML and reloading. The Domains page is the blocklist's inline rules, not local data, so nothing in the console covered the half of the server that answers authoritatively from disk. There is now a Zones page and the endpoints behind it: `GET /api/zones`, `POST`/`PUT`/`DELETE /api/zones/records`, `POST`/`DELETE /api/zones/delegations` and `DELETE /api/zones?zone=`. **The console owns one directory and nothing else.** `blocklist.managed_zones` holds one `<apex>.zone` per zone, with that zone's SOA, apex NS set and the records added there, and it is compiled by the same `localzone.Load` as the operator's files — so a record added from the console is served on the next query, with no reload and no restart. Files in `blocklist.local_zones` are listed and shown read-only and are never rewritten; the key defaults to empty, so a deployment that never sets it gets a zone browser and no zone writes. Which file a record came from is now part of the store (`zone.files`, keyed by the index the store already uses), so the snapshot can say "console" or "your file" per record; the query path does not read it. **The writer proves its own output.** A record is validated by the project's own master-file parser, so `www` means `www.<zone>` and a bare CNAME target is completed exactly as it would be in a zone file; the file is rendered, and the rendering is re-parsed and compared with the records it meant to write before the file is renamed into place. A writer bug that dropped a record would otherwise present as a zone that silently stopped answering. A zone that cannot answer is refused rather than created: no SOA means no NXDOMAIN or NODATA authority, and no apex NS means nothing is delegated and no server is named. The SOA's primary follows the apex NS set — a zone whose SOA names a server outside its own NS set hands out a referral no resolver can follow — and the serial moves on every write. **A delegation takes the names under it, so a record below a cut is refused.** The store answers those names as delegated without reading the zone file, which would make the record data that looks alive and never answers. `DELETE /api/zones?zone=` exists for the same reason from the other side: a zone the console created is a real zone, so it says NXDOMAIN for what it does not hold, and deleting a record is not what gives a name back to upstream. It is refused for a zone that also has records outside the managed directory, because removing that file would remove none of the zone. **Two bugs the work turned up.** `engine.applyConfig` published local-record metrics only when `blocklist.local_zones` was non-empty, so a zone the console created was served but never counted; the gate is now the store's own record count. And the record-value renderer fell back to `RR.String()` for every type it did not know, which is the whole line rather than the RDATA — it wrote a name and a TTL into the value column for DHCID, LOC, SSHFP and SVCB, and would have fed that back as a record value on the next edit. Every write recompiles the store and flushes the cache. Which names changed is not knowable from the API, and a cached upstream answer for a name the zone now answers is simply wrong, so the flush is blunt on purpose. The wire shapes are type aliases in `internal/api` rather than copies, so there is one set of json tags, and `TestZoneEndpointKeySets` pins the key set at every level with a record and a delegation actually present — an empty list only proves the envelope. `TestZoneWritesNeedAManagedDirectory` pins the read-only default, and `TestZoneConsoleRoundTrip` walks the whole thing: a record added through the API is answered authoritatively on the next query, a cut stops the parent answering below it, and deleting the zone hands the name back. Verified against a running instance built from this tree: records and wildcards answer with `aa`, relative names are qualified, a record below a cut is refused with a message that says why, and the file on disk is a zone file an operator can read. The console page is typechecked and built; the browser was not available in this round, so it has not been looked at.
525 lines
13 KiB
Go
525 lines
13 KiB
Go
// Package localzone serves authoritative data from DNS master files: split
|
|
// DNS setups, internal service records and the odd "answer this one name
|
|
// locally" need, all without a second nameserver to run.
|
|
package localzone
|
|
|
|
import (
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
"sync/atomic"
|
|
"time"
|
|
|
|
"github.com/miekg/dns"
|
|
|
|
"git.k8s-git-k6n.net/mats/fancy-dns/internal/zonefile"
|
|
)
|
|
|
|
// Store is an immutable, compiled set of local zones.
|
|
type Store struct {
|
|
// zones maps a lowercased apex (with trailing dot) to its compiled zone.
|
|
zones map[string]*zone
|
|
// count is the number of records held, for the UI.
|
|
count int
|
|
// files lists the sources that produced this store.
|
|
files []string
|
|
// builtAt is the compile timestamp.
|
|
builtAt time.Time
|
|
}
|
|
|
|
type zone struct {
|
|
origin string
|
|
// exact is keyed by "name|type".
|
|
exact map[string][]dns.RR
|
|
// wild holds "*.name" entries.
|
|
wild map[string]map[uint16][]dns.RR
|
|
// soa is the zone's SOA, used for NXDOMAIN and NODATA authority.
|
|
soa *dns.SOA
|
|
// ns holds the apex NS set.
|
|
ns map[uint16][]dns.RR
|
|
// delegations are the names served by NS records (a cut below the apex).
|
|
delegations map[string]bool
|
|
// files maps an index key ("name|TYPE", or "*./name|TYPE" for a
|
|
// wildcard) to the file the record was parsed from. Only the admin
|
|
// API's zone browser reads it, so the query path pays nothing for it.
|
|
files map[string]string
|
|
// sources are the files this zone was compiled from, in file order.
|
|
sources []string
|
|
minTTL uint32
|
|
}
|
|
|
|
// wildKey is the index key a wildcard record is stored under.
|
|
func wildKey(name, rrtype string) string { return "*./" + name + "|" + rrtype }
|
|
|
|
// source reports the file a record was parsed from ("" when unknown).
|
|
func (z *zone) source(key string) string { return z.files[key] }
|
|
|
|
// Options tunes zone loading.
|
|
type Options struct {
|
|
// Origins overrides the per-file origin derivation.
|
|
Origins map[string]string
|
|
// MaxFiles bounds how many files are read (glob results included).
|
|
MaxFiles int
|
|
}
|
|
|
|
// Load compiles every master file in paths. A path may be a glob.
|
|
func Load(paths []string, opt Options) (*Store, error) {
|
|
files, err := expand(paths, opt.MaxFiles)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
s := &Store{
|
|
zones: make(map[string]*zone, len(files)),
|
|
files: files,
|
|
builtAt: time.Now(),
|
|
}
|
|
for _, f := range files {
|
|
origin := zonefile.OriginFromPath(f)
|
|
if o, ok := opt.Origins[f]; ok {
|
|
origin = dns.Fqdn(o)
|
|
}
|
|
z, err := zonefile.ParseFile(f, origin)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("localzone: %w", err)
|
|
}
|
|
apex := strings.ToLower(z.Origin)
|
|
zz, ok := s.zones[apex]
|
|
if !ok {
|
|
zz = &zone{
|
|
origin: apex,
|
|
exact: make(map[string][]dns.RR),
|
|
wild: make(map[string]map[uint16][]dns.RR),
|
|
delegations: make(map[string]bool),
|
|
files: make(map[string]string),
|
|
ns: make(map[uint16][]dns.RR),
|
|
minTTL: 0xFFFFFFFF,
|
|
}
|
|
s.zones[apex] = zz
|
|
}
|
|
zz.noteSource(f)
|
|
for _, rec := range z.Records {
|
|
if add(zz, rec) {
|
|
s.count++
|
|
}
|
|
}
|
|
}
|
|
for _, zz := range s.zones {
|
|
if zz.minTTL == 0xFFFFFFFF {
|
|
zz.minTTL = 60
|
|
}
|
|
}
|
|
return s, nil
|
|
}
|
|
|
|
func add(z *zone, rec zonefile.Record) bool {
|
|
hdr := rec.RR.Header()
|
|
name := strings.ToLower(dns.Fqdn(hdr.Name))
|
|
// SOA is zone metadata, not a queryable record.
|
|
if soa, ok := rec.RR.(*dns.SOA); ok {
|
|
z.soa = soa
|
|
return false
|
|
}
|
|
key := name
|
|
isWild := false
|
|
if strings.HasPrefix(name, "*.") {
|
|
key = name[2:]
|
|
isWild = true
|
|
}
|
|
// A record must live inside its zone; silently dropping out-of-zone data
|
|
// would be a surprise, so refuse it loudly instead.
|
|
if !inZone(key, z.origin) {
|
|
return false
|
|
}
|
|
// NS at or below the apex marks a delegation: the data below belongs to
|
|
// another server, so we must not answer for it.
|
|
if hdr.Rrtype == dns.TypeNS && !isWild {
|
|
if key == z.origin {
|
|
z.ns[hdr.Rrtype] = append(z.ns[hdr.Rrtype], rec.RR)
|
|
} else {
|
|
z.delegations[key] = true
|
|
}
|
|
}
|
|
if hdr.Ttl < z.minTTL {
|
|
z.minTTL = hdr.Ttl
|
|
}
|
|
if isWild {
|
|
m, ok := z.wild[key]
|
|
if !ok {
|
|
m = make(map[uint16][]dns.RR)
|
|
z.wild[key] = m
|
|
}
|
|
m[hdr.Rrtype] = append(m[hdr.Rrtype], rec.RR)
|
|
z.files[wildKey(key, dns.TypeToString[hdr.Rrtype])] = rec.File
|
|
return true
|
|
}
|
|
k := name + "|" + dns.TypeToString[hdr.Rrtype]
|
|
z.exact[k] = append(z.exact[k], rec.RR)
|
|
z.files[k] = rec.File
|
|
return true
|
|
}
|
|
|
|
// noteSource records a contributing file, keeping the order files were read.
|
|
func (z *zone) noteSource(file string) {
|
|
if file == "" {
|
|
return
|
|
}
|
|
for _, f := range z.sources {
|
|
if f == file {
|
|
return
|
|
}
|
|
}
|
|
z.sources = append(z.sources, file)
|
|
}
|
|
|
|
func inZone(name, origin string) bool {
|
|
if origin == "." {
|
|
return true
|
|
}
|
|
return name == origin || strings.HasSuffix(name, "."+origin)
|
|
}
|
|
|
|
// expand turns configured paths (possibly globs) into a concrete file list.
|
|
func expand(paths []string, max int) ([]string, error) {
|
|
var out []string
|
|
seen := map[string]bool{}
|
|
for _, p := range paths {
|
|
if strings.ContainsAny(p, "*?[") {
|
|
matches, err := filepath.Glob(p)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("localzone: bad glob %q: %w", p, err)
|
|
}
|
|
sort.Strings(matches)
|
|
for _, m := range matches {
|
|
if !seen[m] {
|
|
seen[m] = true
|
|
out = append(out, m)
|
|
}
|
|
}
|
|
continue
|
|
}
|
|
if !seen[p] {
|
|
seen[p] = true
|
|
out = append(out, p)
|
|
}
|
|
}
|
|
// A path that is a directory contributes its *.zone files, which is the
|
|
// natural thing to point at.
|
|
var expanded []string
|
|
for _, p := range out {
|
|
fi, err := os.Stat(p)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("localzone: %w", err)
|
|
}
|
|
if fi.IsDir() {
|
|
entries, err := filepath.Glob(filepath.Join(p, "*.zone"))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
sort.Strings(entries)
|
|
expanded = append(expanded, entries...)
|
|
continue
|
|
}
|
|
expanded = append(expanded, p)
|
|
}
|
|
if max > 0 && len(expanded) > max {
|
|
expanded = expanded[:max]
|
|
}
|
|
return expanded, nil
|
|
}
|
|
|
|
// Result is the outcome of a local lookup.
|
|
type Result struct {
|
|
// Found is true when the store has an answer (including a CNAME).
|
|
Found bool
|
|
// Records is the RRset for the queried type, or a CNAME when the name
|
|
// only has one.
|
|
Records []dns.RR
|
|
// Type is the type the records are for (may differ from the question for
|
|
// a CNAME answer).
|
|
Type uint16
|
|
// Delegated is true when the name is served by another server.
|
|
Delegated bool
|
|
// Authority is the SOA for NXDOMAIN/NODATA responses.
|
|
Authority *dns.SOA
|
|
// TTL is the smallest TTL in the answer.
|
|
TTL uint32
|
|
// Zone is the apex of the zone that answered.
|
|
Zone string
|
|
}
|
|
|
|
// Lookup resolves a question against the local store. It does not chase CNAMEs
|
|
// across the local store; the handler re-queries, which keeps the wildcard and
|
|
// delegation rules in one place.
|
|
func (s *Store) Lookup(q dns.Question) Result {
|
|
if s == nil || len(s.zones) == 0 {
|
|
return Result{}
|
|
}
|
|
name := strings.ToLower(dns.Fqdn(q.Name))
|
|
qtype := q.Qtype
|
|
// The cache and the resolver both skip the root and the local reverse
|
|
// zones, but a local zone may legitimately define them, so check first.
|
|
var best *zone
|
|
for apex, z := range s.zones {
|
|
if !inZone(name, apex) {
|
|
continue
|
|
}
|
|
if best == nil || len(apex) > len(best.origin) {
|
|
best = z
|
|
}
|
|
}
|
|
if best == nil {
|
|
return Result{}
|
|
}
|
|
if qtype == dns.TypeANY {
|
|
return s.any(best, name)
|
|
}
|
|
// A delegation means "ask them, not me": the parent holds no data at or
|
|
// below the cut, so it must neither answer nor claim NXDOMAIN. The NS
|
|
// RRset at the cut is the one exception -- only the parent has it, and a
|
|
// resolver needs it to learn the delegation (RFC 1034 §4.3.3).
|
|
for d := name; d != ""; d = parentOf(d) {
|
|
if !best.delegations[d] {
|
|
continue
|
|
}
|
|
if d == name && qtype == dns.TypeNS {
|
|
break
|
|
}
|
|
return Result{Found: false, Delegated: true, Zone: best.origin, Authority: best.soa, TTL: best.minTTL}
|
|
}
|
|
if rrs, ok := best.exact[name+"|"+dns.TypeToString[qtype]]; ok {
|
|
return s.result(best, name, qtype, rrs)
|
|
}
|
|
// Wildcard: "*.zone" covers one label; RFC 1034 §4.3.3 wants the closest
|
|
// enclosing wildcard, so try progressively shorter parents.
|
|
if rrs, ok := closestWildcard(best, name, qtype); ok {
|
|
// A wildcard answer is labelled with the name that was asked for.
|
|
return s.result(best, name, qtype, synthesize(rrs, name))
|
|
}
|
|
// A CNAME at the name answers any other type.
|
|
if qtype != dns.TypeCNAME {
|
|
if rrs, ok := best.exact[name+"|CNAME"]; ok {
|
|
return s.result(best, name, dns.TypeCNAME, rrs)
|
|
}
|
|
}
|
|
// NODATA: the name exists but has no records of this type.
|
|
if nameExists(best, name) {
|
|
return Result{Found: true, Authority: best.soa, Zone: best.origin, TTL: best.minTTL}
|
|
}
|
|
return Result{Found: false, Authority: best.soa, Zone: best.origin, TTL: best.minTTL}
|
|
}
|
|
|
|
func (s *Store) result(z *zone, name string, qtype uint16, rrs []dns.RR) Result {
|
|
min := uint32(0xFFFFFFFF)
|
|
for _, rr := range rrs {
|
|
if t := rr.Header().Ttl; t < min {
|
|
min = t
|
|
}
|
|
}
|
|
if min == 0xFFFFFFFF {
|
|
min = z.minTTL
|
|
}
|
|
return Result{Found: true, Records: rrs, Type: qtype, TTL: min, Zone: z.origin, Authority: z.soa}
|
|
}
|
|
|
|
func (s *Store) any(z *zone, name string) Result {
|
|
var out []dns.RR
|
|
if rrs, ok := z.exact[name+"|A"]; ok {
|
|
out = append(out, rrs...)
|
|
}
|
|
if rrs, ok := z.exact[name+"|AAAA"]; ok {
|
|
out = append(out, rrs...)
|
|
}
|
|
if rrs, ok := z.exact[name+"|CNAME"]; ok {
|
|
out = append(out, rrs...)
|
|
}
|
|
if rrs, ok := z.exact[name+"|MX"]; ok {
|
|
out = append(out, rrs...)
|
|
}
|
|
if rrs, ok := z.exact[name+"|TXT"]; ok {
|
|
out = append(out, rrs...)
|
|
}
|
|
if rrs, ok := z.exact[name+"|SRV"]; ok {
|
|
out = append(out, rrs...)
|
|
}
|
|
if rrs, ok := z.exact[name+"|NS"]; ok {
|
|
out = append(out, rrs...)
|
|
}
|
|
if len(out) == 0 {
|
|
if rrs, ok := closestWildcardAny(z, name); ok {
|
|
out = append(out, synthesize(rrs, name)...)
|
|
}
|
|
}
|
|
if len(out) == 0 {
|
|
return Result{Found: nameExists(z, name), Authority: z.soa, Zone: z.origin, TTL: z.minTTL}
|
|
}
|
|
return s.result(z, name, dns.TypeANY, out)
|
|
}
|
|
|
|
func closestWildcard(z *zone, name string, qtype uint16) ([]dns.RR, bool) {
|
|
// A wildcard only fills in for a name that has no node of its own.
|
|
if len(z.wild) == 0 || nameExists(z, name) {
|
|
return nil, false
|
|
}
|
|
for parent := parentOf(name); parent != ""; parent = parentOf(parent) {
|
|
if m, ok := z.wild[parent]; ok {
|
|
if rrs, ok := m[qtype]; ok {
|
|
return rrs, true
|
|
}
|
|
}
|
|
// A real node (or a delegation) is the closest encloser, and only a
|
|
// wildcard directly below it can answer: a wildcard never reaches
|
|
// past a name that exists.
|
|
if z.delegations[parent] || nameExists(z, parent) {
|
|
return nil, false
|
|
}
|
|
}
|
|
return nil, false
|
|
}
|
|
|
|
func closestWildcardAny(z *zone, name string) ([]dns.RR, bool) {
|
|
if len(z.wild) == 0 || nameExists(z, name) {
|
|
return nil, false
|
|
}
|
|
for parent := parentOf(name); parent != ""; parent = parentOf(parent) {
|
|
if m, ok := z.wild[parent]; ok {
|
|
var out []dns.RR
|
|
for _, rrs := range m {
|
|
out = append(out, rrs...)
|
|
}
|
|
if len(out) > 0 {
|
|
return out, true
|
|
}
|
|
}
|
|
if z.delegations[parent] || nameExists(z, parent) {
|
|
return nil, false
|
|
}
|
|
}
|
|
return nil, false
|
|
}
|
|
|
|
func parentOf(name string) string {
|
|
i := strings.IndexByte(name, '.')
|
|
if i < 0 {
|
|
return ""
|
|
}
|
|
return name[i+1:]
|
|
}
|
|
|
|
// synthesize copies rrs with the owner name replaced by qname, because a
|
|
// wildcard answer is labelled with the name that was asked for. The store is
|
|
// shared read-only, so the records are copied instead of rewritten in place.
|
|
func synthesize(rrs []dns.RR, qname string) []dns.RR {
|
|
out := make([]dns.RR, 0, len(rrs))
|
|
for _, rr := range rrs {
|
|
cp := dns.Copy(rr)
|
|
if cp == nil {
|
|
continue
|
|
}
|
|
cp.Header().Name = qname
|
|
out = append(out, cp)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func nameExists(z *zone, name string) bool {
|
|
// The apex always exists: it carries the SOA and the NS set, and neither
|
|
// is indexed as a queryable record.
|
|
if name == z.origin {
|
|
return true
|
|
}
|
|
prefix := name + "|"
|
|
for k := range z.exact {
|
|
if strings.HasPrefix(k, prefix) {
|
|
return true
|
|
}
|
|
}
|
|
if _, ok := z.wild[name]; ok {
|
|
return true
|
|
}
|
|
return z.delegations[name]
|
|
}
|
|
|
|
// Count is the number of records in the store.
|
|
func (s *Store) Count() int {
|
|
if s == nil {
|
|
return 0
|
|
}
|
|
return s.count
|
|
}
|
|
|
|
// Zones lists the zone apexes.
|
|
func (s *Store) Zones() []string {
|
|
if s == nil {
|
|
return nil
|
|
}
|
|
out := make([]string, 0, len(s.zones))
|
|
for z := range s.zones {
|
|
out = append(out, z)
|
|
}
|
|
sort.Strings(out)
|
|
return out
|
|
}
|
|
|
|
// Files lists the source files.
|
|
func (s *Store) Files() []string {
|
|
if s == nil {
|
|
return nil
|
|
}
|
|
return s.files
|
|
}
|
|
|
|
// BuiltAt is the compile timestamp.
|
|
func (s *Store) BuiltAt() time.Time {
|
|
if s == nil {
|
|
return time.Time{}
|
|
}
|
|
return s.builtAt
|
|
}
|
|
|
|
// Records returns every record in a zone as zone-file text, for the API and
|
|
// the UI's zone browser.
|
|
func (s *Store) Records(origin string) []string {
|
|
if s == nil {
|
|
return nil
|
|
}
|
|
z, ok := s.zones[strings.ToLower(dns.Fqdn(origin))]
|
|
if !ok {
|
|
return nil
|
|
}
|
|
var out []string
|
|
for _, rrs := range z.exact {
|
|
for _, rr := range rrs {
|
|
out = append(out, rr.String())
|
|
}
|
|
}
|
|
for name, m := range z.wild {
|
|
for _, rrs := range m {
|
|
for _, rr := range rrs {
|
|
out = append(out, strings.Replace(rr.String(), "\t"+rr.Header().Name, "\t*."+name, 1))
|
|
}
|
|
}
|
|
}
|
|
sort.Strings(out)
|
|
return out
|
|
}
|
|
|
|
// Holder keeps the current Store for the hot path.
|
|
type Holder struct{ cur atomic.Pointer[Store] }
|
|
|
|
// NewHolder wraps a store.
|
|
func NewHolder(s *Store) *Holder {
|
|
h := &Holder{}
|
|
if s != nil {
|
|
h.cur.Store(s)
|
|
}
|
|
return h
|
|
}
|
|
|
|
// Get returns the current store (nil when nothing is configured).
|
|
func (h *Holder) Get() *Store { return h.cur.Load() }
|
|
|
|
// Set installs a new store.
|
|
func (h *Holder) Set(s *Store) { h.cur.Store(s) }
|