Files
vibedns/internal/zonefile/zonefile.go
T
2026-08-16 21:18:45 -05:00

372 lines
9.6 KiB
Go

// Package zonefile converts between BIND-format zone files and the record
// rows stored in SQLite.
//
// Parsing is delegated to the DNS library's zone parser, so $ORIGIN, $TTL,
// $INCLUDE-free multi-line records, comments and every record type it knows
// are handled exactly as a real name server would handle them.
package zonefile
import (
"bufio"
"fmt"
"io"
"sort"
"strings"
"time"
"github.com/miekg/dns"
"github.com/owen/vibedns/internal/models"
"github.com/owen/vibedns/internal/validate"
)
// ParseSummary reports what an import contained.
type ParseSummary struct {
RecordsParsed int `json:"records_parsed"`
Skipped int `json:"skipped"`
SOAFound bool `json:"soa_found"`
OutOfZone int `json:"out_of_zone"`
Warnings []string `json:"warnings,omitempty"`
}
// ParseResult is the outcome of reading a zone file.
type ParseResult struct {
Records []models.Record
SOA *dns.SOA
Summary ParseSummary
}
// maxWarnings caps how much detail a badly formed file can generate.
const maxWarnings = 25
// Parse reads a zone file and converts it into storable records.
//
// origin must be a normalised FQDN. Records outside the origin are reported
// and skipped rather than silently dropped, because importing them would
// create data the server can never serve.
func Parse(r io.Reader, origin string, defaultTTL uint32) (*ParseResult, error) {
origin = strings.ToLower(dns.Fqdn(origin))
if defaultTTL == 0 {
defaultTTL = 3600
}
res := &ParseResult{}
zp := dns.NewZoneParser(bufio.NewReader(r), origin, "zonefile")
zp.SetDefaultTTL(defaultTTL)
zp.SetIncludeAllowed(false) // $INCLUDE would read arbitrary local files
for {
rr, ok := zp.Next()
if !ok {
break
}
if rr == nil {
continue
}
owner := strings.ToLower(rr.Header().Name)
if !dns.IsSubDomain(origin, owner) {
res.Summary.OutOfZone++
res.Summary.Skipped++
addWarning(&res.Summary, fmt.Sprintf(
"%s %s is outside zone %s and was skipped",
owner, dns.TypeToString[rr.Header().Rrtype], origin))
continue
}
if soa, isSOA := rr.(*dns.SOA); isSOA {
if owner == origin && res.SOA == nil {
res.SOA = soa
res.Summary.SOAFound = true
}
// The SOA is stored as zone metadata, not as a record row: the
// authoritative engine regenerates it so serials stay managed.
continue
}
name, err := relativeName(owner, origin)
if err != nil {
res.Summary.Skipped++
addWarning(&res.Summary, err.Error())
continue
}
rdata := RData(rr)
if strings.TrimSpace(rdata) == "" {
res.Summary.Skipped++
addWarning(&res.Summary, fmt.Sprintf("%s %s had empty record data and was skipped",
owner, dns.TypeToString[rr.Header().Rrtype]))
continue
}
rec := models.Record{
Name: name,
Type: typeName(rr.Header().Rrtype),
Data: rdata,
Enabled: true,
}
if ttl := rr.Header().Ttl; ttl != defaultTTL {
t := ttl
rec.TTL = &t
}
res.Records = append(res.Records, rec)
res.Summary.RecordsParsed++
}
if err := zp.Err(); err != nil {
return nil, fmt.Errorf("zone file could not be parsed: %s", cleanError(err))
}
if res.Summary.RecordsParsed == 0 && res.SOA == nil {
return nil, fmt.Errorf("no records were found in the file; check that it is a BIND zone file for %s",
strings.TrimSuffix(origin, "."))
}
return res, nil
}
func addWarning(s *ParseSummary, msg string) {
if len(s.Warnings) < maxWarnings {
s.Warnings = append(s.Warnings, msg)
}
}
// typeName renders a type, falling back to the RFC 3597 TYPEnnnnn form.
func typeName(t uint16) string {
if s, ok := dns.TypeToString[t]; ok {
return s
}
return fmt.Sprintf("TYPE%d", t)
}
// relativeName converts an absolute owner name into its in-zone relative form.
func relativeName(owner, origin string) (string, error) {
if owner == origin {
return "@", nil
}
if !strings.HasSuffix(owner, "."+origin) {
return "", fmt.Errorf("%s is not inside zone %s", owner, origin)
}
return strings.TrimSuffix(owner, "."+origin), nil
}
// RData returns just the record data portion of an RR.
//
// The DNS library formats a record as "name<TAB>ttl<TAB>class<TAB>type<TAB>rdata",
// so splitting on the first four tabs isolates the rdata without re-implementing
// per-type formatting.
func RData(rr dns.RR) string {
s := rr.String()
parts := strings.SplitN(s, "\t", 5)
if len(parts) < 5 {
// Fall back to trimming the header's own rendering.
header := rr.Header().String()
return strings.TrimSpace(strings.TrimPrefix(s, header))
}
return strings.TrimSpace(parts[4])
}
func cleanError(err error) string {
msg := err.Error()
msg = strings.TrimPrefix(msg, "dns: ")
return msg
}
// --- Export -------------------------------------------------------------
// Export writes a zone as a BIND-compatible zone file.
func Export(w io.Writer, zone models.Zone, records []models.Record) error {
bw := bufio.NewWriter(w)
defer bw.Flush()
fmt.Fprintf(bw, ";; Zone file for %s\n", zone.Name)
fmt.Fprintf(bw, ";; Exported by VibeDNS on %s\n", time.Now().UTC().Format(time.RFC3339))
if zone.Description != "" {
fmt.Fprintf(bw, ";; %s\n", singleLine(zone.Description))
}
fmt.Fprintf(bw, ";;\n")
fmt.Fprintf(bw, "$ORIGIN %s\n", zone.Name)
fmt.Fprintf(bw, "$TTL %d\n\n", zone.DefaultTTL)
// The SOA is always written from zone metadata so the exported serial
// matches what the server is actually answering with.
soa := soaFor(zone)
fmt.Fprintf(bw, "%s\n\n", soa.String())
byType := map[string][]models.Record{}
for _, r := range records {
if strings.EqualFold(r.Type, "SOA") {
continue // regenerated above
}
byType[strings.ToUpper(r.Type)] = append(byType[strings.ToUpper(r.Type)], r)
}
// NS records first, then everything else alphabetically: this is the
// conventional layout and makes diffs between exports readable.
order := []string{"NS"}
var rest []string
for t := range byType {
if t != "NS" {
rest = append(rest, t)
}
}
sort.Strings(rest)
order = append(order, rest...)
for _, t := range order {
recs := byType[t]
if len(recs) == 0 {
continue
}
sort.SliceStable(recs, func(i, j int) bool {
if recs[i].Name == recs[j].Name {
return recs[i].Data < recs[j].Data
}
if recs[i].Name == "@" {
return true
}
if recs[j].Name == "@" {
return false
}
return recs[i].Name < recs[j].Name
})
fmt.Fprintf(bw, ";; %s records\n", t)
for _, r := range recs {
writeRecord(bw, zone, r)
}
fmt.Fprintln(bw)
}
return bw.Flush()
}
func writeRecord(w io.Writer, zone models.Zone, r models.Record) {
name := r.Name
if name == "" {
name = "@"
}
ttl := r.EffectiveTTL(zone.DefaultTTL)
prefix := ""
if !r.Enabled {
// Disabled records are preserved as comments so an export/import round
// trip does not silently discard them.
prefix = ";; DISABLED "
}
line := fmt.Sprintf("%s%-24s %-7d IN %-8s %s", prefix, name, ttl, strings.ToUpper(r.Type), r.Data)
if r.Comment != "" {
line += " ; " + singleLine(r.Comment)
}
fmt.Fprintln(w, line)
}
func soaFor(zone models.Zone) *dns.SOA {
ns := zone.PrimaryNS
if ns == "" {
ns = "ns1." + zone.Name
}
if !strings.HasSuffix(ns, ".") {
ns += "."
}
return &dns.SOA{
Hdr: dns.RR_Header{
Name: zone.Name, Rrtype: dns.TypeSOA, Class: dns.ClassINET, Ttl: zone.DefaultTTL,
},
Ns: strings.ToLower(ns),
Mbox: mailbox(zone.AdminEmail, zone.Name),
Serial: zone.Serial,
Refresh: orDefault(zone.Refresh, 7200),
Retry: orDefault(zone.Retry, 3600),
Expire: orDefault(zone.Expire, 1209600),
Minttl: orDefault(zone.Minimum, 3600),
}
}
func mailbox(email, zone string) string {
e := strings.TrimSpace(strings.ToLower(email))
if e == "" {
return "hostmaster." + zone
}
at := strings.LastIndex(e, "@")
if at < 0 {
return dns.Fqdn(e)
}
local := strings.ReplaceAll(e[:at], ".", `\.`)
return local + "." + dns.Fqdn(e[at+1:])
}
func orDefault(v, def uint32) uint32 {
if v == 0 {
return def
}
return v
}
func singleLine(s string) string {
s = strings.ReplaceAll(s, "\r", " ")
s = strings.ReplaceAll(s, "\n", " ")
return strings.TrimSpace(s)
}
// ZoneMetadataFromSOA copies parsed SOA values onto a zone, which is how an
// imported zone picks up its timers and serial.
func ZoneMetadataFromSOA(z *models.Zone, soa *dns.SOA) {
if soa == nil {
return
}
z.PrimaryNS = soa.Ns
z.AdminEmail = emailFromMailbox(soa.Mbox)
z.Serial = soa.Serial
z.Refresh = soa.Refresh
z.Retry = soa.Retry
z.Expire = soa.Expire
z.Minimum = soa.Minttl
}
// emailFromMailbox converts an SOA RNAME back into an email address.
func emailFromMailbox(mbox string) string {
m := strings.TrimSuffix(mbox, ".")
if m == "" {
return ""
}
// The first unescaped dot separates the local part from the domain.
var local strings.Builder
i := 0
for ; i < len(m); i++ {
if m[i] == '\\' && i+1 < len(m) {
local.WriteByte(m[i+1])
i++
continue
}
if m[i] == '.' {
break
}
local.WriteByte(m[i])
}
if i >= len(m) {
return m
}
return local.String() + "@" + m[i+1:]
}
// SuggestFilename returns a sensible download name for a zone export.
func SuggestFilename(zone string) string {
name := strings.TrimSuffix(zone, ".")
if name == "" {
name = "zone"
}
return name + ".zone"
}
// ValidateRecords re-parses exported records to confirm they will compile.
// It is used by the importer to reject a file before anything is written.
func ValidateRecords(origin string, recs []models.Record, defaultTTL uint32) []string {
var problems []string
for _, r := range recs {
if _, err := validate.BuildRR(origin, r.Name, r.Type, r.Data, r.EffectiveTTL(defaultTTL)); err != nil {
problems = append(problems, fmt.Sprintf("%s %s: %v", r.Name, r.Type, err))
if len(problems) >= maxWarnings {
break
}
}
}
return problems
}