- New zone dialog: choose Domain or Reverse zone. Reverse zones are created from a network and their name is generated; both kinds show the exact zone name and flag likely mistakes (addresses or URLs as names, host-like names, duplicates in the view, parent/child zones, networks off the octet/nibble boundaries) before anything is submitted. - Record form: clears every field but the type after an add; shows the record exactly as it will be stored and warns about unqualified names. In reverse zones it takes an address, defaults to PTR, suggests host names of A/AAAA records with that address and warns about a second PTR; the record list shows addresses. - Address match lists (options, view match lists, ACL elements) offer the existing ACLs, TSIG keys and built-ins as one-click suggestions and flag unknown names. - Server: in reverse zones a record can be named by its address, and a PTR target like nas.example.com without the final dot is read as the full host name (a bare label is refused). Warnings noticed while parsing record data reach the result again. - Fixes: brand link hover underline, the dashboard's "New zone" link opening the dialog twice. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
178 lines
6.2 KiB
Go
178 lines
6.2 KiB
Go
package rr
|
|
|
|
import (
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
const zone = "example.com."
|
|
|
|
func TestParseCanonical(t *testing.T) {
|
|
cases := []struct {
|
|
owner, typ, data, want string
|
|
}{
|
|
{"www.example.com.", "a", "192.0.2.1", "192.0.2.1"},
|
|
{"www.example.com.", "AAAA", "2001:DB8:0:0::1", "2001:db8::1"},
|
|
{"www.example.com.", "CNAME", "web", "web.example.com."},
|
|
{"www.example.com.", "CNAME", "Web.Example.Org.", "web.example.org."},
|
|
{"www.example.com.", "CNAME", "host.example.com", "host.example.com."},
|
|
{"example.com.", "MX", "10 mail", "10 mail.example.com."},
|
|
{"example.com.", "MX", "0 .", "0 ."},
|
|
{"example.com.", "TXT", "v=spf1 mx -all", `"v=spf1 mx -all"`},
|
|
{"example.com.", "TXT", `"a b" "c"`, `"a b" "c"`},
|
|
{"example.com.", "TXT", `say "hi" \o/`, `"say \"hi\" \\o/"`},
|
|
{"example.com.", "TXT", "semi; colon", `"semi; colon"`},
|
|
{"example.com.", "CAA", "0 issue letsencrypt.org", `0 issue "letsencrypt.org"`},
|
|
{"_sip._tcp.example.com.", "SRV", "0 5 5060 sip", "0 5 5060 sip.example.com."},
|
|
{"svc.example.com.", "HTTPS", `1 . alpn="h2,h3"`, `1 . alpn="h2,h3"`},
|
|
{"x.example.com.", "type65280", `\# 4 deadbeef`, `\# 4 deadbeef`},
|
|
{"10.2.0.192.in-addr.arpa.", "PTR", "host.example.com.", "host.example.com."},
|
|
{"_dmarc.example.com.", "TXT", "v=DMARC1; p=none", `"v=DMARC1; p=none"`},
|
|
{"*.example.com.", "A", "192.0.2.9", "192.0.2.9"},
|
|
}
|
|
for _, c := range cases {
|
|
p, err := Parse(zone, c.owner, c.typ, c.data)
|
|
if err != nil {
|
|
t.Errorf("Parse(%s %s %q): %v", c.owner, c.typ, c.data, err)
|
|
continue
|
|
}
|
|
if p.Data != c.want {
|
|
t.Errorf("Parse(%s %s %q) = %q, want %q", c.owner, c.typ, c.data, p.Data, c.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestParseWarnings(t *testing.T) {
|
|
p, err := Parse(zone, "www.example.com.", "CNAME", "host.example.com")
|
|
if err != nil || len(p.Warnings) != 1 || !strings.Contains(p.Warnings[0], "absolute name") {
|
|
t.Errorf("double origin warning missing: %+v %v", p, err)
|
|
}
|
|
p, err = Parse(zone, "sip.example.com.", "SRV", "0 5 5060 sip")
|
|
if err != nil || len(p.Warnings) != 1 {
|
|
t.Errorf("SRV owner warning missing: %+v %v", p, err)
|
|
}
|
|
}
|
|
|
|
func TestParseErrors(t *testing.T) {
|
|
cases := []struct {
|
|
owner, typ, data, want string
|
|
}{
|
|
{"www.example.com.", "A", "01.2.3.4", "invalid A data"},
|
|
{"www.example.com.", "A", "", "empty"},
|
|
{"www.example.com.", "SOA", "x", "managed by Jarl"},
|
|
{"www.example.com.", "RRSIG", "x", "maintained by named"},
|
|
{"www.example.com.", "BOGUS", "x", "unknown record type"},
|
|
{"foo_bar.example.com.", "A", "1.2.3.4", "valid host name"},
|
|
{"example.com.", "MX", "10 1.2.3.4", "not an IP address"},
|
|
{"example.com.", "MX", "10 bad_host.example.org.", "not a valid host name"},
|
|
{"example.com.", "MX", "5 .", "null MX"},
|
|
{"www.example.com.", "A", "1.2.3.4\n$INCLUDE /etc/passwd", "line breaks"},
|
|
{"www.example.com.", "A", "1.2.3.4 ; comment", "comment"},
|
|
{"www.example.com.", "A", "1.2.3.4 5.6.7.8", "invalid A data"},
|
|
{"www.example.com.", "DS", "12345 13 2 abcd", "needs 32 bytes"},
|
|
{"www.example.com.", "NS", "1.2.3.4.", "not an IP address"},
|
|
{"www.example.com.", "CNAME", ".", "root"},
|
|
{"www.example.com.", "A", "$ORIGIN x", "'$'"},
|
|
}
|
|
for _, c := range cases {
|
|
_, err := Parse(zone, c.owner, c.typ, c.data)
|
|
if err == nil || !strings.Contains(err.Error(), c.want) {
|
|
t.Errorf("Parse(%s %s %q) error = %v, want %q", c.owner, c.typ, c.data, err, c.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestQuoteText(t *testing.T) {
|
|
long := strings.Repeat("a", 600)
|
|
q := QuoteText(long)
|
|
if strings.Count(q, `"`) != 6 {
|
|
t.Errorf("600 bytes should be 3 strings: %q", q)
|
|
}
|
|
p, err := Parse(zone, "k._domainkey.example.com.", "TXT", "v=DKIM1; k=rsa; p="+long)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
v, _ := TextValue(p.RR)
|
|
if v != "v=DKIM1; k=rsa; p="+long {
|
|
t.Errorf("TextValue mismatch")
|
|
}
|
|
// Multi-byte characters are not split across strings.
|
|
s := strings.Repeat("é", 200) // 400 bytes
|
|
for _, part := range strings.Split(QuoteText(s), `" "`) {
|
|
if strings.ContainsRune(strings.Trim(part, `"`), '\uFFFD') {
|
|
t.Error("split inside a UTF-8 sequence")
|
|
}
|
|
}
|
|
p, err = Parse(zone, "example.com.", "TXT", `say "hi" \o/`)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if v, _ := TextValue(p.RR); v != `say "hi" \o/` {
|
|
t.Errorf("TextValue = %q", v)
|
|
}
|
|
}
|
|
|
|
func TestTypes(t *testing.T) {
|
|
types := Types()
|
|
if types[0].Type != "A" || !types[0].Common {
|
|
t.Errorf("first type = %+v", types[0])
|
|
}
|
|
seen := map[string]bool{}
|
|
for _, ti := range types {
|
|
if seen[ti.Type] {
|
|
t.Errorf("duplicate %s", ti.Type)
|
|
}
|
|
seen[ti.Type] = true
|
|
if _, _, err := NormalizeType(ti.Type); err != nil {
|
|
t.Errorf("listed type %s not accepted: %v", ti.Type, err)
|
|
}
|
|
if ti.Example != "" {
|
|
owner := "x.example.com."
|
|
switch ti.Type {
|
|
case "SRV":
|
|
owner = "_sip._tcp.example.com."
|
|
case "TLSA":
|
|
owner = "_443._tcp.example.com."
|
|
}
|
|
if _, err := Parse(zone, owner, ti.Type, ti.Example); err != nil {
|
|
t.Errorf("example for %s fails: %v", ti.Type, err)
|
|
}
|
|
}
|
|
}
|
|
for _, bad := range []string{"SOA", "RRSIG", "OPT", "DNSKEY"} {
|
|
if seen[bad] {
|
|
t.Errorf("forbidden type %s listed", bad)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestParseErrorText(t *testing.T) {
|
|
for _, tc := range []struct{ typ, data, want string }{
|
|
{"A", "192.168.1.300", `invalid A data: bad value "192.168.1.300"`},
|
|
{"MX", "x mail", `invalid MX data: bad Pref "x"`},
|
|
} {
|
|
_, err := Parse("example.com.", "www.example.com.", tc.typ, tc.data)
|
|
if err == nil || err.Error() != tc.want {
|
|
t.Errorf("Parse(%s %q) error = %v, want %q", tc.typ, tc.data, err, tc.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestPTRTargetsInReverseZones(t *testing.T) {
|
|
zone := "0.10.10.in-addr.arpa."
|
|
p, err := Parse(zone, "20."+zone, "PTR", "nas.adbraun.lol")
|
|
if err != nil || p.Data != "nas.adbraun.lol." || len(p.Warnings) != 1 {
|
|
t.Errorf("unqualified PTR target: %v %+v", err, p)
|
|
}
|
|
if p, err := Parse(zone, "20."+zone, "PTR", "nas.adbraun.lol."); err != nil || p.Data != "nas.adbraun.lol." || len(p.Warnings) != 0 {
|
|
t.Errorf("qualified PTR target: %v %+v", err, p)
|
|
}
|
|
if _, err := Parse(zone, "20."+zone, "PTR", "nas"); err == nil {
|
|
t.Error("a single-label PTR target inside the reverse zone was accepted")
|
|
}
|
|
// Forward zones keep zone-file semantics.
|
|
if p, err := Parse("adbraun.lol.", "x.adbraun.lol.", "CNAME", "nas"); err != nil || p.Data != "nas.adbraun.lol." {
|
|
t.Errorf("forward CNAME: %v %+v", err, p)
|
|
}
|
|
}
|