ACLs, views' match lists, options with address match lists and blocking client groups take ranges such as 10.0.10.101-10.0.10.199 or 2001:db8::100-2001:db8::1ff (negated, or with spaces around the dash, too). Jarl stores the range as written and writes the fewest prefixes that cover exactly that range to named.conf, with the range as a comment. New package internal/iprange; namedconf gets an AMRange element (a range used to parse as an ACL name), ExpandRanges and statement comments. Mistakes are refused when entered, validation sees what ranges cover, and IPv6 ranges in the IPv4-mapped space get a warning. The web UI shows what a range becomes while typing (GET /catalog/ranges). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
491 lines
13 KiB
Go
491 lines
13 KiB
Go
package namedconf
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"net/netip"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"jarl/internal/iprange"
|
|
)
|
|
|
|
// 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
|
|
// AMRange is an address range, START-END: Jarl's own syntax, written
|
|
// to named.conf as the prefixes that cover it (ExpandRanges).
|
|
AMRange
|
|
)
|
|
|
|
// 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
|
|
Range iprange.Range // AMRange
|
|
}
|
|
|
|
// 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
|
|
case AMRange:
|
|
s = e.Range.String()
|
|
}
|
|
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")
|
|
}
|
|
items = joinRange(items)
|
|
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 {
|
|
if r, ok, err := iprange.Parse(word); ok {
|
|
if err != nil {
|
|
return e, err
|
|
}
|
|
e.Kind = AMRange
|
|
e.Range = r
|
|
return e, nil
|
|
}
|
|
}
|
|
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)
|
|
}
|
|
}
|
|
if _, ok, _ := iprange.Parse(name); ok {
|
|
return fmt.Errorf("ACL name %q looks like an address range", 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 != AMRange && e.Kind != AMName {
|
|
continue
|
|
}
|
|
er, eok := e.addrRange()
|
|
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 pr, ok := prev.addrRange(); ok && eok && pr.Start.Is4() == er.Start.Is4() &&
|
|
!er.Start.Less(pr.Start) && !pr.End.Less(er.End) {
|
|
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
|
|
}
|
|
|
|
// addrRange returns the addresses a prefix or range element covers.
|
|
func (e AMElement) addrRange() (iprange.Range, bool) {
|
|
switch e.Kind {
|
|
case AMRange:
|
|
return e.Range, true
|
|
case AMPrefix:
|
|
return iprange.FromPrefix(e.Prefix), true
|
|
}
|
|
return iprange.Range{}, false
|
|
}
|
|
|
|
// Expand returns the element as named.conf can take it: a range becomes
|
|
// the prefixes that cover it (each negated when the range is), nested
|
|
// lists are expanded too, and anything else is returned as it is.
|
|
func (e AMElement) Expand() []AMElement {
|
|
switch e.Kind {
|
|
case AMRange:
|
|
var out []AMElement
|
|
for _, p := range e.Range.Prefixes() {
|
|
out = append(out, AMElement{Negated: e.Negated, Kind: AMPrefix, Prefix: p})
|
|
}
|
|
return out
|
|
case AMNested:
|
|
n := e
|
|
n.Nested = nil
|
|
for _, in := range e.Nested {
|
|
n.Nested = append(n.Nested, in.Expand()...)
|
|
}
|
|
return []AMElement{n}
|
|
}
|
|
return []AMElement{e}
|
|
}
|
|
|
|
// joinRange turns a range written with spaces around the dash
|
|
// ("10.0.0.1 - 10.0.0.9", three words) into a single word.
|
|
func joinRange(items []Item) []Item {
|
|
if len(items) != 3 || items[0].Value == nil || items[1].Value == nil || items[2].Value == nil ||
|
|
items[0].Value.Quoted || items[1].Value.Quoted || items[2].Value.Quoted || items[1].Value.Text != "-" {
|
|
return items
|
|
}
|
|
text := items[0].Value.Text + "-" + items[2].Value.Text
|
|
if _, ok, _ := iprange.Parse(text); !ok {
|
|
return items
|
|
}
|
|
return []Item{{Value: &Value{Text: text, Pos: items[0].Value.Pos}}}
|
|
}
|
|
|
|
// JoinRanges rewrites ranges written with spaces around the dash, in st's
|
|
// blocks, as single words (the form Jarl stores).
|
|
func JoinRanges(st *Stmt) {
|
|
for _, it := range st.Items {
|
|
if it.Block == nil {
|
|
continue
|
|
}
|
|
for _, s := range it.Block.Stmts {
|
|
if len(s.Items) == 4 && s.Items[0].Bang {
|
|
s.Items = append(s.Items[:1], joinRange(s.Items[1:])...)
|
|
} else {
|
|
s.Items = joinRange(s.Items)
|
|
}
|
|
JoinRanges(s)
|
|
}
|
|
}
|
|
}
|
|
|
|
// ExpandRanges replaces the address ranges in st's blocks (at any depth)
|
|
// by the prefixes that cover them, with the range as a comment, so named
|
|
// can read it. A range is a block statement that is a single word START-END,
|
|
// optionally negated; nothing else in named.conf's grammar looks like one.
|
|
func ExpandRanges(st *Stmt) {
|
|
for _, it := range st.Items {
|
|
if it.Block != nil {
|
|
expandBlock(it.Block)
|
|
}
|
|
}
|
|
}
|
|
|
|
func expandBlock(b *Block) {
|
|
var out []*Stmt
|
|
for _, s := range b.Stmts {
|
|
items, neg := s.Items, false
|
|
if len(items) == 2 && items[0].Bang {
|
|
items, neg = items[1:], true
|
|
}
|
|
if len(items) == 1 && items[0].Value != nil && !items[0].Value.Quoted {
|
|
if r, ok, err := iprange.Parse(items[0].Value.Text); ok && err == nil {
|
|
for i, p := range r.Prefixes() {
|
|
var ns Stmt
|
|
if neg {
|
|
ns.Items = append(ns.Items, Item{Bang: true})
|
|
}
|
|
text := p.String()
|
|
if p.IsSingleIP() {
|
|
text = p.Addr().String()
|
|
}
|
|
ns.Items = append(ns.Items, Item{Value: &Value{Text: text}})
|
|
ns.Pos = s.Pos
|
|
if i == 0 {
|
|
ns.Comment = AMElement{Negated: neg, Kind: AMRange, Range: r}.String()
|
|
}
|
|
out = append(out, &ns)
|
|
}
|
|
continue
|
|
}
|
|
}
|
|
ExpandRanges(s)
|
|
out = append(out, s)
|
|
}
|
|
b.Stmts = out
|
|
}
|
|
|
|
// 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
|
|
}
|