Files
adamandClaude Opus 5.5 e2e5007f94 Address ranges in address match lists (IPv4 and IPv6)
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>
2026-10-11 23:17:14 +01:00

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
}