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>
This commit is contained in:
adamandClaude Opus 5.5 committed 2026-10-11 21:33:16 +01:00
1 parent 5b8f681e2a
commit 76aaaf3ef5
33 files changed
+2096 -55

No files matched your search

+94
View File
@@ -1,9 +1,11 @@
package validate
import (
"net/netip"
"strings"
"jarl/internal/model"
"jarl/internal/namedconf"
)
// checkBlocking checks blocklists and block/allow rules (docs/blocking.md).
@@ -67,6 +69,7 @@ func (c *checker) checkBlocking() {
seen[key] = r
}
}
c.checkClientGroups(effective)
// Jarl writes the response-policy statement where blocking is used;
// named allows one per view (or for the server).
for vid := range used {
@@ -91,3 +94,94 @@ func (c *checker) blockingScope(viewID int64) string {
}
return ""
}
// checkClientGroups checks blocking steps and client groups: steps exist,
// members are addresses (directly or through ACLs), and no address is in
// two groups of the same view.
func (c *checker) checkClientGroups(effective func(objType string, id int64, label string, ids []int64) []int64) {
for _, b := range c.s.SortedBlocklists() {
if _, ok := c.s.StepRank(b.StepID); !ok || b.StepID == model.StepOff {
c.errf("blocking_step_missing", "blocklist", b.ID, "blocklist %s is at step %d, which doesn't exist", b.Name, b.StepID)
}
}
// The highest step any group reaches, per view: lists above it block
// nothing.
top := map[int64]int{}
seen := map[string]*model.ClientGroup{} // view|prefix -> first group
for _, g := range c.s.SortedClientGroups() {
label := "client group " + g.Name
rank, ok := c.s.StepRank(g.StepID)
if !ok {
c.errf("blocking_step_missing", "clientgroup", g.ID, "%s is at step %d, which doesn't exist", label, g.StepID)
}
if len(g.Members) == 0 {
c.warnf("client_group_empty", "clientgroup", g.ID, "%s has no members", label)
}
for _, m := range g.Members {
c.checkMember(g, m, map[string]bool{})
}
views := effective("clientgroup", g.ID, label, g.ViewIDs)
prefixes := c.s.GroupPrefixes(g)
for _, vid := range views {
if len(prefixes) > 0 {
top[vid] = max(top[vid], rank)
}
for _, p := range prefixes {
key := c.blockingScope(vid) + "|" + p.String()
if first := seen[key]; first != nil && first.ID != g.ID {
c.errf("client_group_overlap", "clientgroup", g.ID, "%s is in client groups %s and %s; a client can only be in one (a more specific prefix in another group is fine)", p, first.Name, g.Name)
continue
}
seen[key] = g
}
}
}
for _, b := range c.s.SortedBlocklists() {
rank, ok := c.s.StepRank(b.StepID)
if !ok || rank == 0 || b.Disabled {
continue
}
reached := false
for _, vid := range c.s.BlockingViews(b.ViewIDs) {
if top[vid] >= rank {
reached = true
}
}
if !reached {
c.warnf("blocklist_step_unused", "blocklist", b.ID, "blocklist %s blocks nothing: it is at step %s, and no client group is at that step or above", b.Name, c.s.StepName(b.StepID))
}
}
}
// checkMember checks one member of a client group: an address, a prefix,
// or an ACL whose elements are (recursively) addresses and prefixes.
func (c *checker) checkMember(g *model.ClientGroup, m string, seen map[string]bool) {
if _, err := netip.ParseAddr(m); err == nil {
return
}
if _, err := netip.ParsePrefix(m); err == nil {
return
}
acl := c.s.ACLByName(m)
if acl == nil {
c.errf("client_group_member_invalid", "clientgroup", g.ID, "client group %s: %q is not an address, a prefix or an ACL", g.Name, m)
return
}
if seen[acl.Name] {
return
}
seen[acl.Name] = true
for _, e := range acl.Elements {
el, err := namedconf.ParseAMElement(e)
if err != nil {
continue // reported for the ACL
}
if el.Negated || el.Kind == namedconf.AMKey || el.Kind == namedconf.AMNested || el.Kind == namedconf.AMName && el.IsBuiltin() {
c.errf("client_group_member_invalid", "clientgroup", g.ID, "client group %s uses ACL %s, whose element %q isn't an address or a prefix: blocking matches clients by address only", g.Name, acl.Name, e)
continue
}
if el.Kind == namedconf.AMName {
c.checkMember(g, el.Name, seen)
}
}
}