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:
commit
bd6b8778e4
122 files changed
+29217
No files matched your search
@@ -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
|
||||
}
|
||||
Reference in new issue
Block a user