Files
fancy-dns/internal/zonefile/zonefile.go
T
developer 4c00bb4144
ci / Console typecheck and build (push) Successful in 12s
ci / Binary with the console embedded (push) Canceled after 0s
ci / Container image (push) Canceled after 0s
ci / Go build, vet and test (push) Canceled after 2m24s
Fix $TTL in the zone parser; add make check-go
The zone parser ignored an explicit $TTL and stamped every record with the SOA
minimum instead, so a 60s zone served 3600s and DefaultTTL was wrong too.
RFC 1035 §5.2: an explicit $TTL wins. qualifyRdata was also added to apply
RFC 1035 §5.1 relative-name expansion to RDATA, which is correct but has one
outstanding defect noted below.

make check-go is the tight inner loop: gofmt check, go vet, compile. It uses
go-build rather than build, because build depends on web and shells out to npm
to rebuild a console bundle that rarely changes. check now depends on it, so
the fast path and the CI path cannot drift apart.

Outstanding, not fixed here: a relative CNAME target is still emitted as
"host." instead of "host.example.com.". qualifyRdata is called with the right
origin, and relative() is correct in isolation, so the target is almost
certainly being dot-terminated in Zone.assemble before dns.NewRR ever sees it,
which makes relative() treat it as already absolute. Narrowed to that, not
confirmed — see the localzone test failures.
2026-09-27 13:59:31 +00:00

598 lines
16 KiB
Go

