initial commit
This commit is contained in:
1 parent
5b935301e5
commit
85453852d5
105 files changed
+24846
No files matched your search
@@ -0,0 +1,532 @@
|
||||
// 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)
|
||||
}
|
||||
Reference in new issue
Block a user