Files
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

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
}