533 lines
14 KiB
Go
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)
|
|
}
|