Files
jarl/internal/engine/refs.go
T
adamandClaude Opus 5.5 76aaaf3ef5 Different blocking for different devices: steps and client groups
Client groups put devices (addresses, networks, or ACLs of them) at a
blocking step: off (only your rules), normal (the default), or steps
added above it, each including the lists of the steps below. Lists
start at a step. In each view, a gate policy zone in front of a step's
lists lets the devices below that step through (rpz-client-ip triggers
with rpz-passthru), so every list is still loaded once. The IPv6 part
of a gate leaves the IPv4-mapped range out: named matches IPv4 clients
as ::ffff:a.b.c.d.

Web UI: a Devices panel on the Blocking page and a step on each list.
CLI: jarl block step, jarl block group, jarl blocklist --step. API:
/blocking/steps, /blocking/groups. Database schema 8.

Also: copying the state kept empty lists as empty (not nil), so
reverting a changeset no longer adds restore operations for unchanged
blocklists, rules and views.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 21:33:16 +01:00

225 lines
5.9 KiB
Go

package engine
import (
"fmt"
"sort"
"strings"
"jarl/internal/catalog"
"jarl/internal/dnsname"
"jarl/internal/model"
"jarl/internal/namedconf"
)
type refKind int
const (
refACL refKind = iota
refKey
)
func sameRef(kind refKind, a, b string) bool {
if kind == refKey {
return model.NormalizeKeyName(a) == model.NormalizeKeyName(b)
}
return strings.EqualFold(a, b)
}
// rewriteElements renames references in ACL-style element strings. It
// returns the new list and whether anything matched.
func rewriteElements(list []string, kind refKind, old, new string) ([]string, bool) {
changed := false
var fix func(es []namedconf.AMElement) []namedconf.AMElement
fix = func(es []namedconf.AMElement) []namedconf.AMElement {
for i := range es {
e := &es[i]
switch {
case kind == refACL && e.Kind == namedconf.AMName && !e.IsBuiltin() && sameRef(kind, e.Name, old):
e.Name, changed = new, true
case kind == refKey && e.Kind == namedconf.AMKey && sameRef(kind, e.Name, old):
e.Name, changed = new, true
case e.Kind == namedconf.AMNested:
e.Nested = fix(e.Nested)
}
}
return es
}
out := make([]string, len(list))
for i, s := range list {
e, err := namedconf.ParseAMElement(s)
if err != nil {
out[i] = s
continue
}
res := fix([]namedconf.AMElement{e})
out[i] = res[0].String()
}
return out, changed
}
// walkRefs calls fn for every reference of the given kind in items. fn may
// change the value's text.
func walkRefs(items []namedconf.Item, kind refKind, aml bool, fn func(v *namedconf.Value)) {
for i := 0; i < len(items); i++ {
it := items[i]
if it.Value != nil && !it.Value.Quoted && i+1 < len(items) {
switch strings.ToLower(it.Value.Text) {
case "key":
if kind == refKey && items[i+1].Value != nil {
fn(items[i+1].Value)
}
i++
continue
case "keys":
next := items[i+1]
if kind == refKey {
if next.Value != nil {
fn(next.Value)
} else if next.Block != nil {
for _, s := range next.Block.Stmts {
if len(s.Items) == 1 && s.Items[0].Value != nil {
fn(s.Items[0].Value)
}
}
}
}
i++
continue
}
}
if it.Block == nil {
continue
}
for _, s := range it.Block.Stmts {
if kind == refACL && aml {
var v *namedconf.Value
switch {
case len(s.Items) == 1 && s.Items[0].Value != nil:
v = s.Items[0].Value
case len(s.Items) == 2 && s.Items[0].Bang && s.Items[1].Value != nil:
v = s.Items[1].Value
}
if v != nil && !namedconf.BuiltinACLs[strings.ToLower(v.Text)] {
fn(v)
continue
}
}
walkRefs(s.Items, kind, aml, fn)
}
}
}
// optionHasAML reports whether the option's grammar contains address match
// lists (so single words in its blocks are ACL names).
func optionHasAML(st *model.State, o *model.Option) bool {
cs := catalogScope(st, o.Scope, o.ScopeID)
stmt, ok := catalog.Default().Lookup(cs, o.Name)
return ok && strings.Contains(stmt.Grammar, "<address_match_element>")
}
func optionLabel(st *model.State, o *model.Option) string {
switch o.Scope {
case model.ScopeView:
if v := st.Views[o.ScopeID]; v != nil {
return fmt.Sprintf("view %s option %s", v.Name, o.Name)
}
case model.ScopeZone:
if z := st.Zones[o.ScopeID]; z != nil {
label := "zone " + dnsname.Display(z.Name)
if v := st.ViewName(z); v != "" {
label += " [view " + v + "]"
}
return label + " option " + o.Name
}
case model.ScopeTopLevel:
return "top-level statement " + o.Name
}
return "server option " + o.Name
}
// FindRefs lists the objects that refer to an ACL or key by name.
func FindRefs(st *model.State, kind refKind, name string) []string {
var out []string
for _, a := range st.SortedACLs() {
if _, hit := rewriteElements(a.Elements, kind, name, name); hit {
out = append(out, "ACL "+a.Name)
}
}
for _, v := range st.OrderedViews() {
_, h1 := rewriteElements(v.MatchClients, kind, name, name)
_, h2 := rewriteElements(v.MatchDestinations, kind, name, name)
if h1 || h2 {
out = append(out, "view "+v.Name)
}
}
for _, g := range st.SortedClientGroups() {
if _, hit := rewriteElements(g.Members, kind, name, name); hit {
out = append(out, "client group "+g.Name)
}
}
var opts []string
for _, o := range st.Options {
stmt, err := namedconf.ParseOptionValue(o.Name, o.Value)
if err != nil {
continue
}
hit := false
walkRefs(stmt.Items, kind, optionHasAML(st, o), func(v *namedconf.Value) {
if sameRef(kind, v.Text, name) {
hit = true
}
})
if hit {
opts = append(opts, optionLabel(st, o))
}
}
sort.Strings(opts)
return append(out, opts...)
}
// renameRefs rewrites references to an ACL or key after a rename.
func renameRefs(st *model.State, kind refKind, old, new string, c *applyCtx) {
for _, a := range st.ACLs {
if l, hit := rewriteElements(a.Elements, kind, old, new); hit {
a.Elements = l
a.Touch(c.user(), c.now)
}
}
for _, v := range st.Views {
l1, h1 := rewriteElements(v.MatchClients, kind, old, new)
l2, h2 := rewriteElements(v.MatchDestinations, kind, old, new)
if h1 || h2 {
v.MatchClients, v.MatchDestinations = l1, l2
v.Touch(c.user(), c.now)
}
}
for _, g := range st.ClientGroups {
if l, hit := rewriteElements(g.Members, kind, old, new); hit {
g.Members = l
g.Touch(c.user(), c.now)
}
}
for _, o := range st.Options {
stmt, err := namedconf.ParseOptionValue(o.Name, o.Value)
if err != nil {
continue
}
hit := false
walkRefs(stmt.Items, kind, optionHasAML(st, o), func(v *namedconf.Value) {
if sameRef(kind, v.Text, old) {
v.Text, hit = new, true
}
})
if hit {
o.Value = namedconf.Compact(stmt.Items[1:])
o.Touch(c.user(), c.now)
}
}
}
// FindACLRefs lists the objects that use the ACL called name.
func FindACLRefs(st *model.State, name string) []string { return FindRefs(st, refACL, name) }
// FindKeyRefs lists the objects that use the TSIG key called name.
func FindKeyRefs(st *model.State, name string) []string { return FindRefs(st, refKey, name) }