initial commit
This commit is contained in:
1 parent
5b935301e5
commit
85453852d5
105 files changed
+24846
No files matched your search
@@ -0,0 +1,451 @@
|
||||
// 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)
|
||||
return true
|
||||
}
|
||||
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".
|
||||
if best.delegations[name] {
|
||||
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 {
|
||||
return s.result(best, name, qtype, rrs)
|
||||
}
|
||||
// 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, rrs...)
|
||||
}
|
||||
}
|
||||
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) {
|
||||
for parent := parentOf(name); parent != ""; parent = parentOf(parent) {
|
||||
if m, ok := z.wild[parent]; ok {
|
||||
if rrs, ok := m[qtype]; ok {
|
||||
return rrs, true
|
||||
}
|
||||
}
|
||||
if z.delegations[parent] {
|
||||
return nil, false
|
||||
}
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
func closestWildcardAny(z *zone, name string) ([]dns.RR, bool) {
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
func parentOf(name string) string {
|
||||
i := strings.IndexByte(name, '.')
|
||||
if i < 0 {
|
||||
return ""
|
||||
}
|
||||
return name[i+1:]
|
||||
}
|
||||
|
||||
func nameExists(z *zone, name string) bool {
|
||||
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) }
|
||||
Reference in new issue
Block a user