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:
commit
bd6b8778e4
122 files changed
+29217
No files matched your search
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user