// 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 [ ] { ... };")) } 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 ;", 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-]+ \{ ; \.\.\. \};$`) ) 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 "": return KindBool, nil case "": return KindInt, nil case "": return KindDuration, nil case "": return KindString, nil case "": 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 == "": choices = append(choices, "yes", "no") case strings.HasPrefix(alt, "<"): // e.g. or 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 ]" 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 ] { // ; ... }"): "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 "-": // A range with spaces around the dash: 10.0.0.1 - 10.0.0.9. if len(out) > 0 && i+1 < len(fields) { out[len(out)-1] += " - " + fields[i+1] i++ continue } 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 }