Files
abrown38andClaude Opus 5.5 5630e7a848 Add DNSSEC signing, key backups and Porkbun support
Zones get a DNSSEC setting: named signs them with BIND's default (or a
custom) dnssec-policy and inline signing, with keys in a per-view key
directory. Jarl shows the keys, the DS record and when it is safe to
publish it, checks the parent's DS records (check-ds, which also tells
named), and refuses steps that would break a signed domain: switching
straight to unsigned, finishing a turn-off too early, deleting or moving
a signed zone, TTLs above max-zone-ttl. Turning off goes through the
insecure policy; wipe starts over once the parent has no DS.

Backups store the DNSSEC keys and restore puts them back for named; the
backup unit gets CAP_DAC_READ_SEARCH to read them. Jarl raises a zone's
serial above named's whenever it writes an older one (after a restore).
The serials check compares the signed serial.

With a Porkbun API key in jarl.yaml, the registrar's name servers, glue
records and DS records can be shown and changed, each change checked
first (dry run, force to override).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 21:19:04 +00:00

430 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",
"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
}