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>
97 lines
3.1 KiB
Go
97 lines
3.1 KiB
Go
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)
|
|
}
|
|
}
|