Files
adamandClaude Opus 5.5 d18fe18ccd Sort numeric labels by value
Records, zones and findings now order all-digit labels numerically, so
reverse zones list 2 before 101. model.CanonicalLess is replaced by
model.NameLess; other labels keep byte order.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 08:49:58 +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.NameLess(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
}