Jarl 0.1.0: manage BIND through a web UI, REST API and CLI

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>
This commit is contained in:
abrown38andClaude Opus 5.5 committed 2026-10-07 15:03:13 +00:00
commit bd6b8778e4
122 files changed
+29217

No files matched your search

+615
View File
@@ -0,0 +1,615 @@
package validate
import (
"encoding/base64"
"fmt"
"regexp"
"strings"
"jarl/internal/catalog"
"jarl/internal/dnsname"
"jarl/internal/model"
"jarl/internal/namedconf"
)
// TSIGAlgorithms are the algorithms named supports, with the minimum
// recommended secret length in bytes (the hash output size).
var TSIGAlgorithms = map[string]int{
"hmac-md5": 16,
"hmac-sha1": 20,
"hmac-sha224": 28,
"hmac-sha256": 32,
"hmac-sha384": 48,
"hmac-sha512": 64,
}
var reViewName = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$`)
func (c *checker) checkSettings() {
st := c.s.Settings
for _, ns := range st.Nameservers {
if !dnsname.IsHostname(ns, false) {
c.errf("settings_invalid", "settings", 0, "default name server %q is not a valid host name", dnsname.Display(ns))
}
}
if st.RName != "" && !dnsname.IsMailbox(st.RName) {
c.errf("settings_invalid", "settings", 0, "default SOA contact %q is not a valid mailbox name", st.RName)
}
if st.DefaultTTL > maxTTL {
c.errf("settings_invalid", "settings", 0, "default TTL %d is larger than 2147483647", st.DefaultTTL)
}
switch st.SerialPolicy {
case model.SerialDate, model.SerialIncrement, model.SerialUnixtime:
default:
c.errf("settings_invalid", "settings", 0, "unknown serial policy %q", st.SerialPolicy)
}
}
func (c *checker) checkKeys() {
seen := map[string]bool{}
for _, k := range c.s.SortedKeys() {
label := "TSIG key " + k.Name
if seen[k.Name] {
c.errf("key_duplicate", "key", k.ID, "%s is defined more than once", label)
}
seen[k.Name] = true
if k.Name == "" {
c.errf("key_name_invalid", "key", k.ID, "TSIG key has no name")
} else if err := dnsname.CheckZoneName(dnsname.Fqdn(k.Name)); err != nil {
c.errf("key_name_invalid", "key", k.ID, "%s: invalid name: %v", label, err)
}
minLen, ok := TSIGAlgorithms[k.Algorithm]
if !ok {
c.errf("key_algorithm_invalid", "key", k.ID, "%s: unsupported algorithm %q (use one of hmac-sha256, hmac-sha512, ...)", label, k.Algorithm)
}
b, err := base64.StdEncoding.DecodeString(k.Secret)
if err != nil || len(b) == 0 {
c.errf("key_secret_invalid", "key", k.ID, "%s: secret is not valid base64", label)
} else if ok && len(b) < minLen {
c.warnf("key_secret_weak", "key", k.ID, "%s: secret is only %d bytes; %d or more are recommended for %s", label, len(b), minLen, k.Algorithm)
}
if k.Algorithm == "hmac-md5" || k.Algorithm == "hmac-sha1" {
c.warnf("key_algorithm_weak", "key", k.ID, "%s: %s is outdated; prefer hmac-sha256", label, k.Algorithm)
}
}
}
// parseElements parses ACL-style element strings, reporting errors.
func (c *checker) parseElements(elems []string, report func(err error, elem string)) []namedconf.AMElement {
var out []namedconf.AMElement
for _, s := range elems {
e, err := namedconf.ParseAMElement(s)
if err != nil {
report(err, s)
continue
}
out = append(out, e)
}
return out
}
// checkRefs reports references to ACLs and keys that don't exist.
func (c *checker) checkRefs(elems []namedconf.AMElement, where string, objType string, id int64) {
acls, keys := namedconf.AMRefs(elems)
for _, a := range acls {
if c.s.ACLByName(a) == nil {
c.errf("acl_ref_missing", objType, id, "%s refers to ACL %q, which does not exist", where, a)
}
}
for _, k := range keys {
if !c.keyNames[model.NormalizeKeyName(k)] && !c.opts.ExternalKeysUnknown {
c.errf("key_ref_missing", objType, id, "%s refers to TSIG key %q, which does not exist", where, k)
}
}
}
func (c *checker) checkACLs() {
seen := map[string]bool{}
for _, a := range c.s.SortedACLs() {
label := "ACL " + a.Name
lname := strings.ToLower(a.Name)
if seen[lname] {
c.errf("acl_duplicate", "acl", a.ID, "%s is defined more than once (ACL names are case-insensitive)", label)
}
seen[lname] = true
if namedconf.BuiltinACLs[lname] {
c.errf("acl_name_invalid", "acl", a.ID, "%q is a built-in ACL name and can't be redefined", a.Name)
} else if err := namedconf.CheckACLName(a.Name); err != nil {
c.errf("acl_name_invalid", "acl", a.ID, "%v", err)
}
elems := c.parseElements(a.Elements, func(err error, s string) {
c.errf("acl_element_invalid", "acl", a.ID, "%s: element %q: %v", label, s, err)
})
if len(a.Elements) == 0 {
c.warnf("acl_empty", "acl", a.ID, "%s is empty and matches nothing", label)
}
c.checkRefs(elems, label, "acl", a.ID)
for _, w := range namedconf.Shadowed(elems) {
c.warnf("acl_shadowed", "acl", a.ID, "%s: %s", label, w)
}
}
// Cycles between ACLs.
state := map[string]int{} // 0 unvisited, 1 visiting, 2 done
var visit func(a *model.ACL, path []string)
visit = func(a *model.ACL, path []string) {
n := strings.ToLower(a.Name)
switch state[n] {
case 1:
c.errf("acl_cycle", "acl", a.ID, "ACLs refer to each other in a loop: %s", strings.Join(append(path, a.Name), " -> "))
return
case 2:
return
}
state[n] = 1
var elems []namedconf.AMElement
for _, s := range a.Elements {
if e, err := namedconf.ParseAMElement(s); err == nil {
elems = append(elems, e)
}
}
refs, _ := namedconf.AMRefs(elems)
for _, r := range refs {
if next := c.s.ACLByName(r); next != nil {
visit(next, append(path, a.Name))
}
}
state[n] = 2
}
for _, a := range c.s.SortedACLs() {
visit(a, nil)
}
}
func (c *checker) checkViews() {
seen := map[string]bool{}
for _, v := range c.s.OrderedViews() {
label := "view " + v.Name
ln := strings.ToLower(v.Name)
if seen[ln] {
c.errf("view_duplicate", "view", v.ID, "%s is defined more than once", label)
}
seen[ln] = true
if !reViewName.MatchString(v.Name) {
c.errf("view_name_invalid", "view", v.ID, "view name %q must start with a letter or digit and contain only letters, digits, '_', '-' and '.'", v.Name)
}
for field, list := range map[string][]string{"match-clients": v.MatchClients, "match-destinations": v.MatchDestinations} {
elems := c.parseElements(list, func(err error, s string) {
c.errf("view_acl_invalid", "view", v.ID, "%s: %s element %q: %v", label, field, s, err)
})
c.checkRefs(elems, label+" "+field, "view", v.ID)
for _, w := range namedconf.Shadowed(elems) {
c.warnf("acl_shadowed", "view", v.ID, "%s %s: %s", label, field, w)
}
}
}
// Views that can never match because an earlier view matches every
// client.
views := c.s.OrderedViews()
for i, v := range views {
if !c.viewMatchesEveryone(v) {
continue
}
for _, later := range views[i+1:] {
c.warnf("view_unreachable", "view", later.ID, "view %s is never used: view %s before it matches every client", later.Name, v.Name)
}
break
}
}
// viewMatchesEveryone reports whether a view matches all clients and
// destinations (and not just recursive queries).
func (c *checker) viewMatchesEveryone(v *model.View) bool {
if v.MatchRecursiveOnly != nil && *v.MatchRecursiveOnly {
return false
}
return c.listMatchesEveryone(v.MatchClients, true) && c.listMatchesEveryone(v.MatchDestinations, true)
}
// listMatchesEveryone reports whether an element list matches every
// address. emptyIsAny is true for lists that default to "any".
func (c *checker) listMatchesEveryone(list []string, emptyIsAny bool) bool {
if len(list) == 0 {
return emptyIsAny
}
return namedconf.ContainsAny(c.flatten(list, 0))
}
// flatten parses elements and expands references to user ACLs.
func (c *checker) flatten(list []string, depth int) []namedconf.AMElement {
var out []namedconf.AMElement
for _, s := range list {
e, err := namedconf.ParseAMElement(s)
if err != nil {
continue
}
if e.Kind == namedconf.AMName && !e.IsBuiltin() && depth < 10 {
if a := c.s.ACLByName(e.Name); a != nil {
inner := c.flatten(a.Elements, depth+1)
if e.Negated {
// A negated ACL reference: treat as a single
// negated element (restrictive).
out = append(out, namedconf.AMElement{Negated: true, Kind: namedconf.AMOther, Raw: "!" + e.Name})
continue
}
out = append(out, inner...)
continue
}
}
out = append(out, e)
}
return out
}
// optionScope maps an option to its catalog scope.
func (c *checker) optionScope(o *model.Option) (string, string, bool) {
switch o.Scope {
case model.ScopeOptions:
return catalog.ScopeOptions, "server options", true
case model.ScopeTopLevel:
return catalog.ScopeTop, "top-level statements", true
case model.ScopeView:
v := c.s.Views[o.ScopeID]
if v == nil {
return "", "", false
}
return catalog.ScopeView, "view " + v.Name, true
case model.ScopeZone:
z := c.s.Zones[o.ScopeID]
if z == nil {
return "", "", false
}
return catalog.ZoneScope(z.Type), "zone " + c.zoneLabel(z), true
}
return "", "", false
}
func (c *checker) checkOptions() {
cat := catalog.Default()
type key struct {
scope string
id int64
name string
}
counts := map[key]int{}
var opts []*model.Option
for _, o := range c.s.Options {
opts = append(opts, o)
}
sortOptions(opts)
for _, o := range opts {
scope, where, ok := c.optionScope(o)
if !ok {
c.errf("option_scope_invalid", "option", o.ID, "option %s belongs to a %s that does not exist", o.Name, o.Scope)
continue
}
name := strings.ToLower(o.Name)
st, known := cat.Lookup(scope, name)
if !known {
if o.Scope == model.ScopeZone {
z := c.s.Zones[o.ScopeID]
c.errf("option_unknown", "option", o.ID, "%s: %q is not a valid option for %s zones", where, o.Name, z.Type)
} else {
c.errf("option_unknown", "option", o.ID, "%s: unknown option %q", where, o.Name)
}
continue
}
if st.Managed {
c.errf("option_managed", "option", o.ID, "%s: %q can't be set as an option; it is managed through %s", where, o.Name, st.ManagedBy)
continue
}
switch st.Status {
case "deprecated":
c.warnf("option_deprecated", "option", o.ID, "%s: %q is deprecated in BIND 9.20", where, o.Name)
case "obsolete":
c.warnf("option_obsolete", "option", o.ID, "%s: %q is obsolete and has no effect (or is rejected) in BIND 9.20", where, o.Name)
case "experimental":
c.infof("option_experimental", "option", o.ID, "%s: %q is experimental", where, o.Name)
case "test only", "not configured":
c.warnf("option_unavailable", "option", o.ID, "%s: %q is %s in this BIND build", where, o.Name, st.Status)
}
k := key{o.Scope, o.ScopeID, st.Name}
counts[k]++
if counts[k] == 2 && !st.Multiple {
c.errf("option_duplicate", "option", o.ID, "%s: %q may only be set once", where, o.Name)
}
stmt, err := namedconf.ParseOptionValue(o.Name, o.Value)
if err != nil {
if strings.TrimSpace(o.Value) == "" && (name == "null" || name == "stderr") {
continue
}
c.errf("option_syntax", "option", o.ID, "%s: %s: %v", where, o.Name, err)
continue
}
if err := checkKind(st, stmt); err != nil {
c.errf("option_value_invalid", "option", o.ID, "%s: %s: %v (expected: %s)", where, o.Name, err, st.Grammar)
continue
}
label := where + " " + o.Name
if strings.Contains(st.Grammar, "<address_match_element>") && !strings.Contains(st.Grammar, "\n") {
if b := stmt.FirstBlock(); b != nil {
elems, err := namedconf.ParseAMList(b)
if err != nil {
c.errf("option_value_invalid", "option", o.ID, "%s: %v", label, err)
} else {
c.checkRefs(elems, label, "option", o.ID)
for _, w := range namedconf.Shadowed(elems) {
c.warnf("acl_shadowed", "option", o.ID, "%s: %s", label, w)
}
}
}
}
for _, kn := range keyRefs(stmt) {
if !c.keyNames[model.NormalizeKeyName(kn)] && !c.opts.ExternalKeysUnknown {
c.errf("key_ref_missing", "option", o.ID, "%s refers to TSIG key %q, which does not exist", label, kn)
}
}
}
}
func sortOptions(opts []*model.Option) {
for i := 1; i < len(opts); i++ {
for j := i; j > 0 && optLess(opts[j], opts[j-1]); j-- {
opts[j], opts[j-1] = opts[j-1], opts[j]
}
}
}
func optLess(a, b *model.Option) bool {
if a.Scope != b.Scope {
return a.Scope < b.Scope
}
if a.ScopeID != b.ScopeID {
return a.ScopeID < b.ScopeID
}
if a.Position != b.Position {
return a.Position < b.Position
}
return a.ID < b.ID
}
var (
reDuration = regexp.MustCompile(`(?i)^(\d+|(\d+[wdhms])+|P(\d+[YMWD])*(T(\d+[HMS])+)?)$`)
reInteger = regexp.MustCompile(`^\d+$`)
)
// checkKind does quick checks on simple option values. Anything it does
// not understand is left to named-checkconf.
func checkKind(st *catalog.Statement, stmt *namedconf.Stmt) error {
args := stmt.Items[1:]
single := func() (*namedconf.Value, error) {
if len(args) != 1 || args[0].Value == nil {
return nil, fmt.Errorf("expected a single value")
}
return args[0].Value, nil
}
switch st.Kind {
case catalog.KindBool:
v, err := single()
if err != nil {
return err
}
switch strings.ToLower(v.Text) {
case "yes", "no", "true", "false", "1", "0":
return nil
}
return fmt.Errorf("%q is not a boolean (use yes or no)", v.Text)
case catalog.KindInt:
v, err := single()
if err != nil {
return err
}
if !reInteger.MatchString(v.Text) {
return fmt.Errorf("%q is not a number", v.Text)
}
case catalog.KindDuration:
v, err := single()
if err != nil {
return err
}
if !reDuration.MatchString(v.Text) {
return fmt.Errorf("%q is not a duration (e.g. 3600, 1h30m or PT1H)", v.Text)
}
case catalog.KindString, catalog.KindWord:
if _, err := single(); err != nil {
return err
}
case catalog.KindEnum:
v, err := single()
if err != nil {
return err
}
for _, ch := range st.Choices {
if strings.EqualFold(ch, v.Text) {
return nil
}
}
if lv := strings.ToLower(v.Text); contains(st.Choices, "yes") && (lv == "true" || lv == "false" || lv == "1" || lv == "0") {
return nil
}
return fmt.Errorf("%q is not one of %s", v.Text, strings.Join(st.Choices, ", "))
case catalog.KindACL:
if len(args) != 1 || args[0].Block == nil {
return fmt.Errorf("expected an address match list in braces, e.g. { 192.168.0.0/16; localhost; }")
}
}
return nil
}
func contains(list []string, s string) bool {
for _, x := range list {
if x == s {
return true
}
}
return false
}
// keyRefs finds TSIG key references in a statement: "key NAME" and
// "keys { NAME; ... }" / "keys NAME" anywhere in it.
func keyRefs(st *namedconf.Stmt) []string {
var out []string
var walk func(items []namedconf.Item)
walk = func(items []namedconf.Item) {
for i, it := range items {
if it.Value != nil && !it.Value.Quoted && i+1 < len(items) {
switch strings.ToLower(it.Value.Text) {
case "key":
if n := items[i+1].Value; n != nil {
out = append(out, n.Text)
}
case "keys":
if n := items[i+1].Value; n != nil {
out = append(out, n.Text)
} else if b := items[i+1].Block; b != nil {
for _, s := range b.Stmts {
if len(s.Items) > 0 && s.Items[0].Value != nil {
out = append(out, s.Items[0].Value.Text)
}
}
}
}
}
if it.Block != nil {
for _, s := range it.Block.Stmts {
walk(s.Items)
}
}
}
}
if st.Keyword() == "key" { // a key definition, not a reference
return nil
}
walk(st.Items)
return out
}
// effectiveAML finds the first of names set in the view (if any) or in the
// global options, returning its elements (with ACL references expanded).
func (c *checker) effectiveAML(v *model.View, names ...string) ([]namedconf.AMElement, string, bool) {
for _, n := range names {
if v != nil {
if o := c.s.FindOption(model.ScopeView, v.ID, n); o != nil {
return c.amlOf(o), n, true
}
}
if o := c.s.FindOption(model.ScopeOptions, 0, n); o != nil {
return c.amlOf(o), n, true
}
}
return nil, "", false
}
func (c *checker) amlOf(o *model.Option) []namedconf.AMElement {
st, err := namedconf.ParseOptionValue(o.Name, o.Value)
if err != nil || st.FirstBlock() == nil {
return nil
}
var list []string
for _, s := range st.FirstBlock().Stmts {
list = append(list, namedconf.Compact(s.Items))
}
return c.flatten(list, 0)
}
func (c *checker) effectiveBool(v *model.View, name string, def bool) bool {
var o *model.Option
if v != nil {
o = c.s.FindOption(model.ScopeView, v.ID, name)
}
if o == nil {
o = c.s.FindOption(model.ScopeOptions, 0, name)
}
if o == nil {
return def
}
switch strings.ToLower(strings.TrimSpace(o.Value)) {
case "yes", "true", "1":
return true
case "no", "false", "0":
return false
}
return def
}
// checkSecurity looks for configurations that are dangerous on an
// internet-facing server.
func (c *checker) checkSecurity() {
views := c.s.OrderedViews()
type ctx struct {
v *model.View
label string
}
var ctxs []ctx
if len(views) == 0 {
ctxs = append(ctxs, ctx{nil, "the server"})
}
for _, v := range views {
ctxs = append(ctxs, ctx{v, "view " + v.Name})
}
for _, x := range ctxs {
// A view only reachable from some clients (e.g. "internal") can
// recurse for everyone inside it without being an open resolver.
if x.v != nil && !c.listMatchesEveryone(x.v.MatchClients, true) {
continue
}
if c.effectiveBool(x.v, "recursion", true) {
aml, from, set := c.effectiveAML(x.v, "allow-recursion", "allow-query-cache", "allow-query")
if set && namedconf.ContainsAny(aml) {
p := c.warnf("security_open_resolver", "view", viewID(x.v),
"%s offers recursion to any client (via %s), making it an open resolver that can be abused for DDoS amplification; set allow-recursion to your own networks", x.label, from)
if x.v != nil {
p.View = x.v.Name
}
}
}
if aml, _, set := c.effectiveAML(x.v, "allow-transfer"); set && namedconf.ContainsAny(aml) {
p := c.warnf("security_open_transfer", "view", viewID(x.v), "%s allows zone transfers (AXFR) to anyone; restrict allow-transfer to your secondaries or a TSIG key", x.label)
if x.v != nil {
p.View = x.v.Name
}
}
}
// Dynamic updates would be lost when Jarl rewrites zone files.
for _, z := range c.s.SortedZones() {
if z.Type != model.ZonePrimary {
continue
}
v := c.s.Views[z.ViewID]
if o := c.s.FindOption(model.ScopeZone, z.ID, "update-policy"); o != nil {
p := c.errf("dynamic_update_unsupported", "zone", z.ID, "update-policy makes the zone dynamic, but Jarl rewrites zone files and would discard dynamic updates; manage records through Jarl instead")
p.Zone, p.View = z.Name, c.s.ViewName(z)
continue
}
var aml []namedconf.AMElement
set := false
if o := c.s.FindOption(model.ScopeZone, z.ID, "allow-update"); o != nil {
aml, set = c.amlOf(o), true
} else {
aml, _, set = c.effectiveAML(v, "allow-update")
}
if set && !onlyNone(aml) {
p := c.errf("dynamic_update_unsupported", "zone", z.ID, "allow-update makes the zone dynamic, but Jarl rewrites zone files and would discard dynamic updates; remove allow-update (or set it to { none; }) and manage records through Jarl")
p.Zone, p.View = z.Name, c.s.ViewName(z)
}
if o := c.s.FindOption(model.ScopeZone, z.ID, "allow-transfer"); o != nil && namedconf.ContainsAny(c.amlOf(o)) {
p := c.warnf("security_open_transfer", "zone", z.ID, "allows zone transfers (AXFR) to anyone; restrict allow-transfer to your secondaries or a TSIG key")
p.Zone, p.View = z.Name, c.s.ViewName(z)
}
}
}
func onlyNone(elems []namedconf.AMElement) bool {
for _, e := range elems {
if !(e.Kind == namedconf.AMName && e.Name == "none" && !e.Negated) {
return false
}
}
return true
}
func viewID(v *model.View) int64 {
if v == nil {
return 0
}
return v.ID
}
+472
View File
@@ -0,0 +1,472 @@
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
}
+184
View File
@@ -0,0 +1,184 @@
// Package validate checks a complete configuration State for problems
// before it is rendered and handed to BIND.
//
// Rules come from the DNS RFCs (CNAME exclusivity, glue, DNAME, ...), from
// the checks named itself performs when loading a zone (check-names,
// check-integrity, record limits), and from operational experience (open
// resolvers, open zone transfers). named-checkconf and named-checkzone are
// run as well before anything is applied; this package exists so that
// problems are found early and explained in terms of Jarl objects.
package validate
import (
"fmt"
"sort"
"strings"
"jarl/internal/model"
)
// Severity of a problem.
type Severity string
const (
Error Severity = "error" // blocks applying
Warning Severity = "warning" // shown, does not block
Info Severity = "info"
)
// Problem is a single validation finding.
type Problem struct {
Severity Severity `json:"severity"`
Code string `json:"code"`
Message string `json:"message"`
ObjectType string `json:"object_type,omitempty"` // view, zone, record, acl, key, option, settings
ObjectID int64 `json:"object_id,omitempty"`
Zone string `json:"zone,omitempty"`
View string `json:"view,omitempty"`
Name string `json:"name,omitempty"`
}
// Key identifies a problem independently of object IDs, so problems can be
// compared between two states.
func (p Problem) Key() string {
return string(p.Severity) + "|" + p.Code + "|" + p.View + "|" + p.Zone + "|" + p.Name + "|" + p.Message
}
func (p Problem) String() string {
loc := ""
if p.Zone != "" {
loc = "zone " + p.Zone
if p.View != "" {
loc += " [view " + p.View + "]"
}
loc += ": "
} else if p.View != "" {
loc = "view " + p.View + ": "
}
return string(p.Severity) + ": " + loc + p.Message
}
// Options adjust validation.
type Options struct {
// ExternalKeys are TSIG key names defined outside Jarl, in files
// pulled in by top-level include statements (e.g. rndc-key).
ExternalKeys []string
// ExternalKeysUnknown is set when an included file could not be read,
// so references to unknown keys are not reported.
ExternalKeysUnknown bool
}
// Result holds the problems found.
type Result struct {
Problems []Problem `json:"problems"`
}
// HasErrors reports whether any problem is an error.
func HasErrors(ps []Problem) bool {
for _, p := range ps {
if p.Severity == Error {
return true
}
}
return false
}
// Errors returns only the errors.
func Errors(ps []Problem) []Problem {
var out []Problem
for _, p := range ps {
if p.Severity == Error {
out = append(out, p)
}
}
return out
}
// Introduced returns the problems in after that are not in before.
func Introduced(before, after []Problem) []Problem {
seen := map[string]bool{}
for _, p := range before {
seen[p.Key()] = true
}
var out []Problem
for _, p := range after {
if !seen[p.Key()] {
out = append(out, p)
}
}
return out
}
type checker struct {
s *model.State
opts Options
problems []Problem
// indexes
zoneRecords map[int64][]*model.Record
keyNames map[string]bool
}
// State validates the complete state.
func State(s *model.State, opts Options) []Problem {
c := &checker{s: s, opts: opts, zoneRecords: s.RecordsByZone(), keyNames: map[string]bool{}}
for _, k := range s.Keys {
c.keyNames[k.Name] = true
}
for _, k := range opts.ExternalKeys {
c.keyNames[model.NormalizeKeyName(k)] = true
}
c.checkSettings()
c.checkKeys()
c.checkACLs()
c.checkViews()
c.checkZones()
c.checkOptions()
c.checkSecurity()
sortProblems(c.problems)
return c.problems
}
func (c *checker) add(p Problem) { c.problems = append(c.problems, p) }
func (c *checker) errf(code, objType string, id int64, format string, args ...any) *Problem {
c.add(Problem{Severity: Error, Code: code, ObjectType: objType, ObjectID: id, Message: fmt.Sprintf(format, args...)})
return &c.problems[len(c.problems)-1]
}
func (c *checker) warnf(code, objType string, id int64, format string, args ...any) *Problem {
c.add(Problem{Severity: Warning, Code: code, ObjectType: objType, ObjectID: id, Message: fmt.Sprintf(format, args...)})
return &c.problems[len(c.problems)-1]
}
func (c *checker) infof(code, objType string, id int64, format string, args ...any) *Problem {
c.add(Problem{Severity: Info, Code: code, ObjectType: objType, ObjectID: id, Message: fmt.Sprintf(format, args...)})
return &c.problems[len(c.problems)-1]
}
func sortProblems(ps []Problem) {
rank := map[Severity]int{Error: 0, Warning: 1, Info: 2}
sort.SliceStable(ps, func(i, j int) bool {
a, b := ps[i], ps[j]
if rank[a.Severity] != rank[b.Severity] {
return rank[a.Severity] < rank[b.Severity]
}
if a.View != b.View {
return a.View < b.View
}
if a.Zone != b.Zone {
return a.Zone < b.Zone
}
if a.Name != b.Name {
return model.CanonicalLess(a.Name, b.Name)
}
return a.Message < b.Message
})
}
// zoneLabel returns "name [view]" for messages.
func (c *checker) zoneLabel(z *model.Zone) string {
if v := c.s.ViewName(z); v != "" {
return strings.TrimSuffix(z.Name, ".") + " [view " + v + "]"
}
return strings.TrimSuffix(z.Name, ".")
}
+346
View File
@@ -0,0 +1,346 @@
package validate
import (
"strings"
"testing"
"jarl/internal/dnsname"
"jarl/internal/model"
"jarl/internal/rr"
)
type builder struct {
s *model.State
id int64
}
func newBuilder() *builder { return &builder{s: model.NewState()} }
func (b *builder) next() int64 { b.id++; return b.id }
func (b *builder) view(name string, clients ...string) *model.View {
v := &model.View{ID: b.next(), Name: name, Position: len(b.s.Views), MatchClients: clients}
b.s.Views[v.ID] = v
return v
}
func (b *builder) zone(name string, view *model.View, typ string) *model.Zone {
z := &model.Zone{ID: b.next(), Name: dnsname.Fqdn(name), Type: typ, DefaultTTL: 3600,
SOA: model.SOA{MName: "ns1." + dnsname.Fqdn(name), RName: "hostmaster." + dnsname.Fqdn(name), Serial: 1, Refresh: 3600, Retry: 900, Expire: 1209600, Minimum: 300}}
if view != nil {
z.ViewID = view.ID
}
b.s.Zones[z.ID] = z
return z
}
// primary creates a valid primary zone with NS and glue.
func (b *builder) primary(name string, view *model.View) *model.Zone {
z := b.zone(name, view, model.ZonePrimary)
b.rec(z, "@", "NS", "ns1")
b.rec(z, "ns1", "A", "192.0.2.1")
return z
}
func (b *builder) rec(z *model.Zone, name, typ, data string) *model.Record {
owner, err := dnsname.Owner(name, z.Name)
if err != nil {
panic(err)
}
p, err := rr.Parse(z.Name, owner, typ, data)
if err != nil {
panic(name + " " + typ + " " + data + ": " + err.Error())
}
r := &model.Record{ID: b.next(), ZoneID: z.ID, Name: owner, Type: p.Type, Data: p.Data}
b.s.Records[r.ID] = r
return r
}
func (b *builder) opt(scope string, scopeID int64, name, value string) *model.Option {
o := &model.Option{ID: b.next(), Scope: scope, ScopeID: scopeID, Name: name, Value: value}
b.s.Options[o.ID] = o
return o
}
func codes(ps []Problem, sev Severity) []string {
var out []string
for _, p := range ps {
if p.Severity == sev {
out = append(out, p.Code)
}
}
return out
}
func hasCode(ps []Problem, code string) bool {
for _, p := range ps {
if p.Code == code {
return true
}
}
return false
}
func TestValidZoneIsClean(t *testing.T) {
b := newBuilder()
z := b.primary("example.com", nil)
b.rec(z, "www", "A", "192.0.2.10")
b.rec(z, "www", "AAAA", "2001:db8::10")
b.rec(z, "@", "MX", "10 mail")
b.rec(z, "mail", "A", "192.0.2.25")
b.rec(z, "@", "TXT", "v=spf1 mx -all")
b.rec(z, "_dmarc", "TXT", "v=DMARC1; p=none")
b.rec(z, "ftp", "CNAME", "www")
b.rec(z, "_sip._tcp", "SRV", "0 5 5060 www")
b.rec(z, "*", "A", "192.0.2.99")
b.rec(z, "sub", "NS", "ns.sub")
b.rec(z, "ns.sub", "A", "192.0.2.53")
b.rec(z, "sub", "DS", "12345 13 2 0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef")
rz := b.primary("2.0.192.in-addr.arpa", nil)
rz.SOA.MName = "ns1.example.com."
b.rec(rz, "10", "PTR", "www.example.com.")
b.opt(model.ScopeOptions, 0, "recursion", "yes")
b.opt(model.ScopeOptions, 0, "allow-recursion", "{ 192.168.0.0/16; localhost; }")
ps := State(b.s, Options{})
for _, p := range ps {
if p.Severity != Info {
t.Errorf("unexpected problem: %s (%s)", p, p.Code)
}
}
}
func TestRecordConflicts(t *testing.T) {
cases := []struct {
name string
build func(b *builder, z *model.Zone)
code string
sev Severity
}{
{"cname and A", func(b *builder, z *model.Zone) {
b.rec(z, "www", "CNAME", "ns1")
b.rec(z, "www", "A", "192.0.2.2")
}, "cname_conflict", Error},
{"two cnames", func(b *builder, z *model.Zone) {
b.rec(z, "www", "CNAME", "ns1")
b.rec(z, "www", "CNAME", "other.example.org.")
}, "cname_multiple", Error},
{"cname at apex", func(b *builder, z *model.Zone) { b.rec(z, "@", "CNAME", "other.example.org.") }, "cname_apex", Error},
{"duplicate", func(b *builder, z *model.Zone) {
b.rec(z, "x", "A", "192.0.2.2")
b.rec(z, "x", "A", "192.0.2.2")
}, "record_duplicate", Error},
{"ttl mismatch", func(b *builder, z *model.Zone) {
r := b.rec(z, "x", "A", "192.0.2.2")
r.TTL = model.Ptr(uint32(60))
b.rec(z, "x", "A", "192.0.2.3")
}, "rrset_ttl_mismatch", Error},
{"mx to cname", func(b *builder, z *model.Zone) {
b.rec(z, "mail", "CNAME", "ns1")
b.rec(z, "@", "MX", "10 mail")
}, "mx_cname", Error},
{"srv to cname", func(b *builder, z *model.Zone) {
b.rec(z, "alias", "CNAME", "ns1")
b.rec(z, "_sip._tcp", "SRV", "0 0 5060 alias")
}, "srv_cname", Error},
{"missing glue", func(b *builder, z *model.Zone) { b.rec(z, "sub", "NS", "ns.sub") }, "glue_missing", Error},
{"apex ns without address", func(b *builder, z *model.Zone) { b.rec(z, "@", "NS", "ns2") }, "ns_no_address", Error},
{"ds not at delegation", func(b *builder, z *model.Zone) {
b.rec(z, "x", "DS", "12345 13 2 0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef")
}, "ds_not_at_delegation", Error},
{"below dname", func(b *builder, z *model.Zone) {
b.rec(z, "d", "DNAME", "other.example.org.")
b.rec(z, "x.d", "A", "192.0.2.2")
}, "below_dname", Error},
{"occluded", func(b *builder, z *model.Zone) {
b.rec(z, "sub", "NS", "ns.other.org.")
b.rec(z, "www.sub", "A", "192.0.2.2")
}, "occluded", Warning},
{"cname loop", func(b *builder, z *model.Zone) {
b.rec(z, "a", "CNAME", "b")
b.rec(z, "b", "CNAME", "a")
}, "cname_loop", Error},
{"cname chain", func(b *builder, z *model.Zone) {
b.rec(z, "a", "CNAME", "b")
b.rec(z, "b", "CNAME", "ns1")
}, "cname_chain", Warning},
{"dangling cname", func(b *builder, z *model.Zone) { b.rec(z, "a", "CNAME", "nothing") }, "cname_dangling", Warning},
{"null mx not alone", func(b *builder, z *model.Zone) {
b.rec(z, "@", "MX", "0 .")
b.rec(z, "@", "MX", "10 ns1")
}, "null_mx_not_alone", Error},
{"two spf", func(b *builder, z *model.Zone) {
b.rec(z, "@", "TXT", "v=spf1 mx -all")
b.rec(z, "@", "TXT", "v=spf1 a -all")
}, "spf_multiple", Warning},
{"mx target missing", func(b *builder, z *model.Zone) { b.rec(z, "@", "MX", "10 nomail") }, "target_no_address", Warning},
{"ns to cname", func(b *builder, z *model.Zone) {
b.rec(z, "nsalias", "CNAME", "ns1")
b.rec(z, "@", "NS", "nsalias")
}, "ns_cname", Error},
}
for _, c := range cases {
b := newBuilder()
z := b.primary("example.com", nil)
c.build(b, z)
ps := State(b.s, Options{})
found := false
for _, p := range ps {
if p.Code == c.code && p.Severity == c.sev {
found = true
}
}
if !found {
t.Errorf("%s: expected %s %s, got errors %v warnings %v", c.name, c.sev, c.code, codes(ps, Error), codes(ps, Warning))
}
}
}
func TestZoneRules(t *testing.T) {
b := newBuilder()
b.zone("empty.com", nil, model.ZonePrimary) // no NS
b.zone("sec.com", nil, model.ZoneSecondary) // no primaries
ps := State(b.s, Options{})
if !hasCode(ps, "apex_ns_missing") || !hasCode(ps, "zone_missing_option") {
t.Errorf("got %v", codes(ps, Error))
}
// Views: zones outside views are not allowed once views exist.
b = newBuilder()
b.primary("example.com", nil)
b.view("internal", "192.168.0.0/16")
ps = State(b.s, Options{})
if !hasCode(ps, "zone_outside_view") {
t.Errorf("zone outside view not reported: %v", codes(ps, Error))
}
// in-view must reference an earlier view.
b = newBuilder()
ext := b.view("external")
in := b.view("internal", "10/8")
b.primary("example.com", in)
iv := b.zone("example.com", ext, model.ZoneInView)
iv.InView = "internal"
ps = State(b.s, Options{})
if !hasCode(ps, "zone_inview_invalid") {
t.Errorf("in-view order not checked: %v", codes(ps, Error))
}
if !hasCode(ps, "view_unreachable") {
t.Errorf("unreachable view not reported: %v", codes(ps, Warning))
}
in.Position, ext.Position = 0, 1
ps = State(b.s, Options{})
if HasErrors(ps) {
t.Errorf("valid in-view setup has errors: %v", Errors(ps))
}
}
func TestACLsAndOptions(t *testing.T) {
b := newBuilder()
b.s.ACLs[1] = &model.ACL{ID: 1, Name: "lan", Elements: []string{"192.168.1.0/24", "!192.168.1.5", "missing", "key nokey"}}
b.s.ACLs[2] = &model.ACL{ID: 2, Name: "a", Elements: []string{"b"}}
b.s.ACLs[3] = &model.ACL{ID: 3, Name: "b", Elements: []string{"a"}}
b.s.ACLs[4] = &model.ACL{ID: 4, Name: "any", Elements: []string{"10.0.0.0/8"}}
b.s.ACLs[5] = &model.ACL{ID: 5, Name: "bad", Elements: []string{"10.0.0.1/8"}}
b.id = 10
b.opt(model.ScopeOptions, 0, "recursion", "maybe")
b.opt(model.ScopeOptions, 0, "frobnicate", "yes")
b.opt(model.ScopeOptions, 0, "allow-query", "{ lan; ghost; }")
b.opt(model.ScopeOptions, 0, "allow-transfer", "{ key nope; }")
b.opt(model.ScopeOptions, 0, "directory", `"/var/cache/bind"`)
b.opt(model.ScopeOptions, 0, "directory", `"/tmp"`)
b.opt(model.ScopeOptions, 0, "dnssec-validation", "sometimes")
b.opt(model.ScopeTopLevel, 0, "acl", "x { any; }")
ps := State(b.s, Options{})
for _, want := range []string{"acl_ref_missing", "key_ref_missing", "acl_cycle", "acl_name_invalid", "acl_element_invalid", "option_value_invalid", "option_unknown", "option_duplicate", "option_managed"} {
if !hasCode(ps, want) {
t.Errorf("missing %s; got %v", want, codes(ps, Error))
}
}
if !hasCode(ps, "acl_shadowed") {
t.Errorf("shadowed element not reported: %v", codes(ps, Warning))
}
ps = State(b.s, Options{ExternalKeysUnknown: true})
for _, p := range ps {
if p.Code == "key_ref_missing" {
t.Error("key refs reported despite ExternalKeysUnknown")
}
}
}
func TestSecurityLints(t *testing.T) {
// allow-query any without allow-recursion: open resolver.
b := newBuilder()
b.primary("example.com", nil)
b.opt(model.ScopeOptions, 0, "allow-query", "{ any; }")
ps := State(b.s, Options{})
if !hasCode(ps, "security_open_resolver") {
t.Errorf("open resolver not detected: %v", codes(ps, Warning))
}
// Fixed by recursion no.
b.opt(model.ScopeOptions, 0, "recursion", "no")
if hasCode(State(b.s, Options{}), "security_open_resolver") {
t.Error("open resolver reported with recursion no")
}
// Through an ACL that contains any.
b = newBuilder()
b.s.ACLs[100] = &model.ACL{ID: 100, Name: "everyone", Elements: []string{"any"}}
b.opt(model.ScopeOptions, 0, "allow-recursion", "{ everyone; }")
if !hasCode(State(b.s, Options{}), "security_open_resolver") {
t.Error("open resolver via ACL not detected")
}
// Inside a view that only matches internal clients it's fine.
b = newBuilder()
v := b.view("internal", "192.168.0.0/16")
b.opt(model.ScopeView, v.ID, "allow-recursion", "{ any; }")
if hasCode(State(b.s, Options{}), "security_open_resolver") {
t.Error("open resolver reported for internal-only view")
}
// Dynamic updates are rejected.
b = newBuilder()
z := b.primary("example.com", nil)
b.opt(model.ScopeZone, z.ID, "allow-update", "{ key ddns; }")
ps = State(b.s, Options{ExternalKeys: []string{"ddns"}})
if !hasCode(ps, "dynamic_update_unsupported") {
t.Errorf("allow-update not rejected: %v", codes(ps, Error))
}
b.s.Options[b.id].Value = "{ none; }"
if hasCode(State(b.s, Options{}), "dynamic_update_unsupported") {
t.Error("allow-update { none; } rejected")
}
// Open transfers.
b.opt(model.ScopeZone, z.ID, "allow-transfer", "{ any; }")
if !hasCode(State(b.s, Options{}), "security_open_transfer") {
t.Error("open transfer not detected")
}
}
func TestIntroduced(t *testing.T) {
b := newBuilder()
z := b.primary("example.com", nil)
before := State(b.s, Options{})
b.rec(z, "www", "CNAME", "ns1")
b.rec(z, "www", "TXT", "x")
after := State(b.s, Options{})
intro := Introduced(before, after)
if len(intro) == 0 || intro[0].Code != "cname_conflict" {
t.Errorf("Introduced = %v", intro)
}
if len(Introduced(after, after)) != 0 {
t.Error("same state introduced problems")
}
}
func TestRecordLimit(t *testing.T) {
b := newBuilder()
z := b.primary("example.com", nil)
for i := 0; i < 101; i++ {
b.rec(z, "many", "TXT", strings.Repeat("x", i+1))
}
if !hasCode(State(b.s, Options{}), "rrset_too_large") {
t.Error("RRset over 100 records not reported")
}
b.opt(model.ScopeOptions, 0, "max-records-per-type", "0")
if hasCode(State(b.s, Options{}), "rrset_too_large") {
t.Error("max-records-per-type 0 (unlimited) not honoured")
}
}
+182
View File
@@ -0,0 +1,182 @@
package validate
import (
"path/filepath"
"strings"
"jarl/internal/dnsname"
"jarl/internal/model"
)
const maxTTL = 2147483647
func (c *checker) zoneProblem(z *model.Zone, sev Severity, code, format string, args ...any) *Problem {
var p *Problem
switch sev {
case Error:
p = c.errf(code, "zone", z.ID, format, args...)
case Warning:
p = c.warnf(code, "zone", z.ID, format, args...)
default:
p = c.infof(code, "zone", z.ID, format, args...)
}
p.Zone = z.Name
p.View = c.s.ViewName(z)
return p
}
func (c *checker) checkZones() {
hasViews := len(c.s.Views) > 0
type zkey struct {
view int64
name string
}
seen := map[zkey]bool{}
viewPos := map[int64]int{}
for i, v := range c.s.OrderedViews() {
viewPos[v.ID] = i
}
for _, z := range c.s.SortedZones() {
if z.ViewID != 0 && c.s.Views[z.ViewID] == nil {
c.zoneProblem(z, Error, "zone_view_missing", "the zone's view (id %d) does not exist", z.ViewID)
continue
}
if hasViews && z.ViewID == 0 {
c.zoneProblem(z, Error, "zone_outside_view", "when views are used, every zone must be inside a view (named requires this); move the zone into a view")
}
if err := dnsname.CheckZoneName(z.Name); err != nil {
c.zoneProblem(z, Error, "zone_name_invalid", "invalid zone name: %v", err)
}
k := zkey{z.ViewID, z.Name}
if seen[k] {
c.zoneProblem(z, Error, "zone_duplicate", "the zone is defined more than once in the same view")
}
seen[k] = true
switch z.Type {
case model.ZonePrimary:
c.checkPrimary(z)
case model.ZoneSecondary, model.ZoneStub:
if c.s.FindOption(model.ScopeZone, z.ID, "primaries") == nil {
c.zoneProblem(z, Error, "zone_missing_option", "%s zones need a primaries option listing the primary servers", z.Type)
}
case model.ZoneMirror:
if z.Name != dnsname.Root && c.s.FindOption(model.ScopeZone, z.ID, "primaries") == nil {
c.zoneProblem(z, Error, "zone_missing_option", "mirror zones other than the root zone need a primaries option")
}
case model.ZoneForward:
if c.s.FindOption(model.ScopeZone, z.ID, "forwarders") == nil {
c.zoneProblem(z, Info, "zone_no_forwarders", "the forward zone has no forwarders option, so names in it are resolved normally (forwarding is disabled for this domain)")
}
case model.ZoneHint:
if z.File == "" {
c.zoneProblem(z, Error, "zone_file_required", "hint zones need a file (for example /usr/share/dns/root.hints)")
}
if z.Name != dnsname.Root {
c.zoneProblem(z, Warning, "zone_hint_name", "hint zones are normally the root zone \".\"")
}
case model.ZoneRedirect:
if z.Name != dnsname.Root {
c.zoneProblem(z, Error, "zone_name_invalid", "redirect zones must be named \".\"")
}
if z.File == "" && c.s.FindOption(model.ScopeZone, z.ID, "primaries") == nil {
c.zoneProblem(z, Error, "zone_file_required", "redirect zones need a file or a primaries option")
}
case model.ZoneStaticStub:
if c.s.FindOption(model.ScopeZone, z.ID, "server-addresses") == nil && c.s.FindOption(model.ScopeZone, z.ID, "server-names") == nil {
c.zoneProblem(z, Error, "zone_missing_option", "static-stub zones need server-addresses or server-names")
}
case model.ZoneInView:
c.checkInView(z, viewPos)
default:
c.zoneProblem(z, Error, "zone_type_invalid", "unknown zone type %q", z.Type)
}
if z.File != "" {
switch z.Type {
case model.ZonePrimary:
c.zoneProblem(z, Error, "zone_file_managed", "the file of a primary zone is generated by Jarl and can't be set")
case model.ZoneForward, model.ZoneStaticStub, model.ZoneInView:
c.zoneProblem(z, Error, "zone_file_invalid", "%s zones have no file", z.Type)
default:
if !filepath.IsAbs(z.File) {
c.zoneProblem(z, Error, "zone_file_invalid", "zone file %q must be an absolute path", z.File)
}
}
}
if !z.HasRecords() && len(c.zoneRecords[z.ID]) > 0 {
c.zoneProblem(z, Error, "zone_records_unmanaged", "%s zones can't have records managed by Jarl (they get their data from elsewhere); delete the %d records or change the type to primary", z.Type, len(c.zoneRecords[z.ID]))
}
}
}
func (c *checker) checkInView(z *model.Zone, viewPos map[int64]int) {
if z.ViewID == 0 {
c.zoneProblem(z, Error, "zone_inview_invalid", "in-view zones can only be used inside views")
return
}
target := c.s.ViewByName(z.InView)
if target == nil {
c.zoneProblem(z, Error, "zone_inview_invalid", "in-view refers to view %q, which does not exist", z.InView)
return
}
if target.ID == z.ViewID {
c.zoneProblem(z, Error, "zone_inview_invalid", "in-view can't refer to the zone's own view")
return
}
if viewPos[target.ID] > viewPos[z.ViewID] {
c.zoneProblem(z, Error, "zone_inview_invalid", "in-view refers to view %q, which comes after this view; named only allows references to earlier views (reorder the views)", target.Name)
}
src := c.s.FindZone(z.Name, target.ID)
if src == nil {
c.zoneProblem(z, Error, "zone_inview_invalid", "view %q has no zone %s to share", target.Name, dnsname.Display(z.Name))
} else if src.Type != model.ZonePrimary && src.Type != model.ZoneSecondary {
c.zoneProblem(z, Error, "zone_inview_invalid", "in-view can only share primary or secondary zones, but %s in view %q is %s", dnsname.Display(z.Name), target.Name, src.Type)
}
for _, o := range c.s.ScopedOptions(model.ScopeZone, z.ID) {
if o.Name != "forward" && o.Name != "forwarders" {
c.zoneProblem(z, Error, "zone_inview_option", "in-view zones may only set forward and forwarders, not %s (set it on the zone in view %q)", o.Name, z.InView)
}
}
}
func (c *checker) checkPrimary(z *model.Zone) {
soa := z.SOA
if soa.MName == "" || !dnsname.IsHostname(soa.MName, false) {
c.zoneProblem(z, Error, "soa_invalid", "SOA primary name server (mname) %q is not a valid host name", dnsname.Display(soa.MName))
}
if soa.RName == "" || !dnsname.IsMailbox(soa.RName) {
c.zoneProblem(z, Error, "soa_invalid", "SOA contact (rname) %q is not a valid mailbox (use hostmaster.example.com. or an email address)", soa.RName)
}
for name, v := range map[string]uint32{"refresh": soa.Refresh, "retry": soa.Retry, "expire": soa.Expire, "minimum": soa.Minimum} {
if v > maxTTL {
c.zoneProblem(z, Error, "soa_invalid", "SOA %s %d is larger than 2147483647", name, v)
}
}
if soa.Refresh < 60 {
c.zoneProblem(z, Warning, "soa_warning", "SOA refresh %ds is very short; secondaries will poll constantly", soa.Refresh)
}
if soa.Retry >= soa.Refresh && soa.Refresh > 0 {
c.zoneProblem(z, Warning, "soa_warning", "SOA retry (%d) should be shorter than refresh (%d)", soa.Retry, soa.Refresh)
}
if soa.Expire < soa.Refresh+soa.Retry {
c.zoneProblem(z, Warning, "soa_warning", "SOA expire (%d) should be longer than refresh + retry", soa.Expire)
} else if soa.Expire < 604800 {
c.zoneProblem(z, Warning, "soa_warning", "SOA expire is under a week (%d); 2-4 weeks is recommended so secondaries survive a primary outage", soa.Expire)
}
if soa.Minimum > 10800 {
c.zoneProblem(z, Warning, "soa_warning", "SOA minimum (negative caching TTL) %d is more than 3 hours; BIND caps negative caching at 3 hours", soa.Minimum)
}
if z.DefaultTTL > maxTTL {
c.zoneProblem(z, Error, "ttl_invalid", "default TTL %d is larger than 2147483647", z.DefaultTTL)
}
switch z.SerialPolicy {
case "", model.SerialDate, model.SerialIncrement, model.SerialUnixtime:
default:
c.zoneProblem(z, Error, "zone_serial_policy", "unknown serial policy %q (use date, increment or unixtime)", z.SerialPolicy)
}
if strings.Contains(z.Name, "/") {
c.zoneProblem(z, Info, "zone_name_slash", "zone names with '/' (RFC 2317) are supported, but the file name is escaped")
}
c.checkZoneRecords(z)
}