initial commit
This commit is contained in:
@@ -0,0 +1,371 @@
|
||||
// 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
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
package zonefile
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/owen/vibedns/internal/models"
|
||||
)
|
||||
|
||||
const sampleZone = `$ORIGIN example.com.
|
||||
$TTL 3600
|
||||
|
||||
@ IN SOA ns1.example.com. hostmaster.example.com. (
|
||||
2024010101 ; serial
|
||||
7200 ; refresh
|
||||
3600 ; retry
|
||||
1209600 ; expire
|
||||
300 ) ; minimum
|
||||
|
||||
@ IN NS ns1.example.com.
|
||||
@ IN NS ns2.example.com.
|
||||
@ IN A 192.0.2.10
|
||||
@ IN MX 10 mail.example.com.
|
||||
www IN CNAME example.com.
|
||||
mail IN A 192.0.2.20
|
||||
mail IN AAAA 2001:db8::20
|
||||
ns1 IN A 192.0.2.53
|
||||
txt IN TXT "v=spf1 mx -all"
|
||||
_sip._tcp IN SRV 10 20 5060 sip.example.com.
|
||||
short 60 IN A 192.0.2.99
|
||||
*.wild IN A 192.0.2.100
|
||||
`
|
||||
|
||||
func TestParseZoneFile(t *testing.T) {
|
||||
res, err := Parse(strings.NewReader(sampleZone), "example.com.", 3600)
|
||||
if err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
|
||||
if !res.Summary.SOAFound || res.SOA == nil {
|
||||
t.Fatal("the SOA was not picked up")
|
||||
}
|
||||
if res.SOA.Serial != 2024010101 {
|
||||
t.Errorf("serial = %d, want 2024010101", res.SOA.Serial)
|
||||
}
|
||||
if res.SOA.Minttl != 300 {
|
||||
t.Errorf("SOA minimum = %d, want 300", res.SOA.Minttl)
|
||||
}
|
||||
|
||||
// The SOA is zone metadata, not a record row.
|
||||
for _, r := range res.Records {
|
||||
if r.Type == "SOA" {
|
||||
t.Error("the SOA should not be stored as a record")
|
||||
}
|
||||
}
|
||||
|
||||
byName := map[string][]models.Record{}
|
||||
for _, r := range res.Records {
|
||||
byName[r.Name] = append(byName[r.Name], r)
|
||||
}
|
||||
|
||||
if len(byName["@"]) != 4 { // 2 NS, 1 A, 1 MX
|
||||
t.Errorf("apex records = %d, want 4", len(byName["@"]))
|
||||
}
|
||||
if got := byName["www"]; len(got) != 1 || got[0].Type != "CNAME" {
|
||||
t.Errorf("www = %v, want a single CNAME", got)
|
||||
}
|
||||
if len(byName["mail"]) != 2 {
|
||||
t.Errorf("mail records = %d, want 2 (A and AAAA)", len(byName["mail"]))
|
||||
}
|
||||
if _, ok := byName["*.wild"]; !ok {
|
||||
t.Error("the wildcard record was not parsed")
|
||||
}
|
||||
if _, ok := byName["_sip._tcp"]; !ok {
|
||||
t.Error("the underscore-prefixed SRV name was not parsed")
|
||||
}
|
||||
|
||||
// A record whose TTL differs from the file default keeps an explicit TTL;
|
||||
// one that matches inherits (nil).
|
||||
for _, r := range byName["short"] {
|
||||
if r.TTL == nil || *r.TTL != 60 {
|
||||
t.Errorf("short record TTL = %v, want an explicit 60", r.TTL)
|
||||
}
|
||||
}
|
||||
for _, r := range byName["mail"] {
|
||||
if r.TTL != nil {
|
||||
t.Errorf("mail record TTL = %v, want nil (inherit the zone default)", *r.TTL)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseRejectsOutOfZoneRecords(t *testing.T) {
|
||||
const zone = `$ORIGIN example.com.
|
||||
$TTL 3600
|
||||
@ IN A 192.0.2.1
|
||||
other.org. IN A 192.0.2.2
|
||||
`
|
||||
res, err := Parse(strings.NewReader(zone), "example.com.", 3600)
|
||||
if err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
if res.Summary.OutOfZone != 1 {
|
||||
t.Errorf("out-of-zone count = %d, want 1", res.Summary.OutOfZone)
|
||||
}
|
||||
if len(res.Summary.Warnings) == 0 {
|
||||
t.Error("expected a warning naming the skipped record")
|
||||
}
|
||||
for _, r := range res.Records {
|
||||
if strings.Contains(r.Name, "other") {
|
||||
t.Error("an out-of-zone record was imported")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseRejectsMalformedFile(t *testing.T) {
|
||||
const bad = `$ORIGIN example.com.
|
||||
@ IN A this-is-not-an-address
|
||||
`
|
||||
if _, err := Parse(strings.NewReader(bad), "example.com.", 3600); err == nil {
|
||||
t.Error("expected a parse error for invalid record data")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseEmptyFile(t *testing.T) {
|
||||
if _, err := Parse(strings.NewReader("; just a comment\n"), "example.com.", 3600); err == nil {
|
||||
t.Error("expected an error for a file with no records")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExportRoundTrip(t *testing.T) {
|
||||
zone := models.Zone{
|
||||
ID: 1, Name: "example.com.", Kind: models.ZoneForward, Enabled: true,
|
||||
DefaultTTL: 3600, PrimaryNS: "ns1.example.com.", AdminEmail: "hostmaster@example.com",
|
||||
Serial: 42, Refresh: 7200, Retry: 3600, Expire: 1209600, Minimum: 300,
|
||||
}
|
||||
ttl := uint32(60)
|
||||
records := []models.Record{
|
||||
{Name: "@", Type: "NS", Data: "ns1.example.com.", Enabled: true},
|
||||
{Name: "@", Type: "A", Data: "192.0.2.10", Enabled: true},
|
||||
{Name: "@", Type: "MX", Data: "10 mail.example.com.", Enabled: true},
|
||||
{Name: "www", Type: "CNAME", Data: "example.com.", Enabled: true},
|
||||
{Name: "mail", Type: "A", Data: "192.0.2.20", Enabled: true},
|
||||
{Name: "short", Type: "A", Data: "192.0.2.99", TTL: &ttl, Enabled: true},
|
||||
{Name: "txt", Type: "TXT", Data: `"hello world"`, Enabled: true, Comment: "a note"},
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
if err := Export(&buf, zone, records); err != nil {
|
||||
t.Fatalf("export: %v", err)
|
||||
}
|
||||
out := buf.String()
|
||||
|
||||
for _, want := range []string{"$ORIGIN example.com.", "$TTL 3600", "SOA", "42"} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("export is missing %q\n%s", want, out)
|
||||
}
|
||||
}
|
||||
|
||||
// Re-importing the export must reproduce the same records.
|
||||
res, err := Parse(strings.NewReader(out), "example.com.", 3600)
|
||||
if err != nil {
|
||||
t.Fatalf("re-parse the export: %v", err)
|
||||
}
|
||||
if len(res.Records) != len(records) {
|
||||
t.Errorf("round trip produced %d records, want %d", len(res.Records), len(records))
|
||||
}
|
||||
if res.SOA == nil || res.SOA.Serial != 42 {
|
||||
t.Error("the serial did not survive the round trip")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExportPreservesDisabledRecordsAsComments(t *testing.T) {
|
||||
zone := models.Zone{
|
||||
Name: "example.com.", DefaultTTL: 3600, Serial: 1,
|
||||
PrimaryNS: "ns1.example.com.", AdminEmail: "a@example.com",
|
||||
}
|
||||
records := []models.Record{
|
||||
{Name: "on", Type: "A", Data: "192.0.2.1", Enabled: true},
|
||||
{Name: "off", Type: "A", Data: "192.0.2.2", Enabled: false},
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
if err := Export(&buf, zone, records); err != nil {
|
||||
t.Fatalf("export: %v", err)
|
||||
}
|
||||
out := buf.String()
|
||||
|
||||
if !strings.Contains(out, "DISABLED") {
|
||||
t.Error("a disabled record should be preserved as a comment, not dropped")
|
||||
}
|
||||
// It must be commented out, so re-importing does not re-enable it.
|
||||
res, err := Parse(strings.NewReader(out), "example.com.", 3600)
|
||||
if err != nil {
|
||||
t.Fatalf("re-parse: %v", err)
|
||||
}
|
||||
for _, r := range res.Records {
|
||||
if r.Name == "off" {
|
||||
t.Error("a disabled record was re-imported as active")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMailboxConversion(t *testing.T) {
|
||||
tests := []struct{ email, want string }{
|
||||
{"hostmaster@example.com", "hostmaster.example.com."},
|
||||
{"first.last@example.com", `first\.last.example.com.`},
|
||||
{"", "hostmaster.example.com."},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
if got := mailbox(tc.email, "example.com."); got != tc.want {
|
||||
t.Errorf("mailbox(%q) = %q, want %q", tc.email, got, tc.want)
|
||||
}
|
||||
}
|
||||
|
||||
// And back again.
|
||||
backTests := []struct{ mbox, want string }{
|
||||
{"hostmaster.example.com.", "hostmaster@example.com"},
|
||||
{`first\.last.example.com.`, "first.last@example.com"},
|
||||
}
|
||||
for _, tc := range backTests {
|
||||
if got := emailFromMailbox(tc.mbox); got != tc.want {
|
||||
t.Errorf("emailFromMailbox(%q) = %q, want %q", tc.mbox, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRecordsCatchesBadData(t *testing.T) {
|
||||
records := []models.Record{
|
||||
{Name: "good", Type: "A", Data: "192.0.2.1"},
|
||||
{Name: "bad", Type: "A", Data: "not-an-address"},
|
||||
}
|
||||
problems := ValidateRecords("example.com.", records, 3600)
|
||||
if len(problems) != 1 {
|
||||
t.Errorf("problems = %d, want 1: %v", len(problems), problems)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSuggestFilename(t *testing.T) {
|
||||
if got := SuggestFilename("example.com."); got != "example.com.zone" {
|
||||
t.Errorf("filename = %q, want example.com.zone", got)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user