// 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 "namettlclasstyperdata", // 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 }