// 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) }