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

+180
View File
@@ -0,0 +1,180 @@
package blocking
import (
"fmt"
"math"
"net/netip"
"sort"
"strings"
)
// Client steps (docs/blocking.md): named checks policy zones in order and
// the first match decides, so a "gate" zone that lets some clients through
// (rpz-passthru, matched on the client's address) keeps them from every
// zone after it. Jarl puts a gate in front of the lists of each step,
// letting through the clients whose step is below it.
// GateSuffix follows a gate's name: off.gate.jarl.invalid,
// strict.gate.jarl.invalid.
const GateSuffix = ".gate.jarl.invalid"
// v4Mapped holds the IPv4-mapped IPv6 addresses.
var v4Mapped = netip.MustParsePrefix("::ffff:0:0/96")
// GateZone returns the policy zone name of the gate in front of a step's
// lists ("off" for the gate in front of the normal lists).
func GateZone(step string) string { return step + GateSuffix }
// Assignment gives the addresses in Prefix a step rank (-1 off, 0 normal,
// 1, 2, ... the steps above it).
type Assignment struct {
Prefix netip.Prefix
Rank int
}
// PassBelow returns the fewest prefixes that cover exactly the addresses whose
// rank is below k. An address has the rank of the most specific
// assignment containing it, and rank 0 (normal) when none does. When two
// assignments have the same prefix, the higher rank counts (validation
// refuses that anyway).
func PassBelow(assigns []Assignment, k int) []netip.Prefix {
var out []netip.Prefix
for _, root := range []netip.Prefix{netip.MustParsePrefix("0.0.0.0/0"), netip.MustParsePrefix("::/0")} {
var mine []Assignment
if !root.Addr().Is4() {
// named matches IPv4 clients as IPv4-mapped IPv6 addresses:
// keep IPv6 prefixes clear of them, so only the IPv4 ones
// decide for IPv4 clients.
mine = append(mine, Assignment{v4Mapped, math.MaxInt})
}
rank := 0
for _, a := range assigns {
p := a.Prefix.Masked()
if p.Addr().Is4() != root.Addr().Is4() {
continue
}
if p.Bits() == 0 {
rank = max(rank, a.Rank)
continue
}
mine = append(mine, Assignment{p, a.Rank})
}
for _, p := range cover(root, rank, mine, k) {
if p.Bits() == 0 {
// A trigger needs at least one bit: use both halves.
lo, hi := halves(p)
out = append(out, lo, hi)
continue
}
out = append(out, p)
}
}
return out
}
// cover returns the prefixes inside p whose addresses have a rank below k.
// rank is p's own; inside are the assignments strictly inside p.
func cover(p netip.Prefix, rank int, inside []Assignment, k int) []netip.Prefix {
if len(inside) == 0 {
if rank < k {
return []netip.Prefix{p}
}
return nil
}
var out []netip.Prefix
lo, hi := halves(p)
full := 0
for _, h := range []netip.Prefix{lo, hi} {
var sub []Assignment
for _, a := range inside {
if a.Prefix.Bits() > h.Bits() && h.Contains(a.Prefix.Addr()) {
sub = append(sub, a)
}
}
c := cover(h, ownRank(inside, h, rank), sub, k)
if len(c) == 1 && c[0] == h {
full++
}
out = append(out, c...)
}
if full == 2 {
return []netip.Prefix{p}
}
return out
}
// ownRank is the rank of h: its own assignments' (the highest), or the
// inherited one without any.
func ownRank(inside []Assignment, h netip.Prefix, inherited int) int {
r, found := 0, false
for _, a := range inside {
if a.Prefix == h {
if !found || a.Rank > r {
r = a.Rank
}
found = true
}
}
if !found {
return inherited
}
return r
}
// halves splits a prefix into its two halves.
func halves(p netip.Prefix) (netip.Prefix, netip.Prefix) {
bits := p.Bits() + 1
lo := netip.PrefixFrom(p.Addr(), bits).Masked()
b := lo.Addr().AsSlice()
i := (bits - 1) / 8
b[i] |= 0x80 >> ((bits - 1) % 8)
a, _ := netip.AddrFromSlice(b)
return lo, netip.PrefixFrom(a, bits)
}
// ClientIPOwner returns the policy zone owner name that matches clients
// in p (an rpz-client-ip trigger): 32.37.0.10.10.rpz-client-ip for
// 10.10.0.37/32, 64.zz.3d7f.8013.2a02.rpz-client-ip for 2a02:8013:3d7f::/64.
func ClientIPOwner(p netip.Prefix) string {
p = p.Masked()
a := p.Addr()
var labels []string
if a.Is4() {
b := a.As4()
labels = []string{fmt.Sprint(b[3]), fmt.Sprint(b[2]), fmt.Sprint(b[1]), fmt.Sprint(b[0])}
} else {
// RFC 5952 text, with "zz" for the "::" and the words reversed.
s := a.String()
var words []string
if left, right, ok := strings.Cut(s, "::"); ok {
if left != "" {
words = append(words, strings.Split(left, ":")...)
}
words = append(words, "zz")
if right != "" {
words = append(words, strings.Split(right, ":")...)
}
} else {
words = strings.Split(s, ":")
}
for i := len(words) - 1; i >= 0; i-- {
labels = append(labels, words[i])
}
}
return fmt.Sprint(p.Bits()) + "." + strings.Join(labels, ".") + ".rpz-client-ip"
}
// GateZoneFile renders a gate: every client in prefixes passes.
func GateZoneFile(title string, prefixes []netip.Prefix) string {
owners := make([]string, 0, len(prefixes))
for _, p := range prefixes {
owners = append(owners, ClientIPOwner(p))
}
sort.Strings(owners)
var sb strings.Builder
sb.WriteString(header(title, 1))
for _, o := range owners {
sb.WriteString(o + "\tCNAME\trpz-passthru.\n")
}
return sb.String()
}
+96
View File
@@ -0,0 +1,96 @@
package blocking
import (
"net/netip"
"slices"
"strings"
"testing"
)
func pfx(s string) netip.Prefix { return netip.MustParsePrefix(s) }
// rankOf is the rank PassBelow's model gives an address.
func rankOf(assigns []Assignment, a netip.Addr) int {
best, bits := 0, -1
for _, x := range assigns {
if x.Prefix.Contains(a) && x.Prefix.Bits() >= bits {
if x.Prefix.Bits() > bits || x.Rank > best {
best = x.Rank
}
bits = x.Prefix.Bits()
}
}
return best
}
func covered(ps []netip.Prefix, a netip.Addr) bool {
for _, p := range ps {
if p.Contains(a) {
return true
}
}
return false
}
func TestPassBelow(t *testing.T) {
assigns := []Assignment{
{pfx("10.10.0.20/32"), -1}, // work laptop: off
{pfx("10.10.0.0/24"), 2}, // the kids' network: very strict
{pfx("10.10.0.50/32"), 1}, // except one tablet: strict
{pfx("2a02:db8::/64"), 1}, // an IPv6 network: strict
{pfx("2a02:db8::99/128"), 0}, // one device in it: normal
}
probe := []string{"10.10.0.20", "10.10.0.21", "10.10.0.50", "10.10.0.51", "10.10.1.1", "192.168.1.1", "0.0.0.1", "255.255.255.255",
"2a02:db8::1", "2a02:db8::99", "2a02:db8:0:1::1", "::1", "ffff::1"}
for k := 0; k <= 3; k++ {
gate := PassBelow(assigns, k)
for _, s := range probe {
a := netip.MustParseAddr(s)
if want := rankOf(assigns, a) < k; covered(gate, a) != want {
t.Errorf("k=%d %s: passes=%v, want %v (rank %d)", k, s, !want, want, rankOf(assigns, a))
}
}
for _, p := range gate {
if p.Bits() == 0 {
t.Errorf("k=%d: a /0 trigger", k)
}
// named matches IPv4 clients as ::ffff:a.b.c.d: no IPv6
// prefix may reach them.
if p.Addr().Is6() && p.Overlaps(v4Mapped) {
t.Errorf("k=%d: %s covers IPv4-mapped addresses", k, p)
}
}
}
// Off clients only, before the normal lists.
if got := PassBelow(assigns, 0); !slices.Equal(got, []netip.Prefix{pfx("10.10.0.20/32")}) {
t.Errorf("off gate = %v", got)
}
// Nobody above normal: everyone passes the gate of step 1: two IPv4
// halves, and IPv6 around the IPv4-mapped /96 (96 prefixes).
if got := PassBelow(nil, 1); len(got) != 2+96 {
t.Errorf("empty step-1 gate = %v", got)
}
// The complement of one IPv4 address is 32 prefixes (plus IPv6's 96).
if got := PassBelow([]Assignment{{pfx("10.10.0.50/32"), 1}}, 1); len(got) != 32+96 {
t.Errorf("complement of one address: %d prefixes", len(got))
}
}
func TestClientIPOwner(t *testing.T) {
for p, want := range map[string]string{
"10.10.0.37/32": "32.37.0.10.10.rpz-client-ip",
"10.10.0.0/24": "24.0.0.10.10.rpz-client-ip",
"2a02:db8::/64": "64.zz.db8.2a02.rpz-client-ip",
"2a02:db8::1/128": "128.1.zz.db8.2a02.rpz-client-ip",
"::/1": "1.zz.rpz-client-ip",
"2001:db8:1:2:3:4:5:6/128": "128.6.5.4.3.2.1.db8.2001.rpz-client-ip",
} {
if got := ClientIPOwner(pfx(p)); got != want {
t.Errorf("%s: %s, want %s", p, got, want)
}
}
z := GateZoneFile("t", []netip.Prefix{pfx("10.10.0.20/32")})
if !strings.Contains(z, "32.20.0.10.10.rpz-client-ip\tCNAME\trpz-passthru.\n") {
t.Errorf("gate zone:\n%s", z)
}
}
+3
View File
@@ -40,6 +40,9 @@ type Match struct {
Entry string `json:"entry,omitempty"` // the matching entry: the name, or *.parent
RuleID int64 `json:"rule_id,omitempty"`
List string `json:"list,omitempty"` // the blocklist, when a list matched
// Step is the list's step when it is above normal: only clients at
// that step or above get this answer (others: none).
Step string `json:"step,omitempty"`
}
// Actions of rules.