Jarl keeps a BIND 9.20 server's whole configuration (views, ACLs, TSIG keys, zones of every type, records, named.conf options) in SQLite and renders named.conf, keys.conf and zone files from it. Every change goes through one changeset pipeline: whole-configuration validation, named-checkconf -z and named-checkzone in a staging directory, atomic install, rndc reconfig/reload, serial verification with rndc zonestatus, and rollback if named does not load the change. Changes apply one at a time by default or are bundled and applied together. Applied changesets can be reverted. - jarld: the daemon with the REST API and an embedded web UI, plus import, adopt, check, resync, backup and local user recovery commands - jarl: the CLI, including ACME DNS-01 hooks for certbot and acme.sh - users with argon2id passwords and CSRF-protected sessions; hashed API tokens with granular grants and roles; an audit log - systemd unit, sysusers.d/tmpfiles.d files, Makefile - unit tests, integration tests against a real named, scripts/e2e.sh - docs/ guides, README and CHANGELOG Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
447 lines
14 KiB
Go
447 lines
14 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)
|
|
}
|
|
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
|
|
}
|