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:
abrown38andClaude Opus 5.5 committed 2026-10-07 15:03:13 +00:00
commit bd6b8778e4
122 files changed
+29217

No files matched your search

+417
View File
@@ -0,0 +1,417 @@
// 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
}