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., 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 }