Zones, address redirects, inline rules, lists and groups are cluster objects now (see the previous commits and deploy/MIGRATION.md), so the mechanisms that held them are gone: * internal/kubezones and the resolver's service-account access to a ConfigMap, the address-overrides read/write path and its poller, the zone-file poller and the Local zones editor with its /api/local-zones routes. * localzone.Managed, the directory of console-written master files, and the blocklist.managed_zones key. The resolver now writes no zone data at all; its zone hooks only list. The shared half of that file (the record validator, the renderer and the round-trip check) stays as zonestate.go, because DNSZone rendering is built on it. Its tests are ported to RenderZone, which keeps the coverage of every editable record type, wildcards, relative owners, the SOA/NS rule and the delegation rule. * Tests of the removed write paths: the contract test now builds its zone from a DNSZone, and a new test pins that every zone write route on the resolver is refused and changes nothing. Standalone installs keep editing master files on disk and SIGHUP, and the YAML-backed domain, client, upstream and settings editors, which have not moved. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
452 lines
14 KiB
Go
452 lines
14 KiB
Go
package localzone
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"slices"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/miekg/dns"
|
|
|
|
"git.k8s-git-k6n.net/mats/fancy-dns/internal/zonefile"
|
|
)
|
|
|
|
// DefaultZoneTTL is the $TTL a rendered zone gets when its spec 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
|
|
|
|
// 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
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// 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(ZoneSourceHeader)
|
|
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
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// 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)
|
|
}
|