Files
fancy-dns/internal/zonefile/zonefile.go
T
2026-09-27 11:20:34 +00:00

533 lines
14 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
}
// 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, 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)
}
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)
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, TTL and class so that the result
// is a complete RR line that dns.NewRR will accept.
func (z *Zone) assemble(line, lastOwner string) (string, error) {
fields := splitFields(line)
if len(fields) == 0 {
return "", 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 "", 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])
case isClassToken(rest[0]):
out = append(out, strings.ToUpper(rest[0]))
default:
return "", fmt.Errorf("unexpected token %q before the record type", rest[0])
}
rest = rest[1:]
}
if len(rest) == 0 {
return "", fmt.Errorf("record %q has no type", head)
}
out = append(out, rest...)
return strings.Join(out, " "), 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
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
}
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)
}