Files
jarl/internal/rr/rr.go
T
abrown38andClaude Opus 5.5 bf7f99874b Web UI: zone naming and reverse zones, ACL suggestions, record form
- 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>
2026-10-07 20:49:43 +00:00

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
}