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>
This commit is contained in:
1 parent
49b1c872c9
commit
e2e5007f94
32 files changed
+905
-31
No files matched your search
+141
-8
@@ -6,6 +6,8 @@ import (
|
||||
"net/netip"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"jarl/internal/iprange"
|
||||
)
|
||||
|
||||
// AMKind is the kind of an address match list element.
|
||||
@@ -17,6 +19,9 @@ const (
|
||||
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.
|
||||
@@ -26,10 +31,11 @@ var BuiltinACLs = map[string]bool{"any": true, "none": true, "localhost": true,
|
||||
type AMElement struct {
|
||||
Negated bool
|
||||
Kind AMKind
|
||||
Prefix netip.Prefix // AMPrefix
|
||||
Name string // AMKey, AMName
|
||||
Nested []AMElement // AMNested
|
||||
Raw string // AMOther
|
||||
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
|
||||
@@ -55,6 +61,8 @@ func (e AMElement) String() string {
|
||||
s = "{ " + strings.Join(parts, " ") + " }"
|
||||
case AMOther:
|
||||
s = e.Raw
|
||||
case AMRange:
|
||||
s = e.Range.String()
|
||||
}
|
||||
if e.Negated {
|
||||
return "!" + s
|
||||
@@ -137,6 +145,7 @@ func parseAMItems(items []Item) (AMElement, error) {
|
||||
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 {
|
||||
@@ -172,6 +181,16 @@ func parseAMItems(items []Item) (AMElement, error) {
|
||||
}
|
||||
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 {
|
||||
@@ -215,6 +234,9 @@ func CheckACLName(name string) error {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -318,18 +340,18 @@ func AMRefs(elems []AMElement) (acls, keys []string) {
|
||||
func Shadowed(elems []AMElement) []string {
|
||||
var warns []string
|
||||
for i, e := range elems {
|
||||
if e.Kind != AMPrefix && e.Kind != AMName {
|
||||
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 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()) {
|
||||
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
|
||||
}
|
||||
@@ -338,6 +360,117 @@ func Shadowed(elems []AMElement) []string {
|
||||
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).
|
||||
|
||||
Reference in new issue
Block a user