Files
jarl/internal/namedconf/acl.go
T
abrown38andClaude Opus 5.5 bd6b8778e4 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>
2026-10-07 15:03:13 +00:00

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
}