jarld downloads blocklists (Adblock, hosts or plain domains), checks them daily and reloads named only when a list changed; your own block and allow rules take precedence, and the most specific rule wins. Lists and rules apply per view (default: every view that offers recursion). Web UI Blocking page with name checks, jarl blocklist and jarl block, the /blocking API, blocked counts in the statistics. Database schema 6. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
199 lines
5.8 KiB
Go
199 lines
5.8 KiB
Go
// Package blocking turns blocklists and block/allow rules into BIND
|
|
// response policy zones (RPZ), and answers which rule or list matches a
|
|
// name.
|
|
//
|
|
// A policy zone holds one rule per owner name: "ads.example CNAME ."
|
|
// answers NXDOMAIN for ads.example, "*.ads.example CNAME ." for every name
|
|
// below it, and "CNAME rpz-passthru." answers normally (an exception).
|
|
// named looks names up in a policy zone like in any zone, so wildcards
|
|
// follow DNS rules: a wildcard doesn't apply below a name that exists in
|
|
// the zone. The writers here add the rules that keep "the most specific
|
|
// rule wins" true despite that (see RulesZone).
|
|
package blocking
|
|
|
|
import (
|
|
"bufio"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/netip"
|
|
"strings"
|
|
"unicode/utf8"
|
|
|
|
"jarl/internal/dnsname"
|
|
)
|
|
|
|
// Flags say what a list entry covers.
|
|
const (
|
|
Exact uint8 = 1 << iota // the name itself
|
|
Below // every name below it
|
|
)
|
|
|
|
// List is a parsed blocklist.
|
|
type List struct {
|
|
// Domains maps names (lowercase, without the final dot) to flags.
|
|
Domains map[string]uint8
|
|
// Lines is the number of lines with an entry (not comments or blank
|
|
// lines); Skipped how many of them were not usable.
|
|
Lines, Skipped int
|
|
}
|
|
|
|
// Limits bound what Parse accepts.
|
|
type Limits struct {
|
|
MaxEntries int // distinct domains (0: no limit)
|
|
MaxNameLen int // longest name in characters (0: 253)
|
|
}
|
|
|
|
// ErrTooManyEntries is returned when a list has more than MaxEntries
|
|
// domains.
|
|
var ErrTooManyEntries = errors.New("too many entries")
|
|
|
|
// Parse reads a blocklist. Each line is one of:
|
|
//
|
|
// ||ads.example^ Adblock style: the name and everything below it
|
|
// *.ads.example the name and everything below it
|
|
// 0.0.0.0 ads.example hosts file (any address; several names allowed)
|
|
// ads.example just this name
|
|
//
|
|
// Comments (#, !), Adblock headers ([Adblock Plus]) and blank lines are
|
|
// ignored. Lines that can't be used for DNS blocking (exceptions,
|
|
// cosmetic and URL rules, addresses, single-label names, invalid names)
|
|
// are counted as skipped.
|
|
func Parse(r io.Reader, lim Limits) (*List, error) {
|
|
maxLen := lim.MaxNameLen
|
|
if maxLen <= 0 || maxLen > 253 {
|
|
maxLen = 253
|
|
}
|
|
l := &List{Domains: map[string]uint8{}}
|
|
full := false // a new name was refused because of MaxEntries
|
|
add := func(name string, flags uint8) bool {
|
|
name, ok := normalize(name, maxLen)
|
|
if !ok {
|
|
return false
|
|
}
|
|
if _, seen := l.Domains[name]; !seen && lim.MaxEntries > 0 && len(l.Domains) >= lim.MaxEntries {
|
|
full = true
|
|
return false
|
|
}
|
|
l.Domains[name] |= flags
|
|
return true
|
|
}
|
|
sc := bufio.NewScanner(r)
|
|
sc.Buffer(make([]byte, 64*1024), 1024*1024)
|
|
first := true
|
|
for sc.Scan() {
|
|
line := sc.Text()
|
|
if first {
|
|
line = strings.TrimPrefix(line, "\uFEFF")
|
|
first = false
|
|
}
|
|
line = strings.TrimSpace(line)
|
|
if line == "" || line[0] == '#' || line[0] == '!' || line[0] == '[' {
|
|
continue
|
|
}
|
|
l.Lines++
|
|
ok := false
|
|
switch {
|
|
case strings.HasPrefix(line, "||"):
|
|
ok = add(adblockDomain(line[2:]), Exact|Below)
|
|
case strings.ContainsAny(line, "|/$^@") || strings.Contains(line, "##"):
|
|
// Exceptions (@@), URL and cosmetic rules: not for DNS.
|
|
default:
|
|
if i := strings.Index(line, "#"); i >= 0 {
|
|
line = strings.TrimSpace(line[:i])
|
|
}
|
|
fields := strings.Fields(line)
|
|
switch {
|
|
case len(fields) == 1 && strings.HasPrefix(fields[0], "*."):
|
|
ok = add(fields[0][2:], Exact|Below)
|
|
case len(fields) == 1:
|
|
ok = add(fields[0], Exact)
|
|
case len(fields) > 1 && isAddr(fields[0]):
|
|
for _, f := range fields[1:] {
|
|
if add(f, Exact) {
|
|
ok = true
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if full {
|
|
return l, fmt.Errorf("%w: the list has more than %d domains", ErrTooManyEntries, lim.MaxEntries)
|
|
}
|
|
if !ok {
|
|
l.Skipped++
|
|
}
|
|
}
|
|
if err := sc.Err(); err != nil {
|
|
return l, err
|
|
}
|
|
return l, nil
|
|
}
|
|
|
|
// adblockDomain returns the domain of an Adblock rule's body
|
|
// ("ads.example^" or "ads.example^$important"), or "" when the rule
|
|
// does more than block a domain (other modifiers, paths, wildcards).
|
|
func adblockDomain(s string) string {
|
|
d, rest, _ := strings.Cut(s, "^")
|
|
if rest != "" && rest != "$important" {
|
|
return ""
|
|
}
|
|
if strings.ContainsAny(d, "*/:|$") {
|
|
return ""
|
|
}
|
|
return d
|
|
}
|
|
|
|
func isAddr(s string) bool {
|
|
_, err := netip.ParseAddr(s)
|
|
return err == nil
|
|
}
|
|
|
|
// localNames are hosts-file names that aren't blocklist entries.
|
|
var localNames = map[string]bool{"localhost.localdomain": true, "ip6-localhost": true, "ip6-loopback": true}
|
|
|
|
// normalize lowercases a name, removes the final dot, converts
|
|
// international names to their ASCII form, and checks it is usable: at
|
|
// least two labels of letters, digits, hyphens and underscores, and not an
|
|
// address.
|
|
func normalize(name string, maxLen int) (string, bool) {
|
|
name = strings.TrimSuffix(strings.ToLower(strings.TrimSpace(name)), ".")
|
|
if name == "" || localNames[name] || isAddr(name) {
|
|
return "", false
|
|
}
|
|
for i := 0; i < len(name); i++ {
|
|
if name[i] >= utf8.RuneSelf {
|
|
a, err := dnsname.ToASCII(name)
|
|
if err != nil {
|
|
return "", false
|
|
}
|
|
name = strings.TrimSuffix(strings.ToLower(a), ".")
|
|
break
|
|
}
|
|
}
|
|
if len(name) > maxLen || !strings.Contains(name, ".") {
|
|
return "", false
|
|
}
|
|
for _, label := range strings.Split(name, ".") {
|
|
if label == "" || len(label) > 63 {
|
|
return "", false
|
|
}
|
|
for i := 0; i < len(label); i++ {
|
|
c := label[i]
|
|
if !(c >= 'a' && c <= 'z' || c >= '0' && c <= '9' || c == '-' || c == '_') {
|
|
return "", false
|
|
}
|
|
}
|
|
}
|
|
return name, true
|
|
}
|
|
|
|
// NormalizeName checks and normalizes a domain the way list entries are
|
|
// (for rules entered by hand). The name must have at least two labels.
|
|
func NormalizeName(name string) (string, error) {
|
|
n, ok := normalize(name, 253-len(RulesZone)-2)
|
|
if !ok {
|
|
return "", fmt.Errorf("%q is not a domain name that can be blocked (it needs at least two labels of letters, digits, hyphens or underscores, like ads.example.com)", name)
|
|
}
|
|
return n, nil
|
|
}
|