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>
514 lines
15 KiB
Go
514 lines
15 KiB
Go
// Package catalog knows which named.conf statements are valid where.
|
|
//
|
|
// It is built at start-up from two files shipped with Jarl: the grammar
|
|
// summary printed in named.conf(5) and the statement index from the BIND
|
|
// Administrator Reference Manual (both for BIND 9.20). Jarl uses it to
|
|
// reject options that are not valid in a scope, to offer the right inputs in
|
|
// the web UI, and to show a one-line description of every option.
|
|
// named-checkconf remains the final authority on option values.
|
|
package catalog
|
|
|
|
import (
|
|
_ "embed"
|
|
"fmt"
|
|
"regexp"
|
|
"sort"
|
|
"strings"
|
|
"sync"
|
|
)
|
|
|
|
//go:embed data/grammar-9.20.txt
|
|
var grammarText string
|
|
|
|
//go:embed data/statements-9.20.tsv
|
|
var statementsTSV string
|
|
|
|
// Scopes for configuration statements.
|
|
const (
|
|
ScopeTop = "toplevel" // top-level statements (logging, controls, ...)
|
|
ScopeOptions = "options" // inside options { }
|
|
ScopeView = "view" // inside view { }
|
|
)
|
|
|
|
// ZoneScope returns the scope name for statements inside a zone of the
|
|
// given type, e.g. "zone:primary".
|
|
func ZoneScope(zoneType string) string { return "zone:" + zoneType }
|
|
|
|
// Kinds of values, used for input widgets and quick validation.
|
|
const (
|
|
KindBool = "bool"
|
|
KindInt = "integer"
|
|
KindDuration = "duration"
|
|
KindString = "string" // a quoted string
|
|
KindWord = "word" // a single unquoted word
|
|
KindEnum = "enum"
|
|
KindACL = "address_match_list"
|
|
KindRaw = "raw"
|
|
)
|
|
|
|
// Statement describes one configuration statement in one scope.
|
|
type Statement struct {
|
|
Name string `json:"name"`
|
|
Scope string `json:"scope"`
|
|
Grammar string `json:"grammar"`
|
|
Kind string `json:"kind"`
|
|
Choices []string `json:"choices,omitempty"`
|
|
Multiple bool `json:"multiple"`
|
|
Status string `json:"status,omitempty"` // deprecated, obsolete, experimental, test only, not configured
|
|
Description string `json:"description,omitempty"`
|
|
Tags []string `json:"tags,omitempty"`
|
|
// Managed statements are represented by first-class Jarl objects or
|
|
// fields (zones, views, ACLs, keys, zone type/file, ...) and cannot be
|
|
// set as free-form options.
|
|
Managed bool `json:"managed,omitempty"`
|
|
ManagedBy string `json:"managed_by,omitempty"`
|
|
}
|
|
|
|
// Catalog holds the statements for every scope.
|
|
type Catalog struct {
|
|
scopes map[string]map[string]*Statement
|
|
order map[string][]string
|
|
}
|
|
|
|
var (
|
|
once sync.Once
|
|
def *Catalog
|
|
initErr error
|
|
)
|
|
|
|
// Default returns the catalog for BIND 9.20.
|
|
func Default() *Catalog {
|
|
once.Do(func() {
|
|
def, initErr = build(grammarText, statementsTSV)
|
|
if initErr != nil {
|
|
panic("catalog: " + initErr.Error())
|
|
}
|
|
})
|
|
return def
|
|
}
|
|
|
|
// managed lists statements that Jarl models itself, per scope.
|
|
var managed = map[string]map[string]string{
|
|
ScopeTop: {
|
|
"options": "server options",
|
|
"view": "views",
|
|
"zone": "zones",
|
|
"acl": "ACLs",
|
|
"key": "TSIG keys",
|
|
},
|
|
ScopeView: {
|
|
"match-clients": "the view's match-clients field",
|
|
"match-destinations": "the view's match-destinations field",
|
|
"match-recursive-only": "the view's match-recursive-only field",
|
|
"key": "TSIG keys (define keys at the top level)",
|
|
"zone": "zones",
|
|
"dnssec-policy": dnssecManaged,
|
|
},
|
|
ScopeOptions: {
|
|
"dnssec-policy": dnssecManaged,
|
|
},
|
|
}
|
|
|
|
// dnssecManaged: Jarl turns signing on and off per zone, so that it can
|
|
// guard the steps (a zone mustn't go unsigned while the parent zone still
|
|
// has a DS record for it). dnssec-policy statements that define custom
|
|
// policies (at the top level) are still options.
|
|
const dnssecManaged = "each zone's DNSSEC setting (jarl zone dnssec enable ZONE; see docs/dnssec.md)"
|
|
|
|
var zoneManaged = map[string]string{
|
|
"type": "the zone's type field",
|
|
"file": "the zone's file (generated by Jarl)",
|
|
"in-view": "the zone's in-view field",
|
|
"dnssec-policy": dnssecManaged,
|
|
"key-directory": "Jarl (signed zones keep their keys in Jarl's key directory, which its backups include)",
|
|
}
|
|
|
|
// synonyms are older spellings that named 9.20 still accepts.
|
|
var synonyms = map[string]map[string]string{
|
|
ScopeTop: {"primaries": "remote-servers", "masters": "remote-servers", "parental-agents": "remote-servers"},
|
|
"zone": {"masters": "primaries"},
|
|
}
|
|
|
|
// ZoneTypeSynonyms maps old zone type names to their current names.
|
|
var ZoneTypeSynonyms = map[string]string{"master": "primary", "slave": "secondary"}
|
|
|
|
// Canonical returns the current name of a statement, translating older
|
|
// synonyms (for example "masters" to "primaries" in zones). The second
|
|
// result reports whether name was a synonym.
|
|
func Canonical(scope, name string) (string, bool) {
|
|
name = strings.ToLower(name)
|
|
key := scope
|
|
if strings.HasPrefix(scope, "zone:") {
|
|
key = "zone"
|
|
}
|
|
if c, ok := synonyms[key][name]; ok {
|
|
return c, true
|
|
}
|
|
return name, false
|
|
}
|
|
|
|
// Lookup returns the statement name in scope, if it exists. Synonyms are
|
|
// resolved to the current statement.
|
|
func (c *Catalog) Lookup(scope, name string) (*Statement, bool) {
|
|
n, _ := Canonical(scope, name)
|
|
s, ok := c.scopes[scope][n]
|
|
return s, ok
|
|
}
|
|
|
|
// List returns the statements of a scope in grammar order.
|
|
func (c *Catalog) List(scope string) []*Statement {
|
|
var out []*Statement
|
|
for _, n := range c.order[scope] {
|
|
out = append(out, c.scopes[scope][n])
|
|
}
|
|
return out
|
|
}
|
|
|
|
// Scopes returns all known scope names.
|
|
func (c *Catalog) Scopes() []string {
|
|
var out []string
|
|
for s := range c.scopes {
|
|
out = append(out, s)
|
|
}
|
|
sort.Strings(out)
|
|
return out
|
|
}
|
|
|
|
// ZoneTypes returns the zone types in the grammar (including "in-view").
|
|
func (c *Catalog) ZoneTypes() []string {
|
|
var out []string
|
|
for s := range c.scopes {
|
|
if t, ok := strings.CutPrefix(s, "zone:"); ok {
|
|
out = append(out, t)
|
|
}
|
|
}
|
|
sort.Strings(out)
|
|
return out
|
|
}
|
|
|
|
func build(grammar, tsv string) (*Catalog, error) {
|
|
descs := map[string][2]string{}
|
|
for _, line := range strings.Split(tsv, "\n") {
|
|
if line == "" || strings.HasPrefix(line, "#") {
|
|
continue
|
|
}
|
|
f := strings.Split(line, "\t")
|
|
if len(f) != 3 {
|
|
return nil, fmt.Errorf("bad statements line %q", line)
|
|
}
|
|
descs[f[0]] = [2]string{f[1], f[2]}
|
|
}
|
|
c := &Catalog{scopes: map[string]map[string]*Statement{}, order: map[string][]string{}}
|
|
add := func(scope string, st *Statement) {
|
|
if c.scopes[scope] == nil {
|
|
c.scopes[scope] = map[string]*Statement{}
|
|
}
|
|
if _, dup := c.scopes[scope][st.Name]; dup {
|
|
// The grammar lists some statements twice (e.g. alternative
|
|
// forms); keep the first and merge the grammar text.
|
|
c.scopes[scope][st.Name].Grammar += "\n" + st.Grammar
|
|
return
|
|
}
|
|
st.Scope = scope
|
|
if d, ok := descs[st.Name]; ok {
|
|
st.Description = d[0]
|
|
for _, t := range strings.Split(d[1], ",") {
|
|
if t = strings.TrimSpace(t); t != "" {
|
|
st.Tags = append(st.Tags, t)
|
|
}
|
|
}
|
|
}
|
|
if m, ok := managed[scope][st.Name]; ok {
|
|
st.Managed, st.ManagedBy = true, m
|
|
}
|
|
if strings.HasPrefix(scope, "zone:") {
|
|
if m, ok := zoneManaged[st.Name]; ok {
|
|
st.Managed, st.ManagedBy = true, m
|
|
}
|
|
}
|
|
st.Kind, st.Choices = classify(st.Name, st.Grammar)
|
|
c.scopes[scope][st.Name] = st
|
|
c.order[scope] = append(c.order[scope], st.Name)
|
|
}
|
|
|
|
var lines []string
|
|
for _, l := range strings.Split(grammar, "\n") {
|
|
if l == "" || strings.HasPrefix(l, "#") {
|
|
continue
|
|
}
|
|
lines = append(lines, l)
|
|
}
|
|
level := func(l string) int { return (len(l) - len(strings.TrimLeft(l, " "))) / 5 }
|
|
|
|
for i := 0; i < len(lines); i++ {
|
|
l := lines[i]
|
|
if level(l) != 0 {
|
|
return nil, fmt.Errorf("unexpected indentation at %q", l)
|
|
}
|
|
name := firstWord(l)
|
|
if !strings.HasSuffix(strings.TrimSpace(stripComment(l)), "{") {
|
|
add(ScopeTop, newStatement(name, l))
|
|
continue
|
|
}
|
|
// A block: collect its body.
|
|
j := i + 1
|
|
for j < len(lines) && level(lines[j]) > 0 {
|
|
j++
|
|
}
|
|
body := lines[i+1 : j]
|
|
closing := ""
|
|
if j < len(lines) {
|
|
closing = lines[j] // "};" possibly with flags
|
|
}
|
|
i = j
|
|
switch name {
|
|
case "options", "view":
|
|
scope := name
|
|
parseScope(body, func(st *Statement) { add(scope, st) })
|
|
st := newStatement(name, l+"\n ...\n"+closing)
|
|
st.Multiple, st.Status = flags(closing)
|
|
add(ScopeTop, st)
|
|
case "zone":
|
|
if len(body) == 0 {
|
|
return nil, fmt.Errorf("empty zone grammar")
|
|
}
|
|
first := strings.TrimSpace(body[0])
|
|
var zt string
|
|
if t, ok := strings.CutPrefix(first, "type "); ok {
|
|
zt = strings.TrimSuffix(t, ";")
|
|
body = body[1:]
|
|
} else if strings.HasPrefix(first, "in-view ") {
|
|
zt = "in-view"
|
|
} else {
|
|
return nil, fmt.Errorf("zone grammar without type: %q", first)
|
|
}
|
|
scope := ZoneScope(zt)
|
|
if c.scopes[scope] == nil {
|
|
c.scopes[scope] = map[string]*Statement{}
|
|
}
|
|
parseScope(body, func(st *Statement) { add(scope, st) })
|
|
if _, ok := c.scopes[ScopeTop]["zone"]; !ok {
|
|
add(ScopeTop, newStatement("zone", "zone <string> [ <class> ] { ... };"))
|
|
}
|
|
default:
|
|
// Other top-level blocks (logging, controls, ...) are
|
|
// free-form statements; keep their full grammar.
|
|
text := l + "\n" + strings.Join(body, "\n") + "\n" + closing
|
|
st := newStatement(name, text)
|
|
st.Multiple, st.Status = flags(closing)
|
|
add(ScopeTop, st)
|
|
}
|
|
}
|
|
// include is a directive rather than a statement but is valid at the
|
|
// top level (and Jarl uses it for things like rndc.key).
|
|
add(ScopeTop, &Statement{Name: "include", Grammar: "include <quoted_string>;", Multiple: true,
|
|
Description: "Includes another configuration file at this point."})
|
|
return c, nil
|
|
}
|
|
|
|
// parseScope parses the body of options/view/zone into statements; nested
|
|
// blocks become part of their statement's grammar.
|
|
func parseScope(body []string, emit func(*Statement)) {
|
|
for i := 0; i < len(body); i++ {
|
|
l := strings.TrimPrefix(body[i], " ")
|
|
name := firstWord(l)
|
|
if strings.HasSuffix(strings.TrimSpace(stripComment(l)), "{") {
|
|
j := i + 1
|
|
for j < len(body) && strings.HasPrefix(body[j], " ") {
|
|
j++
|
|
}
|
|
text := l
|
|
for _, sub := range body[i+1 : j] {
|
|
text += "\n" + strings.TrimPrefix(sub, " ")
|
|
}
|
|
closing := ""
|
|
if j < len(body) {
|
|
closing = strings.TrimPrefix(body[j], " ")
|
|
text += "\n" + closing
|
|
}
|
|
st := &Statement{Name: name, Grammar: strings.TrimSpace(text)}
|
|
st.Multiple, st.Status = flags(closing)
|
|
emit(st)
|
|
i = j
|
|
continue
|
|
}
|
|
emit(newStatement(name, l))
|
|
}
|
|
}
|
|
|
|
func newStatement(name, line string) *Statement {
|
|
st := &Statement{Name: name, Grammar: strings.TrimSpace(stripComment(line))}
|
|
if !strings.Contains(line, "\n") {
|
|
st.Multiple, st.Status = flags(line)
|
|
}
|
|
return st
|
|
}
|
|
|
|
func firstWord(l string) string {
|
|
f := strings.Fields(l)
|
|
if len(f) == 0 {
|
|
return ""
|
|
}
|
|
return strings.TrimSuffix(f[0], ";")
|
|
}
|
|
|
|
func stripComment(l string) string {
|
|
if i := strings.Index(l, " // "); i >= 0 {
|
|
return l[:i]
|
|
}
|
|
return l
|
|
}
|
|
|
|
// flags extracts "may occur multiple times" and the status from a trailing
|
|
// grammar comment.
|
|
func flags(l string) (multiple bool, status string) {
|
|
i := strings.Index(l, "// ")
|
|
if i < 0 {
|
|
return false, ""
|
|
}
|
|
for _, part := range strings.Split(l[i+3:], ",") {
|
|
switch p := strings.TrimSpace(part); p {
|
|
case "may occur multiple times":
|
|
multiple = true
|
|
case "deprecated", "obsolete", "experimental", "test only", "not configured", "not implemented":
|
|
status = p
|
|
}
|
|
}
|
|
return multiple, status
|
|
}
|
|
|
|
var (
|
|
reSimple = regexp.MustCompile(`^[a-z0-9-]+ (<[a-z_]+>);$`)
|
|
reEnum = regexp.MustCompile(`^[a-z0-9-]+ \( ([^()]+) \);$`)
|
|
reACL = regexp.MustCompile(`^[a-z0-9-]+ \{ <address_match_element>; \.\.\. \};$`)
|
|
)
|
|
|
|
func classify(name, grammar string) (string, []string) {
|
|
g := strings.TrimSpace(grammar)
|
|
if strings.Contains(g, "\n") {
|
|
return KindRaw, nil
|
|
}
|
|
if m := reSimple.FindStringSubmatch(g); m != nil {
|
|
switch m[1] {
|
|
case "<boolean>":
|
|
return KindBool, nil
|
|
case "<integer>":
|
|
return KindInt, nil
|
|
case "<duration>":
|
|
return KindDuration, nil
|
|
case "<quoted_string>":
|
|
return KindString, nil
|
|
case "<string>":
|
|
return KindWord, nil
|
|
}
|
|
return KindRaw, nil
|
|
}
|
|
if reACL.MatchString(g) {
|
|
return KindACL, nil
|
|
}
|
|
if m := reEnum.FindStringSubmatch(g); m != nil {
|
|
var choices []string
|
|
for _, alt := range strings.Split(m[1], "|") {
|
|
alt = strings.TrimSpace(alt)
|
|
switch {
|
|
case alt == "<boolean>":
|
|
choices = append(choices, "yes", "no")
|
|
case strings.HasPrefix(alt, "<"):
|
|
// e.g. <integer> or <quoted_string> among the choices:
|
|
// the value is not a pure enum.
|
|
return KindRaw, nil
|
|
case strings.ContainsAny(alt, " []"):
|
|
return KindRaw, nil
|
|
default:
|
|
choices = append(choices, alt)
|
|
}
|
|
}
|
|
return KindEnum, choices
|
|
}
|
|
return KindRaw, nil
|
|
}
|
|
|
|
// listParam matches an optional parameter before a statement's list, such
|
|
// as "[ port <integer> ]" in allow-transfer's grammar.
|
|
var listParam = regexp.MustCompile(`\[ ([a-z0-9-]+) `)
|
|
|
|
// WrapList adds the braces to a list value given without them, for
|
|
// statements that end in a list ("allow-transfer [ port <integer> ] {
|
|
// <address_match_element>; ... }"): "10.0.0.53" becomes "{ 10.0.0.53; }",
|
|
// "port 5353 10.0.0.53; key xfer" becomes "port 5353 { 10.0.0.53; key
|
|
// xfer; }". Elements are separated by ";", "," or new lines, or by spaces
|
|
// when they are all addresses. Values with a brace, and values of other
|
|
// statements, are returned as they are.
|
|
func (st *Statement) WrapList(v string) string {
|
|
g, _, _ := strings.Cut(st.Grammar, "\n")
|
|
open := strings.Index(g, " {")
|
|
if strings.Contains(v, "{") || open < 0 || !strings.HasSuffix(g, "; ... };") {
|
|
return v
|
|
}
|
|
params := map[string]bool{}
|
|
for _, m := range listParam.FindAllStringSubmatch(g[:open], -1) {
|
|
params[m[1]] = true
|
|
}
|
|
fields := strings.Fields(v)
|
|
i := 0
|
|
for i+1 < len(fields) && params[strings.ToLower(fields[i])] {
|
|
i += 2
|
|
}
|
|
head := strings.Join(fields[:i], " ")
|
|
rest := strings.Join(fields[i:], " ")
|
|
var elems []string
|
|
if !strings.ContainsAny(rest, ";,\n") {
|
|
elems = spaceElements(fields[i:])
|
|
} else {
|
|
for _, e := range strings.FieldsFunc(rest, func(r rune) bool { return r == ';' || r == ',' || r == '\n' }) {
|
|
if e = strings.TrimSpace(e); e != "" {
|
|
elems = append(elems, e)
|
|
}
|
|
}
|
|
}
|
|
if len(elems) == 0 {
|
|
return v
|
|
}
|
|
list := "{ " + strings.Join(elems, "; ") + "; }"
|
|
if head != "" {
|
|
return head + " " + list
|
|
}
|
|
return list
|
|
}
|
|
|
|
// spaceElements splits list elements separated by spaces: "key NAME"
|
|
// is one element, and "port N" and "tls NAME" belong to the address
|
|
// before them (forwarders { 192.0.2.1 port 53; }).
|
|
func spaceElements(fields []string) []string {
|
|
var out []string
|
|
for i := 0; i < len(fields); i++ {
|
|
f := fields[i]
|
|
switch strings.ToLower(f) {
|
|
case "key", "!key", "!":
|
|
if i+1 < len(fields) {
|
|
f += " " + fields[i+1]
|
|
i++
|
|
}
|
|
if f[0] == '!' && strings.HasPrefix(strings.ToLower(f), "! key") && i+1 < len(fields) {
|
|
f += " " + fields[i+1]
|
|
i++
|
|
}
|
|
case "-":
|
|
// A range with spaces around the dash: 10.0.0.1 - 10.0.0.9.
|
|
if len(out) > 0 && i+1 < len(fields) {
|
|
out[len(out)-1] += " - " + fields[i+1]
|
|
i++
|
|
continue
|
|
}
|
|
case "port", "tls":
|
|
if len(out) > 0 && i+1 < len(fields) {
|
|
out[len(out)-1] += " " + f + " " + fields[i+1]
|
|
i++
|
|
continue
|
|
}
|
|
}
|
|
out = append(out, f)
|
|
}
|
|
return out
|
|
}
|