- 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>
460 lines
15 KiB
Go
460 lines
15 KiB
Go
// Package rr parses, validates and canonicalizes resource record data.
|
|
//
|
|
// Record data is given in zone file (presentation) format, exactly as it
|
|
// would appear in a BIND zone file after the type, for example
|
|
// "10 mail.example.com." for MX. Names without a trailing dot are relative
|
|
// to the zone, as in zone files. TXT data that does not start with a quote
|
|
// is treated as plain text and quoted (and split into 255-byte strings)
|
|
// automatically.
|
|
package rr
|
|
|
|
import (
|
|
"encoding/hex"
|
|
"errors"
|
|
"fmt"
|
|
"reflect"
|
|
"regexp"
|
|
"strings"
|
|
"unicode/utf8"
|
|
|
|
"github.com/miekg/dns"
|
|
|
|
"jarl/internal/dnsname"
|
|
)
|
|
|
|
// Parsed is a successfully parsed record.
|
|
type Parsed struct {
|
|
RR dns.RR
|
|
Type string // canonical type mnemonic, e.g. "A" or "TYPE65280"
|
|
Data string // canonical presentation-format RDATA
|
|
Warnings []string
|
|
// Targets are the domain names in the RDATA (CNAME target, MX
|
|
// exchange, NS name, ...), canonical.
|
|
Targets []string
|
|
}
|
|
|
|
// Errors.
|
|
var (
|
|
ErrEmptyData = errors.New("record data is empty")
|
|
)
|
|
|
|
// forbidden lists types that cannot be created by users, with the reason.
|
|
var forbidden = map[uint16]string{
|
|
dns.TypeSOA: "the SOA record is managed by Jarl from the zone's settings",
|
|
dns.TypeOPT: "OPT is a pseudo-record",
|
|
dns.TypeTSIG: "TSIG is a pseudo-record",
|
|
dns.TypeTKEY: "TKEY is a pseudo-record",
|
|
dns.TypeANY: "ANY is a query type",
|
|
dns.TypeAXFR: "AXFR is a query type",
|
|
dns.TypeIXFR: "IXFR is a query type",
|
|
dns.TypeRRSIG: "DNSSEC signatures are maintained by named (dnssec-policy)",
|
|
dns.TypeNSEC: "NSEC records are maintained by named (dnssec-policy)",
|
|
dns.TypeNSEC3: "NSEC3 records are maintained by named (dnssec-policy)",
|
|
dns.TypeNSEC3PARAM: "NSEC3PARAM is maintained by named (dnssec-policy)",
|
|
dns.TypeDNSKEY: "DNSKEY records are maintained by named (dnssec-policy)",
|
|
dns.TypeCDS: "CDS records are maintained by named (dnssec-policy)",
|
|
dns.TypeCDNSKEY: "CDNSKEY records are maintained by named (dnssec-policy)",
|
|
dns.TypeSIG: "SIG is obsolete",
|
|
dns.TypeNXT: "NXT is obsolete",
|
|
dns.TypeDLV: "DLV is obsolete",
|
|
dns.TypeMD: "MD is obsolete (use MX)",
|
|
dns.TypeMF: "MF is obsolete (use MX)",
|
|
dns.TypeZONEMD: "ZONEMD would be wrong after any change; it is not supported",
|
|
dns.TypeNXNAME: "NXNAME is a meta type",
|
|
dns.TypeMAILA: "MAILA is a query type",
|
|
dns.TypeMAILB: "MAILB is a query type",
|
|
}
|
|
|
|
// NormalizeType returns the canonical mnemonic for a type given in any case
|
|
// (or as TYPEnnn), and its code.
|
|
func NormalizeType(t string) (string, uint16, error) {
|
|
u := strings.ToUpper(strings.TrimSpace(t))
|
|
if u == "" {
|
|
return "", 0, errors.New("record type is empty")
|
|
}
|
|
code, ok := dns.StringToType[u]
|
|
if !ok {
|
|
if n, ok2 := strings.CutPrefix(u, "TYPE"); ok2 {
|
|
var v uint64
|
|
if _, err := fmt.Sscanf(n, "%d", &v); err == nil && v > 0 && v < 65536 && fmt.Sprint(v) == n {
|
|
code = uint16(v)
|
|
if s, known := dns.TypeToString[code]; known {
|
|
u = s
|
|
}
|
|
ok = true
|
|
}
|
|
}
|
|
}
|
|
if !ok {
|
|
return "", 0, fmt.Errorf("unknown record type %q", t)
|
|
}
|
|
if why, bad := forbidden[code]; bad {
|
|
return "", 0, fmt.Errorf("records of type %s can't be managed: %s", u, why)
|
|
}
|
|
if code == 0 || (code >= 128 && code <= 255) || code == 65535 {
|
|
return "", 0, fmt.Errorf("type %s is a meta or reserved type", u)
|
|
}
|
|
return u, code, nil
|
|
}
|
|
|
|
// IsTextType reports whether data for the type is character strings that
|
|
// may be given as plain text.
|
|
func IsTextType(t string) bool { return t == "TXT" || t == "SPF" }
|
|
|
|
// QuoteText converts plain text into one or more quoted character
|
|
// strings of at most 255 bytes each, escaping quotes and backslashes.
|
|
// Multi-byte UTF-8 sequences are not split between strings.
|
|
func QuoteText(text string) string {
|
|
var parts []string
|
|
var cur strings.Builder
|
|
n := 0 // raw bytes in current chunk
|
|
flush := func() {
|
|
parts = append(parts, `"`+cur.String()+`"`)
|
|
cur.Reset()
|
|
n = 0
|
|
}
|
|
for i := 0; i < len(text); {
|
|
r, size := utf8.DecodeRuneInString(text[i:])
|
|
if n+size > 255 {
|
|
flush()
|
|
}
|
|
chunk := text[i : i+size]
|
|
switch {
|
|
case r == '"' || r == '\\':
|
|
cur.WriteByte('\\')
|
|
cur.WriteString(chunk)
|
|
case size == 1 && (chunk[0] < 0x20 || chunk[0] == 0x7f):
|
|
fmt.Fprintf(&cur, "\\%03d", chunk[0])
|
|
default:
|
|
cur.WriteString(chunk)
|
|
}
|
|
n += size
|
|
i += size
|
|
}
|
|
if n > 0 || len(parts) == 0 {
|
|
flush()
|
|
}
|
|
return strings.Join(parts, " ")
|
|
}
|
|
|
|
// TextValue returns the concatenated, unescaped text of TXT-style data.
|
|
func TextValue(r dns.RR) (string, bool) {
|
|
switch v := r.(type) {
|
|
case *dns.TXT:
|
|
return unescapeAll(v.Txt), true
|
|
case *dns.SPF:
|
|
return unescapeAll(v.Txt), true
|
|
}
|
|
return "", false
|
|
}
|
|
|
|
func unescapeAll(ss []string) string {
|
|
var sb strings.Builder
|
|
for _, s := range ss {
|
|
sb.WriteString(unescapeTxt(s))
|
|
}
|
|
return sb.String()
|
|
}
|
|
|
|
// unescapeTxt undoes miekg/dns' presentation escaping of a TXT string.
|
|
func unescapeTxt(s string) string {
|
|
if !strings.Contains(s, `\`) {
|
|
return s
|
|
}
|
|
var out []byte
|
|
for i := 0; i < len(s); i++ {
|
|
if s[i] == '\\' && i+1 < len(s) {
|
|
if i+3 < len(s) && isDigit(s[i+1]) && isDigit(s[i+2]) && isDigit(s[i+3]) {
|
|
out = append(out, byte((s[i+1]-'0')*100+(s[i+2]-'0')*10+(s[i+3]-'0')))
|
|
i += 3
|
|
continue
|
|
}
|
|
out = append(out, s[i+1])
|
|
i++
|
|
continue
|
|
}
|
|
out = append(out, s[i])
|
|
}
|
|
return string(out)
|
|
}
|
|
|
|
func isDigit(c byte) bool { return c >= '0' && c <= '9' }
|
|
|
|
// Parse parses and validates record data. zone and owner must be canonical
|
|
// FQDNs; typ is the record type in any case.
|
|
func Parse(zone, owner, typ, data string) (*Parsed, error) {
|
|
t, code, err := NormalizeType(typ)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
d := strings.TrimSpace(data)
|
|
if d == "" {
|
|
return nil, ErrEmptyData
|
|
}
|
|
for i := 0; i < len(d); i++ {
|
|
if c := d[i]; c < 0x20 && c != '\t' || c == 0x7f {
|
|
return nil, errors.New("record data must not contain line breaks or control characters")
|
|
}
|
|
}
|
|
if strings.HasPrefix(d, "$") {
|
|
return nil, errors.New("record data must not start with '$' (zone file directives are not allowed)")
|
|
}
|
|
if IsTextType(t) && !strings.HasPrefix(d, `"`) {
|
|
d = QuoteText(d)
|
|
}
|
|
// A PTR record names a host, which is never inside the reverse zone
|
|
// itself: "nas.adbraun.lol" means the absolute name.
|
|
var ptrWarning string
|
|
if t == "PTR" && dnsname.IsReverse(zone) && !strings.HasSuffix(d, ".") && !strings.ContainsAny(d, " \t") {
|
|
if !strings.Contains(d, ".") {
|
|
return nil, fmt.Errorf("PTR data %q would become %s%s; give the host's full name, e.g. %s.example.com", d, d+".", zone, d)
|
|
}
|
|
ptrWarning = fmt.Sprintf("%q was read as the absolute name %q (add a trailing dot to be explicit)", d, d+".")
|
|
d += "."
|
|
}
|
|
typeText := t
|
|
if _, known := dns.TypeToString[code]; !known {
|
|
typeText = fmt.Sprintf("TYPE%d", code)
|
|
}
|
|
line := fmt.Sprintf("%s 3600 IN %s %s\n", owner, typeText, d)
|
|
zp := dns.NewZoneParser(strings.NewReader(line), zone, "")
|
|
zp.SetIncludeAllowed(false)
|
|
parsed, ok := zp.Next()
|
|
if perr := zp.Err(); perr != nil {
|
|
return nil, cleanParseError(perr, t)
|
|
}
|
|
if !ok || parsed == nil {
|
|
return nil, fmt.Errorf("could not parse %s data %q", t, data)
|
|
}
|
|
if c := zp.Comment(); c != "" {
|
|
return nil, errors.New("';' starts a comment in zone file syntax; put comments in the record's comment field (or quote the text)")
|
|
}
|
|
if extra, more := zp.Next(); more || extra != nil {
|
|
return nil, errors.New("record data contains more than one record")
|
|
}
|
|
if parsed.Header().Rrtype != code {
|
|
return nil, fmt.Errorf("data does not form a %s record", t)
|
|
}
|
|
p := &Parsed{RR: parsed, Type: t}
|
|
if ptrWarning != "" {
|
|
p.Warnings = append(p.Warnings, ptrWarning)
|
|
}
|
|
if err := p.fixNames(zone, data); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := p.check(owner); err != nil {
|
|
return nil, err
|
|
}
|
|
// Ensure the record fits in a DNS message.
|
|
buf := make([]byte, 65535+512)
|
|
if _, err := dns.PackRR(p.RR, buf, 0, nil, false); err != nil {
|
|
return nil, fmt.Errorf("record data is too large or invalid: %v", err)
|
|
}
|
|
// RR.String() is "owner\tttl\tclass\ttype\tdata".
|
|
fields := strings.SplitN(p.RR.String(), "\t", 5)
|
|
if len(fields) == 5 {
|
|
p.Data = strings.TrimSpace(fields[4])
|
|
}
|
|
if p.Data == "" {
|
|
return nil, ErrEmptyData
|
|
}
|
|
return p, nil
|
|
}
|
|
|
|
var badFieldRe = regexp.MustCompile(`^bad (\S+) (\S+): (.*)$`)
|
|
|
|
func cleanParseError(err error, t string) error {
|
|
msg := err.Error()
|
|
// miekg/dns errors look like: dns: bad A A: "1.2.3" at line: 1:20
|
|
msg = strings.TrimPrefix(msg, "dns: ")
|
|
if i := strings.Index(msg, " at line: "); i >= 0 {
|
|
msg = msg[:i]
|
|
}
|
|
// "bad A A: "1.2.3"" names the type and then the field; say it once.
|
|
if m := badFieldRe.FindStringSubmatch(msg); m != nil && strings.EqualFold(m[1], t) {
|
|
if strings.EqualFold(m[2], t) {
|
|
msg = "bad value " + m[3]
|
|
} else {
|
|
msg = "bad " + m[2] + " " + m[3]
|
|
}
|
|
}
|
|
return fmt.Errorf("invalid %s data: %s", t, msg)
|
|
}
|
|
|
|
// nameField is a domain-name field of an RR struct.
|
|
type nameField struct {
|
|
v reflect.Value
|
|
name string
|
|
}
|
|
|
|
func domainFields(rr dns.RR) []nameField {
|
|
var out []nameField
|
|
var walk func(v reflect.Value)
|
|
walk = func(v reflect.Value) {
|
|
t := v.Type()
|
|
for i := 0; i < t.NumField(); i++ {
|
|
f := t.Field(i)
|
|
fv := v.Field(i)
|
|
if f.Anonymous && f.Type.Kind() == reflect.Struct && f.Type != reflect.TypeOf(dns.RR_Header{}) {
|
|
walk(fv)
|
|
continue
|
|
}
|
|
tag := f.Tag.Get("dns")
|
|
if strings.Contains(tag, "domain-name") && fv.Kind() == reflect.String {
|
|
out = append(out, nameField{fv, f.Name})
|
|
}
|
|
}
|
|
}
|
|
walk(reflect.ValueOf(rr).Elem())
|
|
return out
|
|
}
|
|
|
|
// fixNames lowercases RDATA domain names and repairs the classic mistake
|
|
// of writing an absolute name without the trailing dot when it already
|
|
// ends in the zone name ("www.example.com" in zone example.com would
|
|
// otherwise become "www.example.com.example.com.").
|
|
func (p *Parsed) fixNames(zone, input string) error {
|
|
_ = input
|
|
zoneNoDot := strings.TrimSuffix(zone, ".")
|
|
dup := zoneNoDot + "." + zone // "example.com.example.com."
|
|
for _, f := range domainFields(p.RR) {
|
|
n := strings.ToLower(f.v.String())
|
|
if zone != dnsname.Root && (n == dup || strings.HasSuffix(n, "."+dup)) {
|
|
fixed := strings.TrimSuffix(n, zone)
|
|
p.Warnings = append(p.Warnings, fmt.Sprintf("%q was read as the absolute name %q (add a trailing dot to be explicit)", strings.TrimSuffix(fixed, "."), fixed))
|
|
n = fixed
|
|
}
|
|
f.v.SetString(n)
|
|
p.Targets = append(p.Targets, n)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// check applies type-specific rules, most of them the same ones BIND's
|
|
// check-names and integrity checks apply when it loads a primary zone.
|
|
func (p *Parsed) check(owner string) error {
|
|
hostTarget := func(field, name string, allowRoot bool) error {
|
|
if name == "." {
|
|
if allowRoot {
|
|
return nil
|
|
}
|
|
return fmt.Errorf("%s must not be the root name", field)
|
|
}
|
|
if looksLikeIPv4Prefix(name) {
|
|
return fmt.Errorf("%s must be a host name, not an IP address (%q)", field, dnsname.Display(name))
|
|
}
|
|
if !dnsname.IsHostname(name, false) {
|
|
return fmt.Errorf("%s %q is not a valid host name (letters, digits and hyphens only; check-names would reject it)", field, dnsname.Display(name))
|
|
}
|
|
return nil
|
|
}
|
|
switch v := p.RR.(type) {
|
|
case *dns.A:
|
|
if !dnsname.IsHostname(owner, true) {
|
|
return fmt.Errorf("the owner of an A record must be a valid host name (no underscores); %q would be rejected by check-names", dnsname.Display(owner))
|
|
}
|
|
case *dns.AAAA:
|
|
if !dnsname.IsHostname(owner, true) {
|
|
return fmt.Errorf("the owner of an AAAA record must be a valid host name (no underscores); %q would be rejected by check-names", dnsname.Display(owner))
|
|
}
|
|
if v.AAAA.To4() != nil && strings.Contains(v.AAAA.String(), ".") {
|
|
p.Warnings = append(p.Warnings, "AAAA record contains an IPv4-mapped address")
|
|
}
|
|
case *dns.MX:
|
|
if !dnsname.IsHostname(owner, true) {
|
|
return fmt.Errorf("the owner of an MX record must be a valid host name; %q would be rejected by check-names", dnsname.Display(owner))
|
|
}
|
|
if v.Mx == "." {
|
|
if v.Preference != 0 {
|
|
return errors.New(`a "null MX" (exchange ".") must have preference 0 (RFC 7505)`)
|
|
}
|
|
return nil
|
|
}
|
|
return hostTarget("MX exchange", v.Mx, false)
|
|
case *dns.NS:
|
|
return hostTarget("NS target", v.Ns, false)
|
|
case *dns.SRV:
|
|
if err := hostTarget("SRV target", v.Target, true); err != nil {
|
|
return err
|
|
}
|
|
labels := dns.SplitDomainName(owner)
|
|
if len(labels) < 3 || !strings.HasPrefix(labels[0], "_") || !strings.HasPrefix(labels[1], "_") {
|
|
p.Warnings = append(p.Warnings, "SRV owner names should look like _service._proto.name (RFC 2782)")
|
|
}
|
|
case *dns.PTR:
|
|
if dnsname.IsReverse(owner) {
|
|
return hostTarget("PTR target", v.Ptr, false)
|
|
}
|
|
case *dns.CNAME:
|
|
if v.Target == "." {
|
|
return errors.New("CNAME target must not be the root name")
|
|
}
|
|
case *dns.DNAME:
|
|
if v.Target == "." {
|
|
return errors.New("DNAME target must not be the root name")
|
|
}
|
|
case *dns.CAA:
|
|
switch v.Tag {
|
|
case "issue", "issuewild", "iodef", "issuemail", "issuevmc", "contactemail", "contactphone":
|
|
default:
|
|
p.Warnings = append(p.Warnings, fmt.Sprintf("unknown CAA property tag %q", v.Tag))
|
|
}
|
|
if v.Flag != 0 && v.Flag != 128 {
|
|
p.Warnings = append(p.Warnings, "CAA flags should be 0 or 128 (issuer critical)")
|
|
}
|
|
case *dns.DS:
|
|
if err := checkDigest("DS", v.DigestType, v.Digest, map[uint8]int{1: 20, 2: 32, 4: 48}); err != nil {
|
|
return err
|
|
}
|
|
case *dns.SSHFP:
|
|
if err := checkDigest("SSHFP", v.Type, v.FingerPrint, map[uint8]int{1: 20, 2: 32}); err != nil {
|
|
return err
|
|
}
|
|
case *dns.TLSA:
|
|
if err := checkDigest("TLSA", v.MatchingType, v.Certificate, map[uint8]int{1: 32, 2: 64}); err != nil {
|
|
return err
|
|
}
|
|
labels := dns.SplitDomainName(owner)
|
|
if len(labels) < 3 || !strings.HasPrefix(labels[0], "_") || !strings.HasPrefix(labels[1], "_") {
|
|
p.Warnings = append(p.Warnings, "TLSA owner names should look like _port._proto.name (RFC 6698)")
|
|
}
|
|
case *dns.SPF:
|
|
p.Warnings = append(p.Warnings, "the SPF record type is deprecated (RFC 7208); publish SPF policies as TXT records")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func looksLikeIPv4Prefix(name string) bool {
|
|
// "10.0.0.1.example.com." from "MX 10 10.0.0.1": four numeric labels.
|
|
labels := dns.SplitDomainName(name)
|
|
if len(labels) < 4 {
|
|
return false
|
|
}
|
|
for _, l := range labels[:4] {
|
|
if l == "" || len(l) > 3 {
|
|
return false
|
|
}
|
|
for i := 0; i < len(l); i++ {
|
|
if !isDigit(l[i]) {
|
|
return false
|
|
}
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
func checkDigest(t string, digestType uint8, digest string, sizes map[uint8]int) error {
|
|
want, known := sizes[digestType]
|
|
if !known {
|
|
return nil
|
|
}
|
|
b, err := hex.DecodeString(digest)
|
|
if err != nil {
|
|
return fmt.Errorf("%s digest is not valid hex", t)
|
|
}
|
|
if len(b) != want {
|
|
return fmt.Errorf("%s digest type %d needs %d bytes (%d hex digits), got %d bytes", t, digestType, want, want*2, len(b))
|
|
}
|
|
return nil
|
|
}
|