Files
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

317 lines
8.8 KiB
Go

package blocking
import (
"bufio"
"crypto/sha256"
"encoding/hex"
"fmt"
"io"
"os"
"sort"
"strings"
)
// Policy zone names. They are under .invalid (RFC 6761), so they can
// never clash with a real zone, and named only uses them internally.
const (
// RulesZone holds the rules entered in Jarl (one copy per view).
RulesZone = "rules.jarl.invalid"
// listSuffix follows a list's name: hagezi.blocklist.jarl.invalid.
listSuffix = ".blocklist.jarl.invalid"
)
// ListZone returns the policy zone name of a blocklist.
func ListZone(name string) string { return name + listSuffix }
// ListName returns the blocklist name of a policy zone ("" for the rules
// zone and zones that aren't Jarl's).
func ListName(zone string) string {
zone = strings.TrimSuffix(strings.ToLower(zone), ".")
if n, ok := strings.CutSuffix(zone, listSuffix); ok && !strings.Contains(n, ".") {
return n
}
return ""
}
// Match says what blocking does with a name in one view.
type Match struct {
View string `json:"view,omitempty"`
Result string `json:"result"` // blocked, allowed or none
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.
const (
ActionBlock = "block"
ActionAllow = "allow"
)
// cname is the CNAME target of each action: "." answers NXDOMAIN,
// rpz-passthru. answers as if there were no rule.
var cname = map[string]string{ActionBlock: ".", ActionAllow: "rpz-passthru."}
// header starts a policy zone file. There is no $ORIGIN: names are
// relative to the zone, so a list can be renamed without rewriting its
// file. The short TTLs make a change reach clients' caches quickly.
func header(title string, serial uint32) string {
return fmt.Sprintf("; %s\n; Generated by Jarl. DO NOT EDIT.\n$TTL 60\n@\tSOA\tlocalhost. hostmaster.localhost. %d 3600 600 86400 60\n\tNS\tlocalhost.\n", title, serial)
}
// Placeholder is the content of a list's zone file before the list has
// been downloaded: a valid, empty policy zone.
func Placeholder() string {
return header("Blocklist not downloaded yet.", 1)
}
// Rule is a block or allow rule entered by hand.
type Rule struct {
Name string // lowercase, without the final dot
Action string // ActionBlock or ActionAllow
Exact bool // only the name itself, not the names below it
}
// RuleEntries returns the policy zone owners for rules (relative names,
// "*." for wildcards) and their actions, with the entries that make the
// most specific rule win added: named won't apply a wildcard below a name
// that exists in the zone, so every name below a wildcard that exists
// (because a more specific rule mentions it) gets the wildcard's action
// for itself and for the names below it, unless a rule says otherwise.
// Callers make sure no two rules have the same name and scope.
func RuleEntries(rules []Rule) map[string]string {
e := map[string]string{}
for _, r := range rules {
e[r.Name] = r.Action
if !r.Exact {
e["*."+r.Name] = r.Action
}
}
// Every name that exists in the zone: owners and their ancestors.
nodes := map[string]bool{}
for owner := range e {
n := strings.TrimPrefix(owner, "*.")
for {
nodes[n] = true
i := strings.IndexByte(n, '.')
if i < 0 {
break
}
n = n[i+1:]
}
}
ordered := make([]string, 0, len(nodes))
for n := range nodes {
ordered = append(ordered, n)
}
// Shallow names first, so a filled-in wildcard is inherited further
// down.
sort.Slice(ordered, func(i, j int) bool {
di, dj := strings.Count(ordered[i], "."), strings.Count(ordered[j], ".")
if di != dj {
return di < dj
}
return ordered[i] < ordered[j]
})
for _, n := range ordered {
inherited := ""
for a := n; ; {
i := strings.IndexByte(a, '.')
if i < 0 {
break
}
a = a[i+1:]
if act, ok := e["*."+a]; ok {
inherited = act
break
}
}
if inherited == "" {
continue
}
if _, ok := e[n]; !ok {
e[n] = inherited
}
if _, ok := e["*."+n]; !ok {
e["*."+n] = inherited
}
}
return e
}
// PausedZoneFile renders a rules zone that lets every name through, for
// while blocking is paused: the rules zone comes first, and its match
// stops named from looking at the lists.
func PausedZoneFile(title string) string {
return header(title+" (blocking is paused)", 1) + "*\tCNAME\trpz-passthru.\n"
}
// RulesZoneFile renders the rules zone of a view.
func RulesZoneFile(title string, rules []Rule) string {
e := RuleEntries(rules)
owners := make([]string, 0, len(e))
for o := range e {
owners = append(owners, o)
}
sort.Slice(owners, func(i, j int) bool { return ownerLess(owners[i], owners[j]) })
var sb strings.Builder
sb.WriteString(header(title, 1))
for _, o := range owners {
fmt.Fprintf(&sb, "%s\tCNAME\t%s\n", o, cname[e[o]])
}
return sb.String()
}
// ownerLess sorts names by their labels from the right (example,
// ads.example, *.ads.example, cdn.ads.example), so related rules are next
// to each other.
func ownerLess(a, b string) bool {
ka, kb := reverseLabels(a), reverseLabels(b)
return ka < kb
}
func reverseLabels(n string) string {
labels := strings.Split(n, ".")
for i, j := 0, len(labels)-1; i < j; i, j = i+1, j-1 {
labels[i], labels[j] = labels[j], labels[i]
}
// A wildcard sorts right after the name it belongs to.
return strings.ReplaceAll(strings.Join(labels, "\x00"), "*", "\x01")
}
// MatchRules finds the rule entry named would apply to name (lowercase,
// without the final dot) in a rules zone with entries e: the name itself,
// or else the wildcard of its closest existing ancestor. ok is false when
// no entry applies.
func MatchRules(e map[string]string, name string) (owner, action string, ok bool) {
if act, found := e[name]; found {
return name, act, true
}
nodes := map[string]bool{}
for o := range e {
n := strings.TrimPrefix(o, "*.")
for {
nodes[n] = true
i := strings.IndexByte(n, '.')
if i < 0 {
break
}
n = n[i+1:]
}
}
if nodes[name] {
return "", "", false // the name exists without a rule of its own
}
for a := name; ; {
i := strings.IndexByte(a, '.')
if i < 0 {
return "", "", false
}
a = a[i+1:]
if nodes[a] {
if act, found := e["*."+a]; found {
return "*." + a, act, true
}
return "", "", false
}
}
}
// Compact drops entries another entry already covers (a name below a
// name whose subdomains are blocked). That keeps the zone small, and it
// matters for correctness: no name exists below a wildcard, so every
// wildcard applies to all names below it.
func Compact(l *List) {
for n := range l.Domains {
for a := n; ; {
i := strings.IndexByte(a, '.')
if i < 0 {
break
}
a = a[i+1:]
if l.Domains[a]&Below != 0 {
delete(l.Domains, n)
break
}
}
}
}
// FeedZone writes a list's policy zone to w and returns the number of
// records and a hash of them (the serial isn't part of the hash, so it
// tells whether the content changed). l must be compacted.
func FeedZone(w io.Writer, title string, serial uint32, l *List) (records int, hash string, err error) {
names := make([]string, 0, len(l.Domains))
for n := range l.Domains {
names = append(names, n)
}
sort.Strings(names)
bw := bufio.NewWriterSize(w, 256*1024)
h := sha256.New()
if _, err := bw.WriteString(header(title, serial)); err != nil {
return 0, "", err
}
write := func(owner string) error {
line := owner + "\tCNAME\t.\n"
h.Write([]byte(line))
records++
_, err := bw.WriteString(line)
return err
}
for _, n := range names {
f := l.Domains[n]
if f&Exact != 0 {
if err := write(n); err != nil {
return 0, "", err
}
}
if f&Below != 0 {
if err := write("*." + n); err != nil {
return 0, "", err
}
}
}
if err := bw.Flush(); err != nil {
return 0, "", err
}
return records, hex.EncodeToString(h.Sum(nil)), nil
}
// MatchFeed reports which entry of a list's zone file blocks name: the
// name itself, or a wildcard of one of its ancestors (the closest).
// Lists are compacted, so no name exists below a wildcard and the
// closest wildcard ancestor is the one named uses.
func MatchFeed(path, name string) (owner string, ok bool, err error) {
want := map[string]int{name: 0}
depth := 1
for a := name; ; depth++ {
i := strings.IndexByte(a, '.')
if i < 0 {
break
}
a = a[i+1:]
want["*."+a] = depth
}
f, err := os.Open(path)
if err != nil {
return "", false, err
}
defer f.Close()
best := -1
sc := bufio.NewScanner(f)
for sc.Scan() {
line := sc.Text()
o, _, found := strings.Cut(line, "\t")
if !found {
continue
}
if d, hit := want[o]; hit && (best < 0 || d < best) {
best, owner = d, o
}
}
return owner, best >= 0, sc.Err()
}