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>
430 lines
12 KiB
Go
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
|
|
}
|