build / build-push (push) Successful in 29s
Local zones were only editable as mounted files, so operators could not manage DNS entries from the console. Add a Local zones editor backed by a shared Kubernetes ConfigMap, validate master files before saving, scope the pod service account to that ConfigMap, and reload changed zones on every replica while flushing stale cache entries.
599 lines
17 KiB
Go
599 lines
17 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
|
|
}
|
|
// assemble has already qualified the RDATA names that were written
|
|
// relative to the origin, so the record holds no relative name.
|
|
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)
|
|
}
|
|
// 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. It has to be qualified here, while it is still a token: the RR
|
|
// library resolves RDATA names against the root, so once dns.NewRR has
|
|
// run, "host" and "host." are indistinguishable and the origin would be
|
|
// lost.
|
|
qualifyRdata(rest[1:], rest[0], z.Origin)
|
|
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
|
|
}
|
|
|
|
// rdataNameIndex maps an RR type to the positions of the domain names in its
|
|
// presentation-format RDATA, counted from the first token after the type. The
|
|
// position of a name follows from the type's field layout, so a relative name
|
|
// can be qualified while the line is still a token stream. A type that carries
|
|
// no name in its RDATA is absent, and so is a type assemble does not
|
|
// recognise, since such a record never reaches this point.
|
|
var rdataNameIndex = map[string][]int{
|
|
"CNAME": {0},
|
|
"HTTPS": {1}, // SvcPriority, TargetName, SvcParams...
|
|
"MX": {1}, // Preference, Exchange
|
|
"NAPTR": {5}, // Order, Preference, Flags, Service, Regexp, Replacement
|
|
"NS": {0},
|
|
"PTR": {0},
|
|
"SOA": {0, 1}, // MName, RName
|
|
"SRV": {3}, // Priority, Weight, Port, Target
|
|
"SVCB": {1},
|
|
}
|
|
|
|
// qualifyRdata appends the origin to the domain names in a record's RDATA that
|
|
// were written without a trailing dot: "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. rdata is the token slice after the type
|
|
// and is qualified in place.
|
|
func qualifyRdata(rdata []string, rrtype, origin string) {
|
|
for _, i := range rdataNameIndex[strings.ToUpper(rrtype)] {
|
|
if i < len(rdata) {
|
|
rdata[i] = relative(rdata[i], 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. Legacy names such as
|
|
// "rpz/ads.zone" and "ads.zone" become "ads."; UI-managed names use the
|
|
// "zone--" marker so "zone--example.com.zone" becomes "example.com.".
|
|
func OriginFromPath(p string) string {
|
|
base := p
|
|
if i := strings.LastIndexByte(base, '/'); i >= 0 {
|
|
base = base[i+1:]
|
|
}
|
|
// The UI stores fully-qualified zone names with a marker so they do not
|
|
// collide with the legacy filename convention where "local.example.zone"
|
|
// means the "local." zone.
|
|
if strings.HasPrefix(base, "zone--") && strings.HasSuffix(base, ".zone") {
|
|
return ensureDot(strings.TrimSuffix(strings.TrimPrefix(base, "zone--"), ".zone"))
|
|
}
|
|
if i := strings.IndexByte(base, '.'); i > 0 {
|
|
base = base[:i]
|
|
}
|
|
return ensureDot(base)
|
|
}
|