Added:
- Server statistics: named's memory and CPU (from /proc), cache size,
sockets and client counts against their limits (statistics channel,
rndc status), host memory and load, and free disk space. Memory and
CPU are kept over time as gauges (sum and sample count, averaged in
timelines). Statistics page Server section, jarl stats server,
GET /stats/server.
- jarl record add|set|delete|disable|enable --view a,b | all: the same
change in each view, applied as one changeset.
- Blocking can be paused until resumed (jarl block pause indefinitely,
"Until I resume", {"indefinitely": true}).
Changed:
- List options (allow-transfer, forwarders, ...) get their braces added
when given without them.
- The dashboard lists named-checkconf's warnings about the running
configuration; the CLI prints them after an apply.
- Stats page: tile and card rows fill their width, and the queries and
failed answers charts share margins so their time axes line up.
- Dashboard: options have their own tile.
- 24-hour times in the web UI.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
507 lines
15 KiB
Go
507 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 "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
|
|
}
|