Files
jarl/internal/validate/records.go
T
abrown38andClaude Opus 5.5 bd6b8778e4 Jarl 0.1.0: manage BIND through a web UI, REST API and CLI
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>
2026-10-07 15:03:13 +00:00

473 lines
15 KiB
Go

package validate
import (
"sort"
"strconv"
"strings"
"github.com/miekg/dns"
"jarl/internal/dnsname"
"jarl/internal/model"
"jarl/internal/rr"
)
// BIND 9.20 defaults for max-records-per-type and max-types-per-name.
const (
defaultMaxRecordsPerType = 100
defaultMaxTypesPerName = 100
)
type zoneIndex struct {
z *model.Zone
recs []*model.Record
parsed map[int64]*rr.Parsed
byName map[string][]*model.Record
types map[string]map[string][]*model.Record // name -> type -> records
cuts map[string]bool // delegation points (NS below apex)
dnames map[string]bool
}
func (ix *zoneIndex) has(name, typ string) bool { return len(ix.types[name][typ]) > 0 }
func (ix *zoneIndex) hasAddress(name string) bool {
if ix.has(name, "A") || ix.has(name, "AAAA") {
return true
}
// A wildcard one level up may supply the address.
if p := dnsname.Parent(name); p != "" && dnsname.IsSubdomain(p, ix.z.Name) {
w := "*." + p
if p == dnsname.Root {
w = "*."
}
if len(ix.byName[name]) == 0 && (ix.has(w, "A") || ix.has(w, "AAAA")) {
return true
}
}
return false
}
// cutAbove returns the delegation point at or above name (excluding the
// apex), or "".
func (ix *zoneIndex) cutAbove(name string, includeSelf bool) string {
n := name
if !includeSelf {
n = dnsname.Parent(name)
}
for n != "" && n != ix.z.Name && dnsname.IsSubdomain(n, ix.z.Name) {
if ix.cuts[n] {
return n
}
n = dnsname.Parent(n)
}
return ""
}
func (c *checker) recProblem(z *model.Zone, r *model.Record, sev Severity, code, format string, args ...any) *Problem {
var id int64
name := ""
if r != nil {
id, name = r.ID, r.Name
}
var p *Problem
switch sev {
case Error:
p = c.errf(code, "record", id, format, args...)
case Warning:
p = c.warnf(code, "record", id, format, args...)
default:
p = c.infof(code, "record", id, format, args...)
}
p.Zone, p.View, p.Name = z.Name, c.s.ViewName(z), name
return p
}
func disp(n string) string { return dnsname.Display(n) }
func (c *checker) zoneLimit(z *model.Zone, name string, def int) int {
var o *model.Option
o = c.s.FindOption(model.ScopeZone, z.ID, name)
if o == nil && z.ViewID != 0 {
o = c.s.FindOption(model.ScopeView, z.ViewID, name)
}
if o == nil {
o = c.s.FindOption(model.ScopeOptions, 0, name)
}
if o == nil {
return def
}
n, err := strconv.Atoi(strings.TrimSpace(o.Value))
if err != nil {
return def
}
return n
}
func (c *checker) checkZoneRecords(z *model.Zone) {
ix := &zoneIndex{
z: z,
recs: c.zoneRecords[z.ID],
parsed: map[int64]*rr.Parsed{},
byName: map[string][]*model.Record{},
types: map[string]map[string][]*model.Record{},
cuts: map[string]bool{},
dnames: map[string]bool{},
}
model.SortRecords(ix.recs, z)
// Per-record checks and indexing.
dups := map[string]*model.Record{}
for _, r := range ix.recs {
if !dnsname.IsSubdomain(r.Name, z.Name) {
c.recProblem(z, r, Error, "record_out_of_zone", "%s is not inside the zone %s", disp(r.Name), disp(z.Name))
continue
}
if err := dnsname.CheckOwner(r.Name); err != nil {
c.recProblem(z, r, Error, "record_name_invalid", "invalid name %s: %v", disp(r.Name), err)
continue
}
if r.TTL != nil && *r.TTL > maxTTL {
c.recProblem(z, r, Error, "ttl_invalid", "%s %s: TTL %d is larger than 2147483647", disp(r.Name), r.Type, *r.TTL)
}
p, err := rr.Parse(z.Name, r.Name, r.Type, r.Data)
if err != nil {
c.recProblem(z, r, Error, "record_invalid", "%s %s %s: %v", disp(r.Name), r.Type, r.Data, err)
continue
}
ix.parsed[r.ID] = p
dk := r.Name + "|" + r.Type + "|" + strings.ToLower(p.Data)
if prev, ok := dups[dk]; ok {
c.recProblem(z, r, Error, "record_duplicate", "%s %s %s exists twice (records %d and %d)", disp(r.Name), r.Type, r.Data, prev.ID, r.ID)
continue
}
dups[dk] = r
ix.byName[r.Name] = append(ix.byName[r.Name], r)
if ix.types[r.Name] == nil {
ix.types[r.Name] = map[string][]*model.Record{}
}
ix.types[r.Name][r.Type] = append(ix.types[r.Name][r.Type], r)
if r.Type == "NS" && r.Name != z.Name {
ix.cuts[r.Name] = true
}
if r.Type == "DNAME" {
ix.dnames[r.Name] = true
}
}
maxPerType := c.zoneLimit(z, "max-records-per-type", defaultMaxRecordsPerType)
maxTypes := c.zoneLimit(z, "max-types-per-name", defaultMaxTypesPerName)
names := make([]string, 0, len(ix.types))
for n := range ix.types {
names = append(names, n)
}
sort.Slice(names, func(i, j int) bool { return model.CanonicalLess(names[i], names[j]) })
for _, name := range names {
types := ix.types[name]
first := ix.byName[name][0]
// RRset TTL consistency.
for t, rs := range types {
ttl := rs[0].EffectiveTTL(z)
for _, r := range rs[1:] {
if r.EffectiveTTL(z) != ttl {
c.recProblem(z, r, Error, "rrset_ttl_mismatch", "%s %s: all records of an RRset must have the same TTL (found %d and %d); BIND would silently use the first one", disp(name), t, ttl, r.EffectiveTTL(z))
break
}
}
if maxPerType > 0 && len(rs) > maxPerType {
c.recProblem(z, rs[0], Error, "rrset_too_large", "%s %s has %d records; named refuses to load RRsets larger than max-records-per-type (%d)", disp(name), t, len(rs), maxPerType)
}
}
if maxTypes > 0 && len(types) > maxTypes {
c.recProblem(z, first, Error, "too_many_types", "%s has %d record types; named refuses more than max-types-per-name (%d)", disp(name), len(types), maxTypes)
}
// CNAME exclusivity.
if cn := types["CNAME"]; len(cn) > 0 {
if name == z.Name {
c.recProblem(z, cn[0], Error, "cname_apex", "a CNAME can't be at the zone apex, which must have SOA and NS records (use an A/AAAA record, or an ALIAS-style HTTPS record)")
}
if len(cn) > 1 {
c.recProblem(z, cn[1], Error, "cname_multiple", "%s has %d CNAME records; a name can have only one CNAME", disp(name), len(cn))
}
var others []string
for t := range types {
if t != "CNAME" {
others = append(others, t)
}
}
if len(others) > 0 {
sort.Strings(others)
c.recProblem(z, cn[0], Error, "cname_conflict", "%s has a CNAME, so it can't have any other records (found %s); a CNAME must be the only record at its name (RFC 1034)", disp(name), strings.Join(others, ", "))
}
}
// DNAME rules.
if dn := types["DNAME"]; len(dn) > 1 {
c.recProblem(z, dn[1], Error, "dname_multiple", "%s has %d DNAME records; only one is allowed", disp(name), len(dn))
}
if strings.HasPrefix(name, "*.") {
if ix.has(name, "NS") {
c.recProblem(z, first, Warning, "wildcard_unusual", "wildcard NS records (%s) are almost never what you want", disp(name))
}
if ix.has(name, "DNAME") {
c.recProblem(z, first, Warning, "wildcard_unusual", "DNAME at a wildcard name (%s) should not be used (RFC 4592)", disp(name))
}
}
// Null MX must be alone.
if mx := types["MX"]; len(mx) > 1 {
for _, r := range mx {
if p := ix.parsed[r.ID]; p != nil && p.RR.(*dns.MX).Mx == "." {
c.recProblem(z, r, Error, "null_mx_not_alone", "%s has a null MX (\"0 .\") and other MX records; a null MX must be the only MX (RFC 7505)", disp(name))
break
}
}
}
c.checkTXTPolicies(z, ix, name)
}
// Apex.
if !ix.has(z.Name, "NS") {
c.recProblem(z, nil, Error, "apex_ns_missing", "the zone has no NS records at its apex; named refuses to load a zone without NS records")
}
// Records below DNAMEs.
for _, r := range ix.recs {
for n := dnsname.Parent(r.Name); n != "" && dnsname.IsSubdomain(n, z.Name); n = dnsname.Parent(n) {
if ix.dnames[n] {
c.recProblem(z, r, Error, "below_dname", "%s is below the DNAME at %s; names below a DNAME are never reached (RFC 6672)", disp(r.Name), disp(n))
break
}
if n == z.Name {
break
}
}
}
// Delegations: occluded data and DS placement.
glueNames := map[string]bool{}
for cut := range ix.cuts {
for _, r := range ix.types[cut]["NS"] {
if p := ix.parsed[r.ID]; p != nil {
glueNames[p.RR.(*dns.NS).Ns] = true
}
}
}
for _, r := range ix.recs {
if ix.parsed[r.ID] == nil {
continue
}
if r.Type == "DS" && !ix.cuts[r.Name] {
c.recProblem(z, r, Error, "ds_not_at_delegation", "DS records belong at a delegation point (a name with NS records below the apex); %s is not one", disp(r.Name))
}
cutSelf := ix.cuts[r.Name]
cut := ix.cutAbove(r.Name, false)
switch {
case cutSelf && cut == "":
// At the delegation point: NS and DS are what belongs here;
// address records are fine if they are glue.
switch r.Type {
case "NS", "DS":
case "A", "AAAA":
if !glueNames[r.Name] {
c.recProblem(z, r, Warning, "occluded", "%s %s is at the delegation point %s and won't be served (the server answers with a referral)", disp(r.Name), r.Type, disp(r.Name))
}
case "CNAME":
// reported as cname_conflict
default:
c.recProblem(z, r, Warning, "occluded", "%s %s is at the delegation point %s and won't be served (the server answers with a referral)", disp(r.Name), r.Type, disp(r.Name))
}
case cut != "":
if (r.Type == "A" || r.Type == "AAAA") && glueNames[r.Name] {
continue
}
c.recProblem(z, r, Warning, "occluded", "%s %s is below the delegation point %s and won't be served; only glue address records for the delegated name servers belong there", disp(r.Name), r.Type, disp(cut))
}
}
// Targets of NS, MX, SRV and CNAME records.
for _, r := range ix.recs {
p := ix.parsed[r.ID]
if p == nil {
continue
}
switch v := p.RR.(type) {
case *dns.NS:
c.checkNSTarget(z, ix, r, v.Ns)
case *dns.MX:
if v.Mx != "." {
c.checkHostTarget(z, ix, r, "MX", v.Mx, "mx_cname")
}
case *dns.SRV:
if v.Target != "." {
c.checkHostTarget(z, ix, r, "SRV", v.Target, "srv_cname")
}
case *dns.CNAME:
c.checkCNAMETarget(z, ix, r, v.Target)
}
}
c.checkChildZones(z, ix)
}
// inZoneTarget reports whether target is served from this zone's data
// (inside the zone and not below a delegation).
func (ix *zoneIndex) inZoneTarget(target string) bool {
return dnsname.IsSubdomain(target, ix.z.Name) && ix.cutAbove(target, true) == ""
}
func (c *checker) checkNSTarget(z *model.Zone, ix *zoneIndex, r *model.Record, target string) {
if !dnsname.IsSubdomain(target, z.Name) {
return
}
if ix.has(target, "CNAME") {
c.recProblem(z, r, Error, "ns_cname", "NS target %s is a CNAME; NS records must point to a name with address records (RFC 2181)", disp(target))
return
}
if ix.hasAddress(target) {
return
}
if r.Name == z.Name {
c.recProblem(z, r, Error, "ns_no_address", "name server %s is inside the zone but has no A or AAAA record; named refuses to load the zone", disp(target))
return
}
if dnsname.IsSubdomain(target, r.Name) {
c.recProblem(z, r, Error, "glue_missing", "the delegation of %s uses name server %s, which is inside the delegated zone, so glue A/AAAA records are required at %s", disp(r.Name), disp(target), disp(target))
return
}
if ix.inZoneTarget(target) {
c.recProblem(z, r, Warning, "target_no_address", "name server %s (for the delegation of %s) has no A or AAAA record in this zone", disp(target), disp(r.Name))
}
}
func (c *checker) checkHostTarget(z *model.Zone, ix *zoneIndex, r *model.Record, t, target, cnameCode string) {
if !ix.inZoneTarget(target) {
return
}
if ix.has(target, "CNAME") {
c.recProblem(z, r, Error, cnameCode, "%s target %s is a CNAME; %s records must point directly to a name with address records (RFC 2181 / RFC 2782)", t, disp(target), t)
return
}
if !ix.hasAddress(target) {
if len(ix.byName[target]) == 0 {
c.recProblem(z, r, Warning, "target_no_address", "%s %s points to %s, which does not exist in the zone", disp(r.Name), t, disp(target))
} else {
c.recProblem(z, r, Warning, "target_no_address", "%s %s points to %s, which has no A or AAAA record", disp(r.Name), t, disp(target))
}
}
}
func (c *checker) checkCNAMETarget(z *model.Zone, ix *zoneIndex, r *model.Record, target string) {
if !ix.inZoneTarget(target) {
return
}
// Follow the chain inside the zone to detect loops.
seen := map[string]bool{r.Name: true}
cur := target
for hops := 0; hops < 20; hops++ {
if seen[cur] {
c.recProblem(z, r, Error, "cname_loop", "CNAME loop: %s points (via other CNAMEs) back to itself", disp(r.Name))
return
}
seen[cur] = true
next := ix.types[cur]["CNAME"]
if len(next) == 0 {
break
}
if hops == 0 {
c.recProblem(z, r, Warning, "cname_chain", "%s is a CNAME to %s, which is itself a CNAME; chains slow down resolution", disp(r.Name), disp(target))
}
p := ix.parsed[next[0].ID]
if p == nil {
break
}
cur = p.RR.(*dns.CNAME).Target
if !ix.inZoneTarget(cur) {
return
}
}
if len(ix.byName[target]) == 0 && !ix.hasAddress(target) && !hasDescendant(ix, target) {
c.recProblem(z, r, Warning, "cname_dangling", "%s is a CNAME to %s, which does not exist in the zone", disp(r.Name), disp(target))
}
}
func hasDescendant(ix *zoneIndex, name string) bool {
for n := range ix.byName {
if dnsname.IsStrictSubdomain(n, name) {
return true
}
}
// A wildcard sibling may cover the name.
if p := dnsname.Parent(name); p != "" {
if len(ix.byName["*."+p]) > 0 {
return true
}
}
return false
}
func (c *checker) checkTXTPolicies(z *model.Zone, ix *zoneIndex, name string) {
var spf, dmarc []*model.Record
for _, r := range ix.types[name]["TXT"] {
p := ix.parsed[r.ID]
if p == nil {
continue
}
v, _ := rr.TextValue(p.RR)
lv := strings.ToLower(v)
if strings.HasPrefix(lv, "v=spf1 ") || lv == "v=spf1" {
spf = append(spf, r)
}
if strings.HasPrefix(lv, "v=dmarc1") {
dmarc = append(dmarc, r)
}
}
if len(spf) > 1 {
c.recProblem(z, spf[1], Warning, "spf_multiple", "%s has %d SPF policies (TXT \"v=spf1 ...\"); receivers treat multiple SPF records as a permanent error (RFC 7208), merge them into one", disp(name), len(spf))
}
if len(dmarc) > 1 {
c.recProblem(z, dmarc[1], Warning, "dmarc_multiple", "%s has %d DMARC policies; receivers ignore DMARC when there is more than one", disp(name), len(dmarc))
}
if len(dmarc) > 0 && !strings.HasPrefix(name, "_dmarc.") {
c.recProblem(z, dmarc[0], Warning, "dmarc_name", "DMARC policies must be published at _dmarc.<domain>, not at %s", disp(name))
}
}
// checkChildZones reports records hidden by more specific zones in the
// same view, and child zones that are not delegated.
func (c *checker) checkChildZones(z *model.Zone, ix *zoneIndex) {
for _, other := range c.s.ZonesInView(z.ViewID) {
if other.ID == z.ID || !dnsname.IsStrictSubdomain(other.Name, z.Name) {
continue
}
hidden := 0
var firstHidden *model.Record
for _, r := range ix.recs {
if dnsname.IsSubdomain(r.Name, other.Name) && !(r.Name == other.Name && (r.Type == "NS" || r.Type == "DS")) && !glueFor(ix, other.Name, r) {
hidden++
if firstHidden == nil {
firstHidden = r
}
}
}
if hidden > 0 {
c.recProblem(z, firstHidden, Warning, "shadowed_by_zone", "%d record(s) under %s (e.g. %s %s) are hidden by the zone %s in the same view; move them into that zone", hidden, disp(other.Name), disp(firstHidden.Name), firstHidden.Type, disp(other.Name))
}
if (other.Type == model.ZonePrimary || other.Type == model.ZoneSecondary) && !ix.has(other.Name, "NS") {
p := c.recProblem(z, nil, Info, "child_not_delegated", "zone %s exists on this server but %s has no NS records delegating it, so other resolvers can't find it", disp(other.Name), disp(z.Name))
p.Name = other.Name
}
}
}
func glueFor(ix *zoneIndex, cut string, r *model.Record) bool {
if r.Type != "A" && r.Type != "AAAA" {
return false
}
for _, ns := range ix.types[cut]["NS"] {
if p := ix.parsed[ns.ID]; p != nil && p.RR.(*dns.NS).Ns == r.Name {
return true
}
}
return false
}