// 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 }