Files
jarl/internal/validate/config.go
T
adamandClaude Opus 5.5 e2e5007f94 Address ranges in address match lists (IPv4 and IPv6)
ACLs, views' match lists, options with address match lists and blocking
client groups take ranges such as 10.0.10.101-10.0.10.199 or
2001:db8::100-2001:db8::1ff (negated, or with spaces around the dash, too).
Jarl stores the range as written and writes the fewest prefixes that cover
exactly that range to named.conf, with the range as a comment. New package
internal/iprange; namedconf gets an AMRange element (a range used to parse
as an ACL name), ExpandRanges and statement comments. Mistakes are refused
when entered, validation sees what ranges cover, and IPv6 ranges in the
IPv4-mapped space get a warning. The web UI shows what a range becomes
while typing (GET /catalog/ranges).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 23:17:14 +01:00

633 lines
20 KiB
Go

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)
} else if strings.HasPrefix(strings.ToLower(k.Name), model.ViewKeyPrefix) {
c.errf("key_name_reserved", "key", k.ID, "%s: names starting with %s are kept for the keys Jarl gives views", label, model.ViewKeyPrefix)
}
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)
c.checkRanges(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)
c.checkRanges(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.Expand()...)
}
return out
}
// checkRanges warns about IPv6 ranges in the IPv4-mapped space, which may
// match IPv4 clients too.
func (c *checker) checkRanges(elems []namedconf.AMElement, where, objType string, id int64) {
for _, e := range elems {
if e.Kind == namedconf.AMRange && e.Range.TouchesMapped4() {
c.warnf("range_mapped4", objType, id, "%s: range %s reaches into ::ffff:0:0/96, where named can see IPv4 clients as IPv6 addresses, so it may match IPv4 clients too; for IPv4 clients, write an IPv4 range", where, e.Range)
}
if e.Kind == namedconf.AMNested {
c.checkRanges(e.Nested, where, objType, id)
}
}
}
// 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
}