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>
358 lines
9.7 KiB
Go
358 lines
9.7 KiB
Go
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
|
|
}
|