// 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) } 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 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 }