Files
fancy-dns/internal/localzone/managed.go
T
developer 504512f247 Add a Zones console: create and manage local zones, records and delegations
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.
2026-09-28 07:45:16 +00:00

834 lines
25 KiB
Go

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: "<managed>"})
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: "<api>"})
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)
}