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

+79 -7
View File
@@ -2,6 +2,7 @@ package render
import (
"fmt"
"net/netip"
"path/filepath"
"slices"
"strings"
@@ -21,9 +22,16 @@ type PolicyZone struct {
ViewID int64 // 0 without views
View string // "" without views
File string
ListID int64 // the blocklist; 0 for the rules zone
ListID int64 // the blocklist; 0 for the rules zone and gates
// Gate, for a gate zone: the clients it lets through (past every zone
// after it), and the step whose lists it is in front of.
Gate []netip.Prefix
GateStep string
}
// IsGate reports whether z is a gate zone.
func (z PolicyZone) IsGate() bool { return z.GateStep != "" }
// BlocklistsDir holds the zone files of downloaded blocklists, one per
// list, shared by the views that use it.
func (p Paths) BlocklistsDir() string { return filepath.Join(p.CacheDir, "blocklists") }
@@ -41,7 +49,9 @@ func (p Paths) rulesFile(view string) string {
// PolicyZones returns the policy zones named loads, per view in view
// order, each view's in the order named checks them: the rules zone first
// (rules win over lists), then the enabled lists by name.
// (rules win over lists), then for each step (normal, then the steps above
// it) a gate letting through the clients below that step, when there are
// any, and the step's enabled lists by name.
func PolicyZones(s *model.State, p Paths) []PolicyZone {
var out []PolicyZone
rules, lists := s.SortedBlockRules(), s.SortedBlocklists()
@@ -65,10 +75,26 @@ func PolicyZones(s *model.State, p Paths) []PolicyZone {
break
}
}
for _, b := range lists {
if !b.Disabled && slices.Contains(s.BlockingViews(b.ViewIDs), vid) {
zones = append(zones, PolicyZone{Name: blocking.ListZone(b.Name), ViewID: vid, View: view, File: p.BlocklistFile(b.ID), ListID: b.ID})
assigns := ClientAssignments(s, vid)
for k, step := range append([]int64{model.StepNormal}, stepIDs(s)...) {
var tier []PolicyZone
for _, b := range lists {
if !b.Disabled && b.StepID == step && slices.Contains(s.BlockingViews(b.ViewIDs), vid) {
tier = append(tier, PolicyZone{Name: blocking.ListZone(b.Name), ViewID: vid, View: view, File: p.BlocklistFile(b.ID), ListID: b.ID})
}
}
if len(tier) == 0 {
continue
}
// The clients below this step pass, and never see its lists.
if pass := blocking.PassBelow(assigns, k); len(pass) > 0 {
name := "off"
if k > 0 {
name = s.StepName(step)
}
zones = append(zones, PolicyZone{Name: blocking.GateZone(name), ViewID: vid, View: view, File: p.gateFile(view, name), Gate: pass, GateStep: s.StepName(step)})
}
zones = append(zones, tier...)
}
// While paused, every view with blocking gets a rules zone: it
// lets everything through.
@@ -80,6 +106,39 @@ func PolicyZones(s *model.State, p Paths) []PolicyZone {
return out
}
// gateFile returns a view's gate zone file for a step.
func (p Paths) gateFile(view, name string) string {
return filepath.Join(p.ZoneDir, ViewDir(view), blocking.GateZone(name)+".rpz")
}
// stepIDs returns the IDs of the steps above normal, lowest first.
func stepIDs(s *model.State) []int64 {
var out []int64
for _, st := range s.OrderedBlockSteps() {
out = append(out, st.ID)
}
return out
}
// ClientAssignments returns the step ranks the client groups of a view
// give addresses (blocking.PassBelow).
func ClientAssignments(s *model.State, viewID int64) []blocking.Assignment {
var out []blocking.Assignment
for _, g := range s.SortedClientGroups() {
if !slices.Contains(s.BlockingViews(g.ViewIDs), viewID) {
continue
}
rank, ok := s.StepRank(g.StepID)
if !ok {
continue
}
for _, p := range s.GroupPrefixes(g) {
out = append(out, blocking.Assignment{Prefix: p, Rank: rank})
}
}
return out
}
// ViewRules returns the rules that apply in a view (0 without views).
func ViewRules(s *model.State, viewID int64) []blocking.Rule {
var out []blocking.Rule
@@ -106,7 +165,9 @@ func writeResponsePolicy(w *confWriter, zones []PolicyZone, logRewrites bool) {
// changed (at most every blocking.refresh), so named's default
// delay for batching frequent updates only adds a wait.
opts := []string{"min-update-interval 0"}
if !logRewrites {
// A gate lets most queries through: logging that would double
// the response log, and it blocks nothing.
if !logRewrites || z.IsGate() {
opts = append(opts, "log no")
}
w.write(fmt.Sprintf("\t\tzone %s%s;\n", namedconf.QuoteString(z.Name), prefixSpace(strings.Join(opts, " "))))
@@ -132,8 +193,11 @@ func writePolicyZones(w *confWriter, s *model.State, zones []PolicyZone, out *Ou
for _, z := range zones {
w.write("\n")
label := "blocking: your rules"
if z.ListID != 0 {
switch {
case z.ListID != 0:
label = "blocking: blocklist " + s.Blocklists[z.ListID].Name
case z.IsGate():
label = "blocking: clients below step " + z.GateStep
}
w.begin("blocking", z.ListID, label)
w.write(fmt.Sprintf("%szone %s {\n%s\ttype primary;\n%s\tfile %s;\n%s\tallow-query { none; };\n%s\tallow-transfer { none; };\n%s\tnotify no;\n%s};\n",
@@ -143,6 +207,14 @@ func writePolicyZones(w *confWriter, s *model.State, zones []PolicyZone, out *Ou
out.Blocklists = appendUnique(out.Blocklists, z.File)
continue
}
if z.IsGate() {
title := "Clients that skip the lists from step " + z.GateStep + " on"
if z.View != "" {
title += " in view " + z.View
}
out.Files = append(out.Files, &File{Path: z.File, Content: []byte(blocking.GateZoneFile(title, z.Gate)), Mode: 0o640, Kind: KindPolicy})
continue
}
title := "Blocking rules"
if z.View != "" {
title += " for view " + z.View