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>
473 lines
15 KiB
Go
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
|
|
}
|