A disabled record stays in Jarl with its ID, TTL and comment but is left out of the zone; the zone file lists it as a comment. Web UI toggle, jarl record disable|enable, record.disable|enable operations and API endpoints. Database schema 5. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
498 lines
16 KiB
Go
498 lines
16 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.
|
|
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
|
|
}
|