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

+357
View File
@@ -0,0 +1,357 @@
package namedconf
import (
"errors"
"fmt"
"net/netip"
"strconv"
"strings"
)
// AMKind is the kind of an address match list element.
type AMKind int
const (
AMPrefix AMKind = iota // an address or prefix
AMKey // key <name>
AMName // an ACL name (or built-in ACL)
AMNested // { ... }
AMOther // anything else (e.g. geoip), passed through
)
// Builtin ACL names.
var BuiltinACLs = map[string]bool{"any": true, "none": true, "localhost": true, "localnets": true}
// AMElement is one element of an address match list.
type AMElement struct {
Negated bool
Kind AMKind
Prefix netip.Prefix // AMPrefix
Name string // AMKey, AMName
Nested []AMElement // AMNested
Raw string // AMOther
}
// String returns the element in canonical named.conf syntax, without the
// trailing semicolon.
func (e AMElement) String() string {
var s string
switch e.Kind {
case AMPrefix:
if e.Prefix.IsSingleIP() {
s = e.Prefix.Addr().String()
} else {
s = e.Prefix.String()
}
case AMKey:
s = "key " + quoteIfNeeded(e.Name)
case AMName:
s = quoteIfNeeded(e.Name)
case AMNested:
parts := make([]string, len(e.Nested))
for i, n := range e.Nested {
parts[i] = n.String() + ";"
}
s = "{ " + strings.Join(parts, " ") + " }"
case AMOther:
s = e.Raw
}
if e.Negated {
return "!" + s
}
return s
}
// IsBuiltin reports whether the element is one of the built-in ACL names.
func (e AMElement) IsBuiltin() bool { return e.Kind == AMName && BuiltinACLs[e.Name] }
func quoteIfNeeded(s string) string {
for i := 0; i < len(s); i++ {
c := s[i]
if !(c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' || c == '-' || c == '_' || c == '.') {
return QuoteString(s)
}
}
if s == "" {
return `""`
}
return s
}
// ParseAMList parses the statements of an address match list block.
func ParseAMList(b *Block) ([]AMElement, error) {
var out []AMElement
for _, st := range b.Stmts {
e, err := parseAMItems(st.Items)
if err != nil {
return nil, &Error{Pos: st.Pos, Msg: err.Error()}
}
out = append(out, e)
}
return out, nil
}
// ParseAMElement parses a single element given as text, such as
// "!192.168.1.0/24", "key xfer" or "trusted".
func ParseAMElement(s string) (AMElement, error) {
s = strings.TrimSpace(strings.TrimSuffix(strings.TrimSpace(s), ";"))
if s == "" {
return AMElement{}, errors.New("empty element")
}
st, err := ParseStatement(s)
if err != nil {
var pe *Error
if errors.As(err, &pe) {
return AMElement{}, errors.New(pe.Msg)
}
return AMElement{}, err
}
return parseAMItems(st.Items)
}
// ParseAMListString parses a whole list given as "{ a; b; }" or "a; b".
func ParseAMListString(s string) ([]AMElement, error) {
s = strings.TrimSpace(s)
s = strings.TrimSuffix(s, ";")
s = strings.TrimSpace(s)
if !strings.HasPrefix(s, "{") {
s = "{ " + s + "; }"
}
st, err := ParseStatement("x " + s)
if err != nil {
return nil, err
}
b := st.FirstBlock()
if b == nil || len(st.Items) != 2 {
return nil, errors.New("malformed address match list")
}
return ParseAMList(b)
}
func parseAMItems(items []Item) (AMElement, error) {
var e AMElement
if len(items) > 0 && items[0].Bang {
e.Negated = true
items = items[1:]
}
if len(items) == 0 {
return e, errors.New("empty address match element")
}
first := items[0]
if first.Block != nil {
if len(items) != 1 {
return e, errors.New("unexpected text after nested address match list")
}
nested, err := ParseAMList(first.Block)
if err != nil {
return e, err
}
e.Kind = AMNested
e.Nested = nested
return e, nil
}
if first.Bang {
return e, errors.New("double negation")
}
word := first.Value.Text
lw := strings.ToLower(word)
if !first.Value.Quoted && lw == "key" {
if len(items) != 2 || items[1].Value == nil {
return e, errors.New("'key' must be followed by a key name")
}
e.Kind = AMKey
e.Name = strings.ToLower(strings.TrimSuffix(items[1].Value.Text, "."))
return e, nil
}
if len(items) > 1 {
// geoip and other multi-word elements are passed through.
if lw == "geoip" {
e.Kind = AMOther
e.Raw = Compact(items)
return e, nil
}
return e, fmt.Errorf("unexpected text after %q", word)
}
if !first.Value.Quoted || BuiltinACLs[lw] {
if p, ok, err := parsePrefix(word); ok {
if err != nil {
return e, err
}
e.Kind = AMPrefix
e.Prefix = p
return e, nil
}
}
if BuiltinACLs[lw] {
e.Kind = AMName
e.Name = lw
return e, nil
}
if err := CheckACLName(word); err != nil {
return e, err
}
e.Kind = AMName
e.Name = word
return e, nil
}
// CheckACLName validates the name of a user-defined ACL.
func CheckACLName(name string) error {
if name == "" {
return errors.New("ACL name is empty")
}
if len(name) > 64 {
return errors.New("ACL name is longer than 64 characters")
}
for i := 0; i < len(name); i++ {
c := name[i]
if !(c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' || c == '-' || c == '_' || c == '.') {
return fmt.Errorf("ACL name %q may only contain letters, digits, '-', '_' and '.'", name)
}
}
if c := name[0]; c >= '0' && c <= '9' {
// Avoid names that look like addresses.
if _, ok, _ := parsePrefix(name); ok {
return fmt.Errorf("ACL name %q looks like an address", name)
}
}
return nil
}
// parsePrefix parses an address or prefix as BIND does, including the IPv4
// shorthand where trailing zero octets are omitted ("10/8", "192.168/16").
// ok is false if s doesn't look like an address at all. Prefixes with host
// bits set are rejected, matching named's "address/prefix length mismatch".
func parsePrefix(s string) (netip.Prefix, bool, error) {
addrPart, lenPart, hasLen := strings.Cut(s, "/")
if strings.Contains(addrPart, ":") {
a, err := netip.ParseAddr(addrPart)
if err != nil {
return netip.Prefix{}, true, fmt.Errorf("invalid IPv6 address %q", addrPart)
}
if a.Zone() != "" {
return netip.Prefix{}, true, fmt.Errorf("zoned IPv6 address %q is not allowed", addrPart)
}
return finishPrefix(s, a, lenPart, hasLen, 128)
}
// IPv4 (possibly shorthand). Must be digits and dots only.
if addrPart == "" {
return netip.Prefix{}, false, nil
}
for i := 0; i < len(addrPart); i++ {
if c := addrPart[i]; !(c >= '0' && c <= '9' || c == '.') {
return netip.Prefix{}, false, nil
}
}
octets := strings.Split(addrPart, ".")
if len(octets) > 4 {
return netip.Prefix{}, true, fmt.Errorf("invalid IPv4 address %q", addrPart)
}
if len(octets) < 4 && !hasLen {
return netip.Prefix{}, true, fmt.Errorf("invalid IPv4 address %q (abbreviated addresses need a prefix length)", addrPart)
}
var b [4]byte
for i, o := range octets {
if o == "" || len(o) > 3 {
return netip.Prefix{}, true, fmt.Errorf("invalid IPv4 address %q", addrPart)
}
n, err := strconv.Atoi(o)
if err != nil || n > 255 {
return netip.Prefix{}, true, fmt.Errorf("invalid IPv4 address %q", addrPart)
}
b[i] = byte(n)
}
return finishPrefix(s, netip.AddrFrom4(b), lenPart, hasLen, 32)
}
func finishPrefix(orig string, a netip.Addr, lenPart string, hasLen bool, max int) (netip.Prefix, bool, error) {
bits := max
if hasLen {
n, err := strconv.Atoi(lenPart)
if err != nil || n < 0 || n > max {
return netip.Prefix{}, true, fmt.Errorf("invalid prefix length in %q", orig)
}
bits = n
}
p := netip.PrefixFrom(a, bits)
if p.Masked() != p {
return netip.Prefix{}, true, fmt.Errorf("%q: address/prefix length mismatch (host bits are set; did you mean %s?)", orig, p.Masked())
}
return p, true, nil
}
// ParseAddr parses a plain IP address (no prefix) as used by forwarders,
// primaries and similar options.
func ParseAddr(s string) (netip.Addr, error) {
a, err := netip.ParseAddr(s)
if err != nil {
return netip.Addr{}, fmt.Errorf("invalid IP address %q", s)
}
return a, nil
}
// AMRefs returns the ACL names and key names referenced by elements,
// including nested lists. Built-in ACLs are not included.
func AMRefs(elems []AMElement) (acls, keys []string) {
var walk func([]AMElement)
walk = func(es []AMElement) {
for _, e := range es {
switch e.Kind {
case AMName:
if !BuiltinACLs[e.Name] {
acls = append(acls, e.Name)
}
case AMKey:
keys = append(keys, e.Name)
case AMNested:
walk(e.Nested)
}
}
}
walk(elems)
return acls, keys
}
// Shadowed returns human-readable warnings for elements that can never
// match because an earlier element always matches first (BIND uses first
// match). Only address/prefix elements and "any" are considered.
func Shadowed(elems []AMElement) []string {
var warns []string
for i, e := range elems {
if e.Kind != AMPrefix && e.Kind != AMName {
continue
}
for j := 0; j < i; j++ {
prev := elems[j]
if prev.Kind == AMName && prev.Name == "any" {
warns = append(warns, fmt.Sprintf("element %q never matches: %q before it already matches every address", e.String(), prev.String()))
break
}
if prev.Kind == AMPrefix && e.Kind == AMPrefix &&
prev.Prefix.Addr().Is4() == e.Prefix.Addr().Is4() &&
prev.Prefix.Bits() <= e.Prefix.Bits() && prev.Prefix.Contains(e.Prefix.Addr()) {
warns = append(warns, fmt.Sprintf("element %q never matches: %q before it already covers it (first match wins; put the more specific element first)", e.String(), prev.String()))
break
}
}
}
return warns
}
// ContainsAny reports whether elems contain a non-negated "any" before any
// negated element would restrict it, which makes the list match everyone.
// It is used for security lints (open resolvers, open transfers).
func ContainsAny(elems []AMElement) bool {
for _, e := range elems {
if e.Negated {
return false
}
if e.Kind == AMName && e.Name == "any" {
return true
}
if e.Kind == AMPrefix && e.Prefix.Bits() == 0 {
return true
}
}
return false
}
+157
View File
@@ -0,0 +1,157 @@
package namedconf
import (
"fmt"
"strings"
)
// Value is a single word or quoted string.
type Value struct {
Text string // unescaped text
Quoted bool
Pos Pos
}
// Block is a brace-enclosed list of statements.
type Block struct {
Stmts []*Stmt
Pos Pos
}
// Item is one element of a statement: either a value, a "!" negation
// marker, or a block.
type Item struct {
Value *Value
Block *Block
Bang bool
}
// Stmt is a statement: a sequence of items terminated by a semicolon.
type Stmt struct {
Items []Item
Pos Pos
}
// Keyword returns the statement's first word, lowercased, or "" if the
// statement doesn't start with a value.
func (s *Stmt) Keyword() string {
if len(s.Items) == 0 || s.Items[0].Value == nil {
return ""
}
return strings.ToLower(s.Items[0].Value.Text)
}
// Arg returns the text of the n-th value after the keyword (0-based), or "".
func (s *Stmt) Arg(n int) string {
idx := 0
for _, it := range s.Items[1:] {
if it.Value == nil {
break
}
if idx == n {
return it.Value.Text
}
idx++
}
return ""
}
// Values returns the texts of the leading values after the keyword, up to
// the first block.
func (s *Stmt) Values() []string {
var out []string
for _, it := range s.Items[1:] {
if it.Value == nil {
break
}
out = append(out, it.Value.Text)
}
return out
}
// FirstBlock returns the statement's first block, or nil.
func (s *Stmt) FirstBlock() *Block {
for _, it := range s.Items {
if it.Block != nil {
return it.Block
}
}
return nil
}
// Rest returns a copy of the statement without its keyword.
func (s *Stmt) Rest() *Stmt {
if len(s.Items) == 0 {
return &Stmt{Pos: s.Pos}
}
return &Stmt{Items: append([]Item(nil), s.Items[1:]...), Pos: s.Pos}
}
// Find returns the statements in stmts with the given keyword.
func Find(stmts []*Stmt, keyword string) []*Stmt {
var out []*Stmt
for _, s := range stmts {
if s.Keyword() == keyword {
out = append(out, s)
}
}
return out
}
// FindOne returns the first statement with the given keyword, or nil.
func FindOne(stmts []*Stmt, keyword string) *Stmt {
for _, s := range stmts {
if s.Keyword() == keyword {
return s
}
}
return nil
}
// NewStmt builds a statement from values: strings become unquoted words and
// *Block become blocks. Use Q to make a quoted string.
func NewStmt(items ...any) *Stmt {
st := &Stmt{}
for _, it := range items {
switch v := it.(type) {
case string:
st.Items = append(st.Items, Item{Value: &Value{Text: v}})
case Quoted:
st.Items = append(st.Items, Item{Value: &Value{Text: string(v), Quoted: true}})
case *Block:
st.Items = append(st.Items, Item{Block: v})
case Item:
st.Items = append(st.Items, v)
default:
panic(fmt.Sprintf("namedconf.NewStmt: unsupported item %T", it))
}
}
return st
}
// Quoted marks a string for NewStmt as a quoted string.
type Quoted string
// NewBlock builds a block from statements.
func NewBlock(stmts ...*Stmt) *Block { return &Block{Stmts: stmts} }
// Clone returns a deep copy of the statement.
func (s *Stmt) Clone() *Stmt {
out := &Stmt{Pos: s.Pos, Items: make([]Item, len(s.Items))}
for i, it := range s.Items {
switch {
case it.Value != nil:
v := *it.Value
out.Items[i] = Item{Value: &v}
case it.Block != nil:
b := &Block{Pos: it.Block.Pos}
for _, st := range it.Block.Stmts {
b.Stmts = append(b.Stmts, st.Clone())
}
out.Items[i] = Item{Block: b}
default:
out.Items[i] = it
}
}
return out
}
+189
View File
@@ -0,0 +1,189 @@
// Package namedconf parses and prints the named.conf configuration syntax.
//
// The parser is deliberately generic: a configuration is a list of
// statements, and each statement is a sequence of values (words or quoted
// strings) and blocks, terminated by a semicolon. This is enough to
// represent every BIND 9 statement without knowing its grammar, which is
// checked separately by the options catalog and, ultimately, by
// named-checkconf.
package namedconf
import (
"fmt"
"strings"
)
// Pos is a position in a configuration file.
type Pos struct {
File string
Line int
}
func (p Pos) String() string {
if p.File == "" {
return fmt.Sprintf("line %d", p.Line)
}
return fmt.Sprintf("%s:%d", p.File, p.Line)
}
type tokenKind int
const (
tokEOF tokenKind = iota
tokWord
tokString
tokLBrace
tokRBrace
tokSemi
tokBang
)
func (k tokenKind) String() string {
switch k {
case tokEOF:
return "end of input"
case tokWord:
return "word"
case tokString:
return "quoted string"
case tokLBrace:
return "'{'"
case tokRBrace:
return "'}'"
case tokSemi:
return "';'"
case tokBang:
return "'!'"
}
return "?"
}
type token struct {
kind tokenKind
text string
pos Pos
}
// Error is a syntax error with a position.
type Error struct {
Pos Pos
Msg string
}
func (e *Error) Error() string { return e.Pos.String() + ": " + e.Msg }
type lexer struct {
src string
i int
line int
file string
}
func (l *lexer) errorf(format string, args ...any) error {
return &Error{Pos: Pos{File: l.file, Line: l.line}, Msg: fmt.Sprintf(format, args...)}
}
// next returns the next token, skipping whitespace and all three comment
// styles (/* */, // and #).
func (l *lexer) next() (token, error) {
for {
if l.i >= len(l.src) {
return token{kind: tokEOF, pos: Pos{l.file, l.line}}, nil
}
c := l.src[l.i]
switch {
case c == '\n':
l.line++
l.i++
case c == ' ' || c == '\t' || c == '\r' || c == '\f' || c == '\v':
l.i++
case c == '#':
l.skipLine()
case c == '/' && l.peek(1) == '/':
l.skipLine()
case c == '/' && l.peek(1) == '*':
start := l.line
end := strings.Index(l.src[l.i+2:], "*/")
if end < 0 {
l.line = start
return token{}, l.errorf("unterminated /* comment")
}
l.line += strings.Count(l.src[l.i:l.i+2+end], "\n")
l.i += 2 + end + 2
default:
return l.lexToken()
}
}
}
func (l *lexer) peek(n int) byte {
if l.i+n < len(l.src) {
return l.src[l.i+n]
}
return 0
}
func (l *lexer) skipLine() {
for l.i < len(l.src) && l.src[l.i] != '\n' {
l.i++
}
}
func (l *lexer) lexToken() (token, error) {
pos := Pos{l.file, l.line}
c := l.src[l.i]
switch c {
case '{':
l.i++
return token{tokLBrace, "{", pos}, nil
case '}':
l.i++
return token{tokRBrace, "}", pos}, nil
case ';':
l.i++
return token{tokSemi, ";", pos}, nil
case '!':
l.i++
return token{tokBang, "!", pos}, nil
case '"':
var sb strings.Builder
l.i++
for {
if l.i >= len(l.src) {
return token{}, &Error{Pos: pos, Msg: "unterminated quoted string"}
}
c := l.src[l.i]
if c == '\\' && l.i+1 < len(l.src) {
sb.WriteByte(l.src[l.i+1])
if l.src[l.i+1] == '\n' {
l.line++
}
l.i += 2
continue
}
if c == '"' {
l.i++
break
}
if c == '\n' {
l.line++
}
sb.WriteByte(c)
l.i++
}
return token{tokString, sb.String(), pos}, nil
}
start := l.i
for l.i < len(l.src) {
c := l.src[l.i]
if c == ' ' || c == '\t' || c == '\r' || c == '\n' || c == '\f' || c == '\v' ||
c == '{' || c == '}' || c == ';' || c == '"' || c == '!' || c == '#' {
break
}
if c == '/' && (l.peek(1) == '/' || l.peek(1) == '*') {
break
}
l.i++
}
return token{tokWord, l.src[start:l.i], pos}, nil
}
+195
View File
@@ -0,0 +1,195 @@
package namedconf
import (
"strings"
"testing"
)
const sample = `
// Debian style config
include "/etc/bind/rndc.key"; # shell comment
/* block
comment */
acl "trusted" { 192.168.1.0/24; !192.168.1.66; key "xfer-key"; { 10/8; }; };
controls {
inet 127.0.0.1 port 953 allow { 127.0.0.1; } keys { "rndc-key"; };
};
options {
directory "/var/cache/bind";
allow-query { any; };
listen-on-v6 { "none"; };
forwarders port 53 { 1.1.1.1; 2606:4700:4700::1111 port 53; };
version "not \"telling\"";
};
zone "example.com" IN {
type primary;
file "/etc/bind/db.example.com";
};
`
func TestParseAndFormat(t *testing.T) {
stmts, err := Parse("named.conf", sample)
if err != nil {
t.Fatal(err)
}
if len(stmts) != 5 {
t.Fatalf("got %d statements", len(stmts))
}
if stmts[0].Keyword() != "include" || stmts[0].Arg(0) != "/etc/bind/rndc.key" {
t.Errorf("include parsed wrong: %+v", stmts[0])
}
acl := stmts[1]
if acl.Keyword() != "acl" || acl.Arg(0) != "trusted" {
t.Errorf("acl parsed wrong")
}
elems, err := ParseAMList(acl.FirstBlock())
if err != nil {
t.Fatal(err)
}
var got []string
for _, e := range elems {
got = append(got, e.String())
}
want := "192.168.1.0/24|!192.168.1.66|key xfer-key|{ 10.0.0.0/8; }"
if strings.Join(got, "|") != want {
t.Errorf("acl elements = %q want %q", strings.Join(got, "|"), want)
}
opts := FindOne(stmts, "options")
ver := FindOne(opts.FirstBlock().Stmts, "version")
if ver.Arg(0) != `not "telling"` {
t.Errorf("escaped string = %q", ver.Arg(0))
}
out := Format(stmts)
// Re-parse the output and format again: must be stable.
stmts2, err := Parse("out", out)
if err != nil {
t.Fatalf("re-parse: %v\n%s", err, out)
}
if out2 := Format(stmts2); out2 != out {
t.Errorf("format not stable:\n%s\n---\n%s", out, out2)
}
if !strings.Contains(out, `version "not \"telling\"";`) {
t.Errorf("quoted escape lost:\n%s", out)
}
z := FindOne(stmts, "zone")
if z.Arg(0) != "example.com" || z.Arg(1) != "IN" || FindOne(z.FirstBlock().Stmts, "type").Arg(0) != "primary" {
t.Errorf("zone parsed wrong")
}
if line := z.Pos.Line; line != 17 {
t.Errorf("zone line = %d", line)
}
}
func TestParseErrors(t *testing.T) {
bad := map[string]string{
`options { directory "x" };`: "missing ';' before '}'",
`options { directory "x"; `: "missing '}'",
`zone "x" { type primary; }`: "missing ';' at end of statement",
`};`: "unexpected '}'",
`options { version "abc; };`: "unterminated quoted string",
`/* never closed`: "unterminated /* comment",
}
for src, want := range bad {
_, err := Parse("t", src)
if err == nil || !strings.Contains(err.Error(), want) {
t.Errorf("Parse(%q) error = %v, want %q", src, err, want)
}
}
}
func TestAMElements(t *testing.T) {
good := map[string]string{
"192.168.1.0/24": "192.168.1.0/24",
"!10.0.0.1": "!10.0.0.1",
"! 10.0.0.1": "!10.0.0.1",
"10/8": "10.0.0.0/8",
"192.168/16": "192.168.0.0/16",
"2001:db8::/32": "2001:db8::/32",
"key xfer": "key xfer",
`key "rndc-key"`: "key rndc-key",
"any": "any",
`"none"`: "none",
"LocalHost": "localhost",
"trusted": "trusted",
"{ 1.2.3.4; any; }": "{ 1.2.3.4; any; }",
"10.0.0.5/32": "10.0.0.5",
}
for in, want := range good {
e, err := ParseAMElement(in)
if err != nil {
t.Errorf("ParseAMElement(%q): %v", in, err)
continue
}
if e.String() != want {
t.Errorf("ParseAMElement(%q) = %q want %q", in, e.String(), want)
}
}
bad := []string{"10.0.0.5/8", "300.1.1.1", "10.1", "key", "1.2.3.4 5.6.7.8", "2001:db8::1/200", "fe80::1%eth0", "bad name!"}
for _, in := range bad {
if e, err := ParseAMElement(in); err == nil {
t.Errorf("ParseAMElement(%q) = %q, want error", in, e.String())
}
}
e, err := ParseAMElement("10.0.0.5/8")
if err == nil || !strings.Contains(err.Error(), "did you mean 10.0.0.0/8") {
t.Errorf("host bits error = %v (%v)", err, e)
}
}
func TestShadowedAndRefs(t *testing.T) {
elems, err := ParseAMListString("192.168.1.0/24; !192.168.1.13; trusted; key k1; { key k2; other; }")
if err != nil {
t.Fatal(err)
}
w := Shadowed(elems)
if len(w) != 1 || !strings.Contains(w[0], "192.168.1.13") {
t.Errorf("Shadowed = %v", w)
}
acls, keys := AMRefs(elems)
if strings.Join(acls, ",") != "trusted,other" || strings.Join(keys, ",") != "k1,k2" {
t.Errorf("AMRefs = %v %v", acls, keys)
}
anyList, _ := ParseAMListString("{ any; }")
if !ContainsAny(anyList) {
t.Error("ContainsAny(any) false")
}
neg, _ := ParseAMListString("!10.0.0.1; any")
if ContainsAny(neg) {
t.Error("ContainsAny with leading negation true")
}
if w := Shadowed(anyList); len(w) != 0 {
t.Errorf("unexpected %v", w)
}
w2, _ := ParseAMListString("any; 10.0.0.0/8")
if len(Shadowed(w2)) != 1 {
t.Error("element after any not reported")
}
}
func TestParseOptionValue(t *testing.T) {
st, err := ParseOptionValue("allow-query", "{ 10.0.0.0/8; localhost; };")
if err != nil {
t.Fatal(err)
}
if got := CompactStmt(st); got != "allow-query { 10.0.0.0/8; localhost; };" {
t.Errorf("compact = %q", got)
}
if _, err := ParseOptionValue("allow-query", "{ 10.0.0.0/8; "); err == nil {
t.Error("unbalanced accepted")
}
if _, err := ParseOptionValue("recursion", "yes; recursion no"); err == nil {
t.Error("two statements accepted")
}
if _, err := ParseOptionValue("recursion", ""); err == nil {
t.Error("empty accepted")
}
}
func TestFormatLayout(t *testing.T) {
stmts, _ := Parse("", `options { allow-recursion { 10.0.0.0/8; 172.16.0.0/12; 192.168.0.0/16; 100.64.0.0/10; fd00::/8; localhost; }; recursion yes; };`)
out := Format(stmts)
want := "options {\n\tallow-recursion {\n\t\t10.0.0.0/8;\n\t\t172.16.0.0/12;\n\t\t192.168.0.0/16;\n\t\t100.64.0.0/10;\n\t\tfd00::/8;\n\t\tlocalhost;\n\t};\n\trecursion yes;\n};\n"
if out != want {
t.Errorf("layout:\n%s\nwant:\n%s", out, want)
}
}
+148
View File
@@ -0,0 +1,148 @@
package namedconf
import (
"errors"
"fmt"
"strings"
)
// Parse parses a complete configuration (or configuration fragment). The
// file name is only used in error messages and positions. Include
// directives are returned as ordinary statements; callers that want to
// follow them must do so themselves.
func Parse(file, src string) ([]*Stmt, error) {
p := &parser{lex: &lexer{src: src, line: 1, file: file}}
if err := p.advance(); err != nil {
return nil, err
}
stmts, err := p.stmts(false)
if err != nil {
return nil, err
}
return stmts, nil
}
type parser struct {
lex *lexer
tok token
}
func (p *parser) advance() error {
t, err := p.lex.next()
if err != nil {
return err
}
p.tok = t
return nil
}
func (p *parser) errorf(format string, args ...any) error {
return &Error{Pos: p.tok.pos, Msg: fmt.Sprintf(format, args...)}
}
// stmts parses statements until EOF (top level) or a closing brace.
func (p *parser) stmts(inBlock bool) ([]*Stmt, error) {
var out []*Stmt
for {
switch p.tok.kind {
case tokEOF:
if inBlock {
return nil, p.errorf("missing '}' (unexpected end of input)")
}
return out, nil
case tokRBrace:
if !inBlock {
return nil, p.errorf("unexpected '}'")
}
return out, nil
case tokSemi:
// Empty statement; BIND tolerates stray semicolons.
if err := p.advance(); err != nil {
return nil, err
}
continue
}
st, err := p.stmt()
if err != nil {
return nil, err
}
out = append(out, st)
}
}
func (p *parser) stmt() (*Stmt, error) {
st := &Stmt{Pos: p.tok.pos}
for {
switch p.tok.kind {
case tokSemi:
if err := p.advance(); err != nil {
return nil, err
}
return st, nil
case tokEOF:
return nil, &Error{Pos: st.Pos, Msg: "missing ';' at end of statement"}
case tokRBrace:
return nil, p.errorf("missing ';' before '}'")
case tokWord:
st.Items = append(st.Items, Item{Value: &Value{Text: p.tok.text, Pos: p.tok.pos}})
case tokString:
st.Items = append(st.Items, Item{Value: &Value{Text: p.tok.text, Quoted: true, Pos: p.tok.pos}})
case tokBang:
st.Items = append(st.Items, Item{Bang: true})
case tokLBrace:
pos := p.tok.pos
if err := p.advance(); err != nil {
return nil, err
}
inner, err := p.stmts(true)
if err != nil {
return nil, err
}
st.Items = append(st.Items, Item{Block: &Block{Stmts: inner, Pos: pos}})
// p.tok is now '}'.
}
if err := p.advance(); err != nil {
return nil, err
}
}
}
// ParseStatement parses exactly one statement. A missing trailing
// semicolon is added.
func ParseStatement(src string) (*Stmt, error) {
s := strings.TrimSpace(src)
if !strings.HasSuffix(s, ";") {
s += ";"
}
stmts, err := Parse("", s)
if err != nil {
return nil, err
}
if len(stmts) != 1 {
return nil, fmt.Errorf("expected exactly one statement, found %d", len(stmts))
}
return stmts[0], nil
}
// ParseOptionValue parses the value part of an option (everything after
// the option name), for example `{ 10.0.0.0/8; localhost; }` for
// allow-query. It returns the statement "name value;".
func ParseOptionValue(name, value string) (*Stmt, error) {
v := strings.TrimSpace(value)
v = strings.TrimSuffix(v, ";")
if strings.TrimSpace(v) == "" {
return nil, fmt.Errorf("option %s needs a value", name)
}
st, err := ParseStatement(name + " " + v)
if err != nil {
var pe *Error
if errors.As(err, &pe) {
return nil, errors.New(pe.Msg)
}
return nil, err
}
if st.Keyword() != strings.ToLower(name) {
return nil, fmt.Errorf("value for %s is malformed", name)
}
return st, nil
}
+160
View File
@@ -0,0 +1,160 @@
package namedconf
import (
"strings"
)
// maxInline is the widest a block may be to be printed on one line.
const maxInline = 72
// Format returns stmts in canonical form, one statement per line, with
// nested blocks indented by tabs.
func Format(stmts []*Stmt) string {
var sb strings.Builder
for _, s := range stmts {
writeStmt(&sb, s, 0)
}
return sb.String()
}
// FormatStmt returns a single statement in canonical form, indented by
// indent tabs, including the trailing newline.
func FormatStmt(s *Stmt, indent int) string {
var sb strings.Builder
writeStmt(&sb, s, indent)
return sb.String()
}
// Compact returns the statement's items on a single line, without the
// trailing semicolon: `allow-query { 10.0.0.0/8; localhost; }`.
func Compact(items []Item) string {
var sb strings.Builder
writeItemsInline(&sb, items)
return sb.String()
}
// CompactStmt returns the statement on a single line with its semicolon.
func CompactStmt(s *Stmt) string { return Compact(s.Items) + ";" }
// QuoteString returns s as a named.conf quoted string.
func QuoteString(s string) string {
var sb strings.Builder
sb.WriteByte('"')
for i := 0; i < len(s); i++ {
if s[i] == '"' || s[i] == '\\' {
sb.WriteByte('\\')
}
sb.WriteByte(s[i])
}
sb.WriteByte('"')
return sb.String()
}
func valueText(v *Value) string {
if v.Quoted {
return QuoteString(v.Text)
}
return v.Text
}
func writeStmt(sb *strings.Builder, s *Stmt, indent int) {
tabs := strings.Repeat("\t", indent)
sb.WriteString(tabs)
inline := Compact(s.Items)
if !hasNestedBlocks(s.Items) && len(inline)+indent*8 <= maxInline+8 {
sb.WriteString(inline)
sb.WriteString(";\n")
return
}
needSpace := false
for _, it := range s.Items {
switch {
case it.Bang:
if needSpace {
sb.WriteByte(' ')
}
sb.WriteByte('!')
needSpace = false
case it.Value != nil:
if needSpace {
sb.WriteByte(' ')
}
sb.WriteString(valueText(it.Value))
needSpace = true
case it.Block != nil:
if needSpace {
sb.WriteByte(' ')
}
blockInline := Compact([]Item{it})
if !blockHasBlocks(it.Block) && len(blockInline) <= 40 {
sb.WriteString(blockInline)
} else if len(it.Block.Stmts) == 0 {
sb.WriteString("{ }")
} else {
sb.WriteString("{\n")
for _, inner := range it.Block.Stmts {
writeStmt(sb, inner, indent+1)
}
sb.WriteString(tabs)
sb.WriteString("}")
}
needSpace = true
}
}
sb.WriteString(";\n")
}
func hasNestedBlocks(items []Item) bool {
for _, it := range items {
if it.Block != nil && blockHasBlocks(it.Block) {
return true
}
}
return false
}
func blockHasBlocks(b *Block) bool {
for _, s := range b.Stmts {
for _, it := range s.Items {
if it.Block != nil {
return true
}
}
}
return false
}
func writeItemsInline(sb *strings.Builder, items []Item) {
needSpace := false
for _, it := range items {
switch {
case it.Bang:
if needSpace {
sb.WriteByte(' ')
}
sb.WriteByte('!')
needSpace = false
case it.Value != nil:
if needSpace {
sb.WriteByte(' ')
}
sb.WriteString(valueText(it.Value))
needSpace = true
case it.Block != nil:
if needSpace {
sb.WriteByte(' ')
}
if len(it.Block.Stmts) == 0 {
sb.WriteString("{ }")
} else {
sb.WriteString("{ ")
for _, s := range it.Block.Stmts {
writeItemsInline(sb, s.Items)
sb.WriteString("; ")
}
sb.WriteString("}")
}
needSpace = true
}
}
}