Files
jarl/internal/validate/records.go
T
adamandClaude Opus 5.5 12013b12d5 Stats heatmap: empty hours same height; cname_chain only for long chains
The "Busiest times" grid's hours without data used the class "empty",
which picked up the padding of empty messages. One CNAME to another no
longer warns; three or more get an info note showing the chain.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 16:58:40 +01:00

506 lines
17 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
}
// checkDisabledRecords warns about disabled records that couldn't be
// enabled again as the zone is now: the CNAME rules are the usual way a
// name changes while one of its records is disabled.
func (c *checker) checkDisabledRecords(z *model.Zone) {
served := map[string]map[string]bool{}
for _, r := range c.zoneRecords[z.ID] {
if served[r.Name] == nil {
served[r.Name] = map[string]bool{}
}
served[r.Name][r.Type] = true
}
for _, r := range c.allRecords[z.ID] {
if !r.Disabled {
continue
}
types := served[r.Name]
switch {
case r.Type != "CNAME" && types["CNAME"]:
c.recProblem(z, r, Warning, "disabled_conflict", "the disabled record %s %s %s can't be enabled again while %s has a CNAME (a CNAME must be the only record at its name)", disp(r.Name), r.Type, r.Data, disp(r.Name))
case r.Type == "CNAME" && len(types) > 0:
c.recProblem(z, r, Warning, "disabled_conflict", "the disabled CNAME %s → %s can't be enabled again while %s has other records (a CNAME must be the only record at its name)", disp(r.Name), r.Data, disp(r.Name))
}
}
}
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. One CNAME to
// another is common and costs nothing (named answers the chain in
// one response); a longer chain gets a note.
seen := map[string]bool{r.Name: true}
chain := []string{disp(r.Name), disp(target)}
note := func() {
if len(chain) > 3 {
c.recProblem(z, r, Info, "cname_chain", "%s reaches its address through %d CNAMEs (%s); fine, but each one is another lookup for resolvers that don't get the chain at once", disp(r.Name), len(chain)-1, strings.Join(chain, " → "))
}
}
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
}
p := ix.parsed[next[0].ID]
if p == nil {
break
}
cur = p.RR.(*dns.CNAME).Target
chain = append(chain, disp(cur))
if !ix.inZoneTarget(cur) {
note()
return
}
}
note()
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
}