package localzone import ( "errors" "fmt" "os" "path/filepath" "slices" "sort" "strconv" "strings" "time" "github.com/miekg/dns" "git.k8s-git-k6n.net/mats/fancy-dns/internal/zonefile" ) // DefaultZoneTTL is the $TTL a console-written zone file gets when the request // does not name one. It is also the SOA minimum, so a record written without a // TTL of its own inherits it. const DefaultZoneTTL uint32 = 300 // FileHeader tops every console-written zone file. The file is rewritten // whole on each edit, so hand-written commentary below it does not survive; // operator-owned data belongs in blocklist.local_zones, which the API never // touches. const FileHeader = `; fancy-dns console-managed zone data. ; ; Rewritten by the admin API on every change: edits made here by hand are lost ; the next time a record in this zone is added, changed or deleted. Keep ; hand-maintained data in blocklist.local_zones instead. ` // editableTypes are the RR types the API accepts. It is a subset of what the // master-file assembler recognises, and of the types a zone browser can put // in a form: SOA is zone metadata the writer owns, and AXFR/IXFR/ANY are not // things a console should be able to add to a zone it serves. var editableTypes = []string{ "A", "AAAA", "CAA", "CNAME", "DHCID", "HINFO", "HTTPS", "LOC", "MX", "NAPTR", "NS", "PTR", "SRV", "SPF", "SRV", "SSHFP", "SVCB", "TXT", "URI", } // EditableTypes is the set of record types the API will write, sorted and // deduplicated. The console builds its type picker from this, so the two // cannot drift apart. func EditableTypes() []string { seen := make(map[string]bool, len(editableTypes)) out := make([]string, 0, len(editableTypes)) for _, t := range editableTypes { if seen[t] { continue } seen[t] = true out = append(out, t) } sort.Strings(out) return out } func isEditableType(rrtype string) bool { for _, t := range EditableTypes() { if t == rrtype { return true } } return false } // Managed is the console-managed zone directory: the only zone data the admin // API is allowed to rewrite. It is one file per apex, named after it, holding // the SOA, the apex NS set and whatever records the console has written. A // zone whose bulk lives in an operator's master file can still gain a managed // file, because Load merges every file it is given under one apex. type Managed struct { dir string } // NewManaged wraps a directory. The directory is created on the first write, // never here: a read of a zone directory that does not exist is a legitimate // state, not an error. func NewManaged(dir string) *Managed { return &Managed{dir: dir} } // Dir is the managed directory. func (m *Managed) Dir() string { if m == nil { return "" } return m.dir } // Path is the file holding one apex's managed data. func (m *Managed) Path(origin string) string { if m == nil || m.dir == "" { return "" } return filepath.Join(m.dir, zoneFileName(origin)) } // ErrNotManaged is why a write did not happen because the console is not // allowed to write zone data: no managed directory is configured, or the one // that is cannot be written to. It is not "not found", so the API answers a // delete with 400 for it and 404 for a record that simply is not there. var ErrNotManaged = errors.New("local zone writes are disabled") // Check reports whether a write can proceed, naming which of the two reasons // stopped it. func (m *Managed) Check() error { if m == nil || m.dir == "" { return fmt.Errorf("%w: set blocklist.managed_zones to let the console write zone data", ErrNotManaged) } if !m.Writable() { return fmt.Errorf("%w: the managed zone directory %s is not writable", ErrNotManaged, m.dir) } return nil } // Remove deletes a zone's managed file, which is how a zone the console // created stops being authoritative. It refuses a zone that also exists in an // operator's master file: deleting that file would not remove the zone, and // reporting success would be a lie. func (m *Managed) Remove(origin string, wholeZoneIsManaged bool) (bool, error) { canonical, err := NormalizeOrigin(origin) if err != nil { return false, err } if err := m.Check(); err != nil { return false, err } if !wholeZoneIsManaged { return false, fmt.Errorf("%s also has records outside %s; remove those first", canonical, m.dir) } path := m.Path(canonical) if err := os.Remove(path); err != nil { if os.IsNotExist(err) { return false, nil } return false, err } return true, nil } // Exists reports whether the managed directory is there. It is not an error // when it is not: the directory holds no zones until the console writes one. func (m *Managed) Exists() bool { if m == nil || m.dir == "" { return false } info, err := os.Stat(m.dir) return err == nil && info.IsDir() } // Writable reports whether a write can succeed: a directory that is (or whose // nearest existing parent is) one the process may write to. It creates // nothing, because the API calls it on every read to tell the console whether // its edit controls are live. func (m *Managed) Writable() bool { if m == nil || m.dir == "" { return false } dir := m.dir for { info, err := os.Stat(dir) if err == nil { if !info.IsDir() { return false } probe, err := os.CreateTemp(dir, ".fancy-dns-probe") if err != nil { return false } name := probe.Name() _ = probe.Close() _ = os.Remove(name) return true } if !os.IsNotExist(err) { return false } parent := filepath.Dir(dir) if parent == dir { return false } dir = parent } } // ZoneDefaults seeds a managed file for an apex that has none yet: the SOA and // apex NS set of a zone that already exists elsewhere, or the NS targets the // request itself supplies. type ZoneDefaults struct { // TTL is the $TTL of a new file; 0 means DefaultZoneTTL. TTL uint32 // Soa is an existing zone's SOA rdata (absolute names), or "" to // synthesise one from the first NS target. Soa string // NS are the apex NS targets of a new file. NS []string } // Add writes one record into a zone, creating the zone's managed file when it // has none. The record is canonicalised first, so what the API echoes back is // what a zone-file parse will produce. func (m *Managed) Add(origin string, rec Record, def ZoneDefaults) (uint32, error) { var serial uint32 err := m.mutate(origin, def, func(f *fileState) error { rec, err := newRecord(f.origin, rec.Name, rec.Type, ttlOr(rec.TTL, f.ttl), rec.Value) if err != nil { return err } for _, have := range f.recs { if have.Key() == rec.Key() && have.Value == rec.Value { return fmt.Errorf("%s %s already exists in %s", rec.Name, rec.Type, origin) } } f.recs = append(f.recs, rec) return nil }, &serial) return serial, err } // Put replaces the whole RRset of one name and type. This is the console's // edit: a name and type hold one logical record (an A, a set of MXs, a TXT // RRset), and replacing it wholesale is what an operator means by "change // this row". func (m *Managed) Put(origin string, rec Record, def ZoneDefaults) (uint32, error) { var serial uint32 err := m.mutate(origin, def, func(f *fileState) error { rec, err := newRecord(f.origin, rec.Name, rec.Type, ttlOr(rec.TTL, f.ttl), rec.Value) if err != nil { return err } kept := f.recs[:0] for _, have := range f.recs { if have.Key() == rec.Key() { continue } kept = append(kept, have) } f.recs = append(kept, rec) return nil }, &serial) return serial, err } // Delete removes one record, or the whole RRset when value is empty. It // reports whether anything was removed: "delete" on a name that is not there // is a stale console, not a server error. func (m *Managed) Delete(origin, name, rrtype, value string) (uint32, bool, error) { var serial uint32 var removed bool err := m.mutate(origin, ZoneDefaults{}, func(f *fileState) error { owner, err := ownerInZone(f.origin, name) if err != nil { return err } up := strings.ToUpper(strings.TrimSpace(rrtype)) if !isEditableType(up) { return fmt.Errorf("type %s cannot be written through the API", up) } // An empty value means "the whole RRset", so there is nothing to // parse. want := "" if value != "" { rec, err := newRecord(f.origin, owner, up, f.ttl, value) if err != nil { return err } want = rec.Value } key := owner + "|" + up kept := f.recs[:0] for _, have := range f.recs { hit := have.Key() == key && (want == "" || have.Value == want) if hit { removed = true continue } kept = append(kept, have) } f.recs = append([]Record(nil), kept...) if !removed { return fmt.Errorf("no managed record matches %s %s in %s", name, up, f.origin) } return nil }, &serial) if err != nil { return 0, false, err } return serial, removed, nil } // SetDelegation hands a name below the apex, and everything under it, to other // servers: it writes the NS RRset of that name. func (m *Managed) SetDelegation(origin, name string, targets []string, ttl uint32, def ZoneDefaults) (uint32, error) { if len(targets) == 0 { return 0, errors.New("a delegation needs at least one NS target") } cut, err := NormalizeName(name) if err != nil { return 0, fmt.Errorf("delegated name: %w", err) } if cut == strings.ToLower(dns.Fqdn(origin)) { return 0, errors.New("a delegation must be below the zone apex; the apex NS set is the zone's own server list") } if def.TTL == 0 { def.TTL = ttl } var serial uint32 err = m.mutate(origin, def, func(f *fileState) error { for _, target := range targets { t, err := newRecord(f.origin, cut, "NS", f.ttl, target) if err != nil { return fmt.Errorf("delegation target %q: %w", target, err) } // Replace one target at a time, so re-submitting the form does // not accumulate duplicates and a second target does not drop // the first. kept := make([]Record, 0, len(f.recs)+1) for _, have := range f.recs { if have.Key() == t.Key() && have.Value == t.Value { continue } kept = append(kept, have) } f.recs = append(kept, t) } return nil }, &serial) return serial, err } // RemoveDelegation deletes the NS RRset of a name, which is the whole cut. func (m *Managed) RemoveDelegation(origin, name string) (uint32, bool, error) { return m.Delete(origin, name, "NS", "") } // mutate is the whole write path: read, change, bump the serial, render, prove // the rendering parses back to the same records, then write atomically. func (m *Managed) mutate(origin string, def ZoneDefaults, fn func(*fileState) error, serial *uint32) error { if err := m.Check(); err != nil { return err } canonical, err := NormalizeOrigin(origin) if err != nil { return err } f, err := m.load(canonical) if err != nil { return err } if f == nil { f, err = newFileState(canonical, def) if err != nil { return err } } if err := fn(f); err != nil { return err } if err := f.finish(); err != nil { return err } return m.save(f, serial) } // fileState is one managed file's content in presentation form. type fileState struct { origin string ttl uint32 soa *dns.SOA recs []Record } // newFileState builds a file for an apex that has none: an existing zone's // SOA and NS set when the caller supplied them, else a synthesised SOA whose // primary is the first NS target. func newFileState(origin string, def ZoneDefaults) (*fileState, error) { ttl := def.TTL if ttl == 0 { ttl = DefaultZoneTTL } f := &fileState{origin: origin, ttl: ttl} if def.Soa != "" { rr, err := dns.NewRR(origin + " " + zonefile.FormatTTL(ttl) + " IN SOA " + def.Soa) if err != nil { return nil, fmt.Errorf("seed SOA: %w", err) } soa, ok := rr.(*dns.SOA) if !ok { return nil, fmt.Errorf("seed SOA is not an SOA record") } cp := *soa f.soa = &cp } for _, target := range def.NS { rec, err := newRecord(origin, origin, "NS", ttl, target) if err != nil { return nil, err } f.recs = append(f.recs, rec) } return f, nil } // finish is the invariant check that runs before anything touches the disk: a // zone with no SOA cannot answer NXDOMAIN or NODATA (the engine sends a NODATA // with no authority section instead), and a zone with no apex NS set delegates // nothing and has no server to name. func (f *fileState) finish() error { apex := make([]string, 0, 4) for _, rec := range f.recs { if rec.Name == f.origin && rec.Type == "NS" { apex = append(apex, rec.Value) } } if err := f.checkCuts(); err != nil { return err } if f.soa == nil { if len(apex) == 0 { return errors.New("a new zone needs an NS record at the apex, or a delegation to start from") } soa, err := synthSOA(f.origin, apex[0], serialFor(time.Now()), f.ttl) if err != nil { return err } f.soa = soa } // The SOA's primary follows the apex NS set. A zone whose SOA names a // server that is not in its own NS set is a zone that hands out a // referral no resolver can follow, and the console is what changes that // set, so the console has to keep the two together. sort.Strings(apex) if !slices.Contains(apex, f.soa.Ns) && len(apex) > 0 { f.soa.Ns = apex[0] } f.soa.Serial = nextSerial(f.soa.Serial) sortRecords(f.recs) return nil } // checkCuts refuses data that a delegation has taken away. A cut means the // names under it belong to another server: the store answers them as // delegated and never looks at a record that lives there, so a record written // below a cut is dead data that looks alive. The cut's own NS RRset is the // one thing the parent does serve (RFC 1034 §4.3.3). func (f *fileState) checkCuts() error { cuts := make([]string, 0, 4) for _, rec := range f.recs { if rec.Type == "NS" && rec.Name != f.origin { cuts = append(cuts, rec.Name) } } for _, cut := range cuts { for _, rec := range f.recs { if rec.Name == cut { continue } if strings.HasSuffix(rec.Name, "."+cut) { return fmt.Errorf("%s is delegated to another server; delete the delegation to put data back", rec.Name) } } } return nil } // render writes the file in the form an operator would have typed: a header, a // $ORIGIN/$TTL pair, the SOA, then one owner line per name with its records // indented under it. func (f *fileState) render() string { var sb strings.Builder sb.WriteString(FileHeader) fmt.Fprintf(&sb, "$ORIGIN %s\n", f.origin) fmt.Fprintf(&sb, "$TTL %s\n", zonefile.FormatTTL(f.ttl)) soa := f.soa fmt.Fprintf(&sb, "@\t%s IN SOA\t%s %s %d %d %d %d %d\n", zonefile.FormatTTL(soa.Hdr.Ttl), soa.Ns, soa.Mbox, soa.Serial, soa.Refresh, soa.Retry, soa.Expire, soa.Minttl) byName := make(map[string][]Record) for _, rec := range f.recs { byName[rec.Name] = append(byName[rec.Name], rec) } // One record per line, not the owner-then-indented-records form: this // project's master-file parser takes a bare owner line as a record with // no type, and a zone file only loads if its own parser accepts it. for _, name := range sortedNames(byName) { for _, rec := range byName[name] { ttl := rec.TTL if ttl == 0 { ttl = f.ttl } fmt.Fprintf(&sb, "%-28s %s IN %-5s %s\n", relativeOwner(name, f.origin), zonefile.FormatTTL(ttl), rec.Type, rec.Value) } } return sb.String() } func sortedNames(byName map[string][]Record) []string { out := make([]string, 0, len(byName)) for name := range byName { out = append(out, name) } sort.Strings(out) return out } // save writes the file atomically, and only after proving that what it // rendered is what a parse of that text produces. A writer bug would otherwise // show up as a zone that silently stopped answering. func (m *Managed) save(f *fileState, serial *uint32) error { text := f.render() check, err := parseFileState(f.origin, text) if err != nil { return fmt.Errorf("the zone file for %s did not render correctly: %w", f.origin, err) } if err := sameRecords(check.recs, f.recs); err != nil { return fmt.Errorf("the zone file for %s did not render correctly: %w", f.origin, err) } if err := os.MkdirAll(m.dir, 0o755); err != nil { return fmt.Errorf("managed zone directory %s: %w", m.dir, err) } path := m.Path(f.origin) tmp, err := os.CreateTemp(m.dir, ".fancy-dns-zone-*") if err != nil { return fmt.Errorf("managed zone directory %s: %w", m.dir, err) } name := tmp.Name() defer func() { // A rename that never happened must not leave a stray temp file. _ = os.Remove(name) }() if _, err := tmp.WriteString(text); err != nil { _ = tmp.Close() return err } if err := tmp.Sync(); err != nil { _ = tmp.Close() return err } if err := tmp.Close(); err != nil { return err } if err := os.Chmod(name, 0o644); err != nil { return err } if err := os.Rename(name, path); err != nil { return err } if serial != nil && f.soa != nil { *serial = f.soa.Serial } return nil } // load reads one apex's managed file. A missing file is (nil, nil): the zone // has no console-managed data yet, which is the normal state. func (m *Managed) load(origin string) (*fileState, error) { path := m.Path(origin) f, err := os.Open(path) if err != nil { if os.IsNotExist(err) { return nil, nil } return nil, err } defer f.Close() z, err := zonefile.Parse(f, zonefile.Options{Origin: origin, File: path, BaseDir: m.dir}) if err != nil { return nil, err } if strings.ToLower(z.Origin) != origin { return nil, fmt.Errorf("%s holds zone %s, not %s", path, z.Origin, origin) } return fileStateFromZone(z, origin), nil } // ManagedRecords reads the records the console owns for one apex, which is the // subset the API may rewrite. func (m *Managed) ManagedRecords(origin string) ([]Record, error) { f, err := m.load(origin) if err != nil || f == nil { return nil, err } return append([]Record(nil), f.recs...), nil } // fileStateFromZone converts a parsed master file into presentation form. func fileStateFromZone(z *zonefile.Zone, origin string) *fileState { ttl := z.DefaultTTL if ttl == 0 { ttl = DefaultZoneTTL } f := &fileState{origin: origin, ttl: ttl} if z.SOA != nil { cp := *z.SOA f.soa = &cp } for _, rec := range z.Records { if _, isSOA := rec.RR.(*dns.SOA); isSOA { continue } rrttl := rec.TTL if rrttl == 0 { rrttl = ttl } f.recs = append(f.recs, Record{ Name: rec.Name, Type: dns.TypeToString[rec.RR.Header().Rrtype], TTL: rrttl, Value: rdata(rec.RR), Wildcard: strings.HasPrefix(rec.Name, "*."), }) } return f } // parseFileState is the reader half of the round-trip check: it parses text // the writer produced and returns it in the same form the writer held it. func parseFileState(origin, text string) (*fileState, error) { z, err := zonefile.Parse(strings.NewReader(text), zonefile.Options{Origin: origin, File: ""}) if err != nil { return nil, err } return fileStateFromZone(z, origin), nil } // sameRecords compares the records a rendered file parses back to with the // ones the writer meant to write. A round-trip that loses a record, changes a // TTL or mangles a name is a writer bug, and it is caught here rather than by // an operator noticing a zone that stopped answering. func sameRecords(got, want []Record) error { if len(got) != len(want) { return fmt.Errorf("rendered %d records, expected %d", len(got), len(want)) } for i := range want { if got[i] != want[i] { return fmt.Errorf("rendered %+v, expected %+v", got[i], want[i]) } } return nil } // newRecord validates one API record and returns it in canonical form: the // owner fully qualified, the type uppercased, the RDATA as dns.NewRR parses // it. Canonicalising at the edge means the store snapshot, the console and // the file always agree on spelling. func newRecord(origin, name, rrtype string, ttl uint32, value string) (Record, error) { owner, err := ownerInZone(origin, name) if err != nil { return Record{}, err } up := strings.ToUpper(strings.TrimSpace(rrtype)) if up == "" { return Record{}, errors.New("record type is required") } if !isEditableType(up) { return Record{}, fmt.Errorf("type %s cannot be written through the API", up) } value = strings.TrimSpace(value) if value == "" { return Record{}, fmt.Errorf("the %s record needs a value", up) } line := owner + " " + zonefile.FormatTTL(ttl) + " IN " + up + " " + value // The project's own master-file parser is the authority on what a record // line means: it qualifies an RDATA name written without a trailing dot // against the origin, which is what an operator typing "ns1" for a CNAME // in a zone file gets. Parsing here rather than with dns.NewRR is what // keeps the API's idea of a value and the store's the same value. z, err := zonefile.Parse(strings.NewReader(line), zonefile.Options{Origin: origin, File: ""}) if err != nil { return Record{}, fmt.Errorf("%s %s: %w", owner, up, err) } if len(z.Records) != 1 { return Record{}, fmt.Errorf("%s %s: the line parsed into %d records", owner, up, len(z.Records)) } parsed := z.Records[0] if _, isSOA := parsed.RR.(*dns.SOA); isSOA { return Record{}, errors.New("SOA is zone metadata, not a record the API writes") } if !inZone(parsed.Name, origin) { return Record{}, fmt.Errorf("%s is outside the zone %s", parsed.Name, origin) } return Record{ Name: parsed.Name, Type: up, TTL: parsed.TTL, Value: rdata(parsed.RR), Wildcard: strings.HasPrefix(parsed.Name, "*."), }, nil } // ttlOr picks the record's own TTL, falling back to the zone's. func ttlOr(rec, zone uint32) uint32 { if rec != 0 { return rec } return zone } // ownerInZone canonicalises an owner name the way a zone file would: a name // written without a trailing dot belongs to the zone it is in, so "www" in // home.example.com is www.home.example.com. — the console asks for a short // name and a relative one is the common case, not an error. func ownerInZone(origin, name string) (string, error) { trimmed := strings.TrimSpace(name) owner, err := NormalizeName(trimmed) if err != nil { return "", fmt.Errorf("owner name: %w", err) } if !strings.HasSuffix(trimmed, ".") { // A relative name: qualify it, unless it already spells the apex or // something under it (which is what an operator pasting a name out of // the table above does). bare := strings.TrimSuffix(strings.TrimPrefix(owner, "*."), ".") base := strings.TrimSuffix(origin, ".") if bare != base && !strings.HasSuffix(bare, "."+base) { owner = dns.Fqdn(bare + "." + base) if strings.HasPrefix(strings.TrimSpace(name), "*.") { owner = "*." + owner } } } if !inZone(owner, origin) { return "", fmt.Errorf("%s is outside the zone %s", owner, origin) } return owner, nil } // NormalizeOrigin canonicalises a zone apex and refuses the ones the writer // cannot round-trip. func NormalizeOrigin(in string) (string, error) { out := strings.ToLower(strings.TrimSpace(dns.Fqdn(strings.TrimSpace(in)))) if out == "." { return "", errors.New("the root zone is not editable") } if out == "" || !strings.Contains(strings.TrimSuffix(out, "."), ".") { return "", fmt.Errorf("%q is not a zone apex", in) } for _, label := range strings.Split(strings.TrimSuffix(out, "."), ".") { if label == "" { return "", fmt.Errorf("%q is not a zone apex", in) } } return out, nil } // NormalizeName canonicalises an owner name, allowing a leftmost "*." wildcard. func NormalizeName(in string) (string, error) { s := strings.ToLower(strings.TrimSpace(in)) if s == "" || s == "." { return "", errors.New("owner name is required") } wild := strings.HasPrefix(s, "*.") s = strings.TrimPrefix(s, "*.") full := strings.ToLower(dns.Fqdn(s)) for _, label := range strings.Split(strings.TrimSuffix(full, "."), ".") { if label == "" { return "", fmt.Errorf("%q is not a domain name", in) } } if wild { full = "*." + full } return full, nil } // relativeOwner renders an owner relative to the origin, the way a zone file // is written. func relativeOwner(name, origin string) string { if name == origin { return "@" } suffixed := strings.TrimSuffix(name, ".") originSuffix := strings.TrimSuffix(origin, ".") if suffixed == originSuffix { return "@" } if len(suffixed) > len(originSuffix) && strings.HasSuffix(suffixed, "."+originSuffix) { return suffixed[:len(suffixed)-len(originSuffix)-1] } return name } // zoneFileName maps an apex to its managed file. func zoneFileName(origin string) string { return strings.TrimSuffix(strings.ToLower(origin), ".") + ".zone" } // synthSOA builds a zone's start of authority. func synthSOA(origin, primary string, serial, ttl uint32) (*dns.SOA, error) { if primary == "" { return nil, errors.New("an SOA needs a primary nameserver") } if !strings.HasSuffix(primary, ".") { primary = dns.Fqdn(primary) } rr, err := dns.NewRR(fmt.Sprintf("%s %d IN SOA %s hostmaster.%s %d 3600 600 604800 %d", origin, ttl, primary, origin, serial, ttl)) if err != nil { return nil, err } soa, ok := rr.(*dns.SOA) if !ok { return nil, errors.New("built SOA did not parse") } return soa, nil } // nextSerial advances a serial by one. A zone with no serial yet gets a // date-based one, which is what an operator's first edit expects to see. func nextSerial(current uint32) uint32 { if current == 0 { return serialFor(time.Now()) } if next, err := zonefile.SerialNext(current, "+1"); err == nil { return next } return current } // serialFor is the YYYYMMDDnn form RFC 1912 §2.2.2 recommends. func serialFor(t time.Time) uint32 { day, err := strconv.ParseUint(t.UTC().Format("20060102"), 10, 32) if err != nil { return 1 } return uint32(day) }