Files
fancy-dns/internal/localzone/store.go
T
developer a5e54c09df
ci / Console typecheck and build (push) Successful in 13s
ci / Go build, vet and test (push) Successful in 20s
ci / Binary with the console embedded (push) Successful in 17s
ci / Container image (push) Failing after 8s
Qualify relative RDATA names in the zone-file parser
A domain name in the RDATA section written without a trailing dot is
relative to the zone origin (RFC 1035 §5.1), so "www CNAME host" in
example.com. means host.example.com. The qualifier ran on the record
after dns.NewRR had already resolved every RDATA name against the root,
which turns "host" into "host." and makes the two indistinguishable --
relative() saw the trailing dot and left it alone. Every relative CNAME,
NS, MX, PTR, SRV, NAPTR, SOA and SVCB target therefore pointed at the
DNS root, which showed up as a local zone answering a CNAME with
"host." and the alias target holding no local answer. Qualification now
happens in assemble on the token stream, before the record is built,
using a table of the RDATA positions that hold a name per type; names
that already end in a dot stay absolute, so the RPZ targets "." and
"rpz-drop." and the null MX targets are unaffected.

Fix three wrong expectations in the localzone tests while here. The loop
fixture was built with dns.NewRR on "alias.example.com", which cannot
parse because the RR library wants a TTL and class column, so the
expected record now spells them out. The origin-derivation fixture
used "a" as an owner name, and a leading token that names an RR type
reads as an omitted owner, which has no previous owner to inherit on
the first line of a file; the owner is now a word. The merge test
counted the SOA, but it is zone metadata rather than a queryable
record, so two files contribute three records. The holder race test also
looked up a name from the base zone while the holder was serving a store
compiled from other.test., which no answer could satisfy; it now
accepts either store's data.
2026-09-27 14:10:25 +00:00

496 lines
12 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
minTTL uint32
}
// 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),
ns: make(map[uint16][]dns.RR),
minTTL: 0xFFFFFFFF,
}
s.zones[apex] = zz
}
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)
return true
}
k := name + "|" + dns.TypeToString[hdr.Rrtype]
z.exact[k] = append(z.exact[k], rec.RR)
return true
}
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) }