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>
317 lines
8.8 KiB
Go
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()
|
|
}
|