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>
418 lines
12 KiB
Go
418 lines
12 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",
|
|
},
|
|
}
|
|
|
|
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",
|
|
}
|
|
|
|
// 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
|
|
}
|