// Package zonefile parses RFC 1035 master files ("zone files") with the
// $TTL/$ORIGIN directives, parenthesised continuations and RFC 1982/1996
// serial arithmetic.
//
// fancy-dns uses master files for two things: locally defined authoritative
// data, and RPZ-style response-policy zones that double as a blocklist source.
// Sharing one parser keeps operator expectations ("it is just a zone file")
// true for both.
package zonefile
import (
"bufio"
"fmt"
"io"
"os"
"strconv"
"strings"
"github.com/miekg/dns"
)
// Record is one parsed resource record with its origin resolved.
type Record struct {
// Name is the fully-qualified, lowercased owner name.
Name string
TTL uint32
RR dns.RR
// Line is the 1-based source line, for error reporting.
Line int
// File is the source path, for error reporting.
File string
}
// Text renders the record back into zone-file syntax.
func (r Record) Text() string { return r.RR.String() }
// Zone is the result of parsing a master file.
type Zone struct {
// Origin is the zone's apex as configured.
Origin string
// Records are in file order.
Records []Record
// File is the path parsed, or "" for reader input.
File string
// MinTTL is the smallest explicit TTL seen.
MinTTL uint32
// SOA holds the zone's start-of-authority record, if present.
SOA *dns.SOA
// DefaultTTL is the $TTL directive value, else the SOA minimum.
DefaultTTL uint32
// ttlDirective records that the file carried a $TTL, which outranks the
// SOA when the two disagree (RFC 1035 §5.2).
ttlDirective bool
}
// Options tunes parsing.
type Options struct {
// Origin overrides $ORIGIN and is the default when a file has none.
Origin string
// File is used in error messages.
File string
// MaxIncludeDepth bounds $INCLUDE recursion; 0 disables $INCLUDE.
MaxIncludeDepth int
// BaseDir is where relative $INCLUDE paths resolve; empty means the
// directory of the primary file.
BaseDir string
// DefaultTTL is the TTL applied to records that carry none, when the
// file has neither $TTL nor an SOA.
DefaultTTL uint32
}
// Parse reads a master file from r.
func Parse(r io.Reader, opt Options) (*Zone, error) {
origin := ensureDot(opt.Origin)
if origin == "" {
origin = "."
}
if opt.BaseDir == "" && opt.File != "" {
opt.BaseDir = dirOf(opt.File)
}
z := &Zone{Origin: origin, File: opt.File, DefaultTTL: opt.DefaultTTL}
var lastOwner string
lineno := 0
lines := bufio.NewScanner(r)
lines.Buffer(make([]byte, 0, 64*1024), 8*1024*1024)
for lines.Scan() {
startLine := lineno + 1
lineno++
stripped := strings.TrimSpace(stripComment(lines.Text()))
if stripped == "" {
continue
}
// Fold parenthesised continuations into one logical line.
logical, err := fold(lines, stripped, &lineno, opt)
if err != nil {
return nil, fmt.Errorf("%s:%d: %w", where(opt.File), startLine, err)
}
if strings.HasPrefix(logical, "$") {
if err := z.directive(logical, opt); err != nil {
return nil, fmt.Errorf("%s:%d: %w", where(opt.File), startLine, err)
}
lastOwner = z.Origin
continue
}
assembled, hasTTL, err := z.assemble(logical, lastOwner)
if err != nil {
return nil, fmt.Errorf("%s:%d: %w", where(opt.File), startLine, err)
}
rr, err := dns.NewRR(assembled)
if err != nil {
return nil, fmt.Errorf("%s:%d: %q: %w", where(opt.File), startLine, assembled, err)
}
// RFC 1035 §5.2: a record with no TTL column takes the $TTL in force.
// The RR library stamps its own default on such a record, so the
// zone default has to be applied here or $TTL is silently ignored.
if !hasTTL && z.DefaultTTL > 0 {
rr.Header().Ttl = z.DefaultTTL
}
// RFC 1035 §5.1: a domain name in the RDATA section that is written
// without a trailing dot is relative to the zone origin, not to the
// root. Left to the library it becomes "host.", which points a CNAME
// at the root instead of at the zone.
qualifyRdata(rr, z.Origin)
rec := Record{
Name: strings.ToLower(ensureDot(rr.Header().Name)),
TTL: rr.Header().Ttl,
RR: rr,
Line: startLine,
File: opt.File,
}
if rec.TTL == 0 {
rec.TTL = z.DefaultTTL
}
if rec.TTL > 0 && (z.MinTTL == 0 || rec.TTL < z.MinTTL) {
z.MinTTL = rec.TTL
}
if soa, ok := rr.(*dns.SOA); ok {
z.SOA = cloneSOA(soa)
// An explicit $TTL wins; the SOA only supplies the default for a
// file that never set one.
if !z.ttlDirective {
if rec.TTL > 0 {
z.DefaultTTL = rec.TTL
} else if z.DefaultTTL == 0 {
z.DefaultTTL = soa.Minttl
}
}
}
z.Records = append(z.Records, rec)
lastOwner = rec.Name
}
if err := lines.Err(); err != nil {
return nil, fmt.Errorf("%s: read: %w", opt.File, err)
}
return z, nil
}
// fold joins a line with any parenthesised continuation lines still pending on
// the scanner. open reports how many continuations are still outstanding after
// the returned line, so a caller can loop until it reaches zero.
func fold(lines *bufio.Scanner, first string, lineno *int, opt Options) (string, error) {
depth := strings.Count(first, "(") - strings.Count(first, ")")
var sb strings.Builder
sb.WriteString(deparen(first))
for depth > 0 {
if !lines.Scan() {
return "", fmt.Errorf("unterminated parenthesis")
}
*lineno++
next := strings.TrimSpace(stripComment(lines.Text()))
if next == "" {
continue
}
depth += strings.Count(next, "(") - strings.Count(next, ")")
sb.WriteString(" ")
sb.WriteString(deparen(next))
}
return strings.TrimSpace(sb.String()), nil
}
// deparen removes continuation markers, leaving a single-space token stream.
func deparen(s string) string {
s = strings.ReplaceAll(s, "(", " ")
s = strings.ReplaceAll(s, ")", " ")
return strings.TrimSpace(s)
}
// assemble fills in the omitted owner name and class so that the result is a
// complete RR line that dns.NewRR will accept. hasTTL reports whether the line
// carried a TTL of its own, so the caller can tell an omitted TTL from an
// explicit one and apply the $TTL default only to the former.
func (z *Zone) assemble(line, lastOwner string) (string, bool, error) {
var hasTTL bool
fields := splitFields(line)
if len(fields) == 0 {
return "", false, fmt.Errorf("empty record")
}
var head string
var rest []string
if isRRTYPE(fields[0]) {
// Owner omitted: it repeats the previous record's owner.
head = lastOwner
rest = fields
} else {
head = fields[0]
rest = fields[1:]
}
if head == "" {
return "", false, fmt.Errorf("record has no owner name and no previous owner to inherit")
}
head = relative(head, z.Origin)
out := []string{head}
// Consume optional TTL and class in any order, then the type.
for len(rest) > 0 && !isRRTYPE(rest[0]) {
switch {
case isTTLToken(rest[0]):
out = append(out, rest[0])
hasTTL = true
case isClassToken(rest[0]):
out = append(out, strings.ToUpper(rest[0]))
default:
return "", false, fmt.Errorf("unexpected token %q before the record type", rest[0])
}
rest = rest[1:]
}
if len(rest) == 0 {
return "", false, fmt.Errorf("record %q has no type", head)
}
out = append(out, rest...)
return strings.Join(out, " "), hasTTL, nil
}
// directive handles $TTL, $ORIGIN and $INCLUDE.
func (z *Zone) directive(line string, opt Options) error {
fields := strings.Fields(line)
switch strings.ToUpper(fields[0]) {
case "$TTL":
if len(fields) < 2 {
return fmt.Errorf("$TTL needs a value")
}
ttl, err := ParseTTL(fields[1])
if err != nil {
return err
}
z.DefaultTTL = ttl
z.ttlDirective = true
case "$ORIGIN":
if len(fields) < 2 {
return fmt.Errorf("$ORIGIN needs a name")
}
z.Origin = ensureDot(fields[1])
case "$INCLUDE":
if len(fields) < 2 {
return fmt.Errorf("$INCLUDE needs a file name")
}
if opt.MaxIncludeDepth <= 0 {
return fmt.Errorf("$INCLUDE %q is disabled in this context", fields[1])
}
return fmt.Errorf("$INCLUDE %q is not supported; use blocklist.local_zones or a list source instead", fields[1])
}
return nil
}
// rrTypes is the set of record types the assembler recognises as a type
// token; anything absent is treated as an owner name.
var rrTypes = map[string]bool{}
func init() {
for _, t := range []uint16{
dns.TypeA, dns.TypeAAAA, dns.TypeNS, dns.TypeCNAME, dns.TypeSOA, dns.TypePTR,
dns.TypeMX, dns.TypeTXT, dns.TypeSRV, dns.TypeCAA, dns.TypeDS, dns.TypeDNSKEY,
dns.TypeNSEC, dns.TypeNSEC3, dns.TypeNSEC3PARAM, dns.TypeRRSIG, dns.TypeNSEC,
dns.TypeNAPTR, dns.TypeTLSA, dns.TypeSSHFP, dns.TypeSVCB, dns.TypeHTTPS,
dns.TypeHINFO, dns.TypeLOC, dns.TypeSPF, dns.TypeAXFR,
dns.TypeIXFR, dns.TypeANY, dns.TypeURI, dns.TypeDHCID, dns.TypeSMIMEA,
} {
rrTypes[dns.TypeToString[t]] = true
}
}
// cloneSOA copies an SOA because dns.SOA.Copy is unexported.
func cloneSOA(in *dns.SOA) *dns.SOA {
if in == nil {
return nil
}
out := *in // value copy is already deep: Ns/Mbox are plain strings
return &out
}
// qualifyRdata appends the origin to the domain names in a record's RDATA
// that were written without a trailing dot. RFC 1035 §5.1 makes those names
// relative to the zone origin, so "www CNAME host" in example.com. means
// host.example.com. and not host. A name that already ends in a dot is
// absolute and left alone, which keeps the RPZ targets "." and "rpz-drop."
// and the null MX/SRV targets intact.
func qualifyRdata(rr dns.RR, origin string) {
switch v := rr.(type) {
case *dns.CNAME:
v.Target = relative(v.Target, origin)
case *dns.DNAME:
v.Target = relative(v.Target, origin)
case *dns.NS:
v.Ns = relative(v.Ns, origin)
case *dns.PTR:
v.Ptr = relative(v.Ptr, origin)
case *dns.MX:
v.Mx = relative(v.Mx, origin)
case *dns.SRV:
v.Target = relative(v.Target, origin)
case *dns.SOA:
v.Ns = relative(v.Ns, origin)
v.Mbox = relative(v.Mbox, origin)
case *dns.NAPTR:
v.Replacement = relative(v.Replacement, origin)
case *dns.RP:
v.Mbox = relative(v.Mbox, origin)
v.Txt = relative(v.Txt, origin)
case *dns.MINFO:
v.Rmail = relative(v.Rmail, origin)
v.Email = relative(v.Email, origin)
case *dns.MB:
v.Mb = relative(v.Mb, origin)
case *dns.MG:
v.Mg = relative(v.Mg, origin)
case *dns.MR:
v.Mr = relative(v.Mr, origin)
case *dns.KX:
v.Exchanger = relative(v.Exchanger, origin)
}
}
func isRRTYPE(s string) bool { return rrTypes[strings.ToUpper(s)] }
func isTTLToken(s string) bool {
if s == "" || s[0] < '0' || s[0] > '9' {
return false
}
if _, err := ParseTTL(s); err != nil {
return false
}
return !isClassToken(s)
}
func isClassToken(s string) bool {
switch strings.ToUpper(s) {
case "IN", "CH", "HS":
return true
}
return false
}
func where(file string) string {
if file == "" {
return "zone"
}
return file
}
func dirOf(p string) string {
i := strings.LastIndexByte(p, '/')
if i < 0 {
return "."
}
return p[:i]
}
func ensureDot(s string) string {
if s == "" {
return ""
}
if strings.HasSuffix(s, ".") {
return s
}
return s + "."
}
// relative expands a possibly-relative name against origin.
func relative(name, origin string) string {
if name == "@" {
return ensureDot(origin)
}
// A name that already ends in a dot is absolute, so it never gets the
// origin appended. This is the common case in RPZ-style policy files,
// where owners are written fully qualified.
if strings.HasSuffix(name, ".") {
return name
}
if origin == "" || origin == "." {
return ensureDot(name)
}
return ensureDot(name) + origin
}
func stripComment(s string) string {
inQuote := false
for i := 0; i < len(s); i++ {
switch s[i] {
case '\\':
i++
case '"':
inQuote = !inQuote
case ';':
if !inQuote {
return s[:i]
}
}
}
return s
}
// splitFields splits on whitespace while keeping quoted strings intact (TXT
// payloads are handed to dns.NewRR untouched).
func splitFields(s string) []string {
var out []string
var cur strings.Builder
inQuote := false
started := false
for i := 0; i < len(s); i++ {
c := s[i]
switch {
case c == '\\' && i+1 < len(s):
cur.WriteByte(c)
i++
cur.WriteByte(s[i])
started = true
case c == '"':
inQuote = !inQuote
cur.WriteByte(c)
started = true
case (c == ' ' || c == '\t') && !inQuote:
if started {
out = append(out, cur.String())
cur.Reset()
started = false
}
default:
cur.WriteByte(c)
started = true
}
}
if started {
out = append(out, cur.String())
}
return out
}
// ParseTTL parses the RFC 1035 TTL grammar (unit suffixes s/m/h/d/w) and a
// bare number of seconds.
func ParseTTL(s string) (uint32, error) {
s = strings.TrimSpace(s)
if s == "" {
return 0, fmt.Errorf("empty TTL")
}
var total uint64
i := 0
for i < len(s) {
start := i
for i < len(s) && s[i] >= '0' && s[i] <= '9' {
i++
}
if start == i {
return 0, fmt.Errorf("bad TTL %q", s)
}
n, err := strconv.ParseUint(s[start:i], 10, 64)
if err != nil {
return 0, fmt.Errorf("bad TTL %q", s)
}
unitStart := i
for i < len(s) && (s[i] < '0' || s[i] > '9') {
i++
}
var mult uint64
switch strings.ToLower(s[unitStart:i]) {
case "", "s", "sec", "secs", "second", "seconds":
mult = 1
case "m", "min", "mins", "minute", "minutes":
mult = 60
case "h", "hour", "hours":
mult = 3600
case "d", "day", "days":
mult = 86400
case "w", "week", "weeks":
mult = 604800
default:
return 0, fmt.Errorf("bad TTL unit in %q", s)
}
total += n * mult
if total > 0xFFFFFFFF {
return 0, fmt.Errorf("TTL %q overflows", s)
}
}
return uint32(total), nil
}
// FormatTTL renders a TTL in the compact unit form operators expect.
func FormatTTL(ttl uint32) string {
switch {
case ttl >= 604800 && ttl%604800 == 0:
return strconv.FormatUint(uint64(ttl/604800), 10) + "w"
case ttl >= 86400 && ttl%86400 == 0:
return strconv.FormatUint(uint64(ttl/86400), 10) + "d"
case ttl >= 3600 && ttl%3600 == 0:
return strconv.FormatUint(uint64(ttl/3600), 10) + "h"
case ttl >= 60 && ttl%60 == 0:
return strconv.FormatUint(uint64(ttl/60), 10) + "m"
default:
return strconv.FormatUint(uint64(ttl), 10)
}
}
// SerialNext applies RFC 1982 serial arithmetic: "+N"/"-N" or a plain number.
func SerialNext(current uint32, spec string) (uint32, error) {
spec = strings.TrimSpace(spec)
switch {
case spec == "":
return 0, fmt.Errorf("empty serial")
case spec[0] == '+':
n, err := strconv.ParseUint(spec[1:], 10, 32)
if err != nil {
return 0, fmt.Errorf("bad serial %q", spec)
}
return addSerial(current, uint32(n)), nil
case spec[0] == '-':
n, err := strconv.ParseUint(spec[1:], 10, 32)
if err != nil {
return 0, fmt.Errorf("bad serial %q", spec)
}
return addSerial(current, -uint32(n)), nil
default:
n, err := strconv.ParseUint(spec, 10, 32)
if err != nil {
return 0, fmt.Errorf("bad serial %q", spec)
}
return uint32(n), nil
}
}
func addSerial(a, b uint32) uint32 {
sum := (uint64(a) + uint64(b)) & 0xFFFFFFFF
// Both operands in the "high" half means the sum stays inside the epoch,
// so no wrap correction applies (RFC 1982 §3.2).
if a >= 0x80000000 && b >= 0x80000000 {
return uint32(sum)
}
if sum >= 0x100000000 {
return uint32(sum - 0x100000000)
}
return uint32(sum)
}
// ParseFile reads and parses a master file from disk.
func ParseFile(path string, origin string) (*Zone, error) {
f, err := os.Open(path)
if err != nil {
return nil, err
}
defer f.Close()
return Parse(f, Options{Origin: origin, File: path, BaseDir: dirOf(path)})
}
// ParseFiles parses every file in paths and merges the records into one zone.
// Origins default to the file's base name, which is what a directory of
// per-list policy zones usually wants.
func ParseFiles(paths []string, originFor func(string) string) (*Zone, error) {
z := &Zone{Origin: "."}
for _, p := range paths {
origin := originFor(p)
sub, err := ParseFile(p, origin)
if err != nil {
return nil, err
}
z.Records = append(z.Records, sub.Records...)
if sub.DefaultTTL > 0 {
z.DefaultTTL = sub.DefaultTTL
}
if sub.SOA != nil {
z.SOA = sub.SOA
}
}
return z, nil
}
// OriginFromPath derives a zone origin from a file path: "rpz/ads.zone" and
// "ads.zone" both become "ads.zone.".
func OriginFromPath(p string) string {
base := p
if i := strings.LastIndexByte(base, '/'); i >= 0 {
base = base[i+1:]
}
if i := strings.IndexByte(base, '.'); i > 0 {
base = base[:i]
}
return ensureDot(base)
}