Add blocking: blocklists and block/allow rules with BIND response policy zones
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>
This commit is contained in:
1 parent
436cd79ec9
commit
9fc23310bd
54 files changed
+4269
-85
No files matched your search
@@ -0,0 +1,180 @@
|
||||
package blocking
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParse(t *testing.T) {
|
||||
src := "\uFEFF[Adblock Plus]\r\n" + `! Title: test list
|
||||
# a comment
|
||||
|
||||
||ads.example^
|
||||
||Tracker.Example.^$important
|
||||
||cosmetic.example^$third-party
|
||||
@@||allowed.example^
|
||||
example.org##.banner
|
||||
|https://url.example/path
|
||||
0.0.0.0 hosts.example other.example # two names
|
||||
127.0.0.1 localhost
|
||||
127.0.0.1 localhost.localdomain
|
||||
::1 ip6-localhost
|
||||
0.0.0.0 0.0.0.0
|
||||
plain.example
|
||||
*.wild.example
|
||||
single
|
||||
1.2.3.4
|
||||
bad_label!.example
|
||||
münchen.example
|
||||
www.ads.example
|
||||
` + strings.Repeat("a", 64) + ".example\n"
|
||||
l, err := Parse(strings.NewReader(src), Limits{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := map[string]uint8{
|
||||
"ads.example": Exact | Below, "tracker.example": Exact | Below,
|
||||
"hosts.example": Exact, "other.example": Exact, "plain.example": Exact,
|
||||
"wild.example": Exact | Below, "xn--mnchen-3ya.example": Exact, "www.ads.example": Exact,
|
||||
}
|
||||
if len(l.Domains) != len(want) {
|
||||
t.Errorf("domains = %v", l.Domains)
|
||||
}
|
||||
for n, f := range want {
|
||||
if l.Domains[n] != f {
|
||||
t.Errorf("%s = %d, want %d", n, l.Domains[n], f)
|
||||
}
|
||||
}
|
||||
// Skipped: $third-party, @@, ##, |url, localhost (x3), 0.0.0.0, single, 1.2.3.4, bad_label!, 64-char label.
|
||||
if l.Skipped != 12 || l.Lines != 19 {
|
||||
t.Errorf("lines %d, skipped %d", l.Lines, l.Skipped)
|
||||
}
|
||||
|
||||
Compact(l)
|
||||
if _, ok := l.Domains["www.ads.example"]; ok {
|
||||
t.Error("www.ads.example is covered by ||ads.example^ and should be dropped")
|
||||
}
|
||||
var sb strings.Builder
|
||||
n, hash, err := FeedZone(&sb, "test", 7, l)
|
||||
if err != nil || n != 10 {
|
||||
t.Fatalf("FeedZone: %d records, %v", n, err)
|
||||
}
|
||||
for _, line := range []string{"ads.example\tCNAME\t.\n", "*.ads.example\tCNAME\t.\n", "plain.example\tCNAME\t.\n", "@\tSOA\tlocalhost. hostmaster.localhost. 7 "} {
|
||||
if !strings.Contains(sb.String(), line) {
|
||||
t.Errorf("zone lacks %q:\n%s", line, sb.String())
|
||||
}
|
||||
}
|
||||
if strings.Contains(sb.String(), "*.plain.example") || strings.Contains(sb.String(), "$ORIGIN") {
|
||||
t.Errorf("zone:\n%s", sb.String())
|
||||
}
|
||||
// The hash ignores the serial.
|
||||
var other strings.Builder
|
||||
if _, h2, _ := FeedZone(&other, "test", 8, l); h2 != hash {
|
||||
t.Error("hash depends on the serial")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseLimits(t *testing.T) {
|
||||
_, err := Parse(strings.NewReader("a.example\nb.example\na.example\nc.example\n"), Limits{MaxEntries: 2})
|
||||
if !errors.Is(err, ErrTooManyEntries) {
|
||||
t.Errorf("err = %v", err)
|
||||
}
|
||||
if _, err := Parse(strings.NewReader("a.example\nb.example\na.example\n"), Limits{MaxEntries: 2}); err != nil {
|
||||
t.Errorf("a repeated name counted against the limit: %v", err)
|
||||
}
|
||||
l, _ := Parse(strings.NewReader("long-name.example\nshort.example\n"), Limits{MaxNameLen: 15})
|
||||
if len(l.Domains) != 1 || l.Skipped != 1 {
|
||||
t.Errorf("max name length: %v", l.Domains)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuleEntries(t *testing.T) {
|
||||
rules := []Rule{
|
||||
{Name: "ads.example", Action: ActionBlock}, // and everything below
|
||||
{Name: "cdn.ads.example", Action: ActionAllow, Exact: true}, // just this name
|
||||
{Name: "wide.example", Action: ActionBlock}, // and below
|
||||
{Name: "ok.wide.example", Action: ActionAllow}, // and below
|
||||
{Name: "deep.x.y.wide.example", Action: ActionAllow, Exact: true}, // two empty names in between
|
||||
{Name: "solo.example", Action: ActionBlock, Exact: true},
|
||||
}
|
||||
e := RuleEntries(rules)
|
||||
for name, want := range map[string]string{
|
||||
"ads.example": ActionBlock,
|
||||
"x.ads.example": ActionBlock,
|
||||
"cdn.ads.example": ActionAllow,
|
||||
"y.cdn.ads.example": ActionBlock, // the wildcard above still applies
|
||||
"ok.wide.example": ActionAllow,
|
||||
"a.ok.wide.example": ActionAllow,
|
||||
"y.wide.example": ActionBlock, // exists only because of a deeper rule
|
||||
"x.y.wide.example": ActionBlock,
|
||||
"z.x.y.wide.example": ActionBlock,
|
||||
"deep.x.y.wide.example": ActionAllow,
|
||||
"below.deep.x.y.wide.example": ActionBlock,
|
||||
"solo.example": ActionBlock,
|
||||
"other.example": "",
|
||||
"sub.solo.example": "",
|
||||
"example": "",
|
||||
"unrelated.org": "",
|
||||
} {
|
||||
_, act, ok := MatchRules(e, name)
|
||||
if !ok {
|
||||
act = ""
|
||||
}
|
||||
if act != want {
|
||||
t.Errorf("%s: %q, want %q", name, act, want)
|
||||
}
|
||||
}
|
||||
z := RulesZoneFile("rules", rules)
|
||||
if !strings.Contains(z, "cdn.ads.example\tCNAME\trpz-passthru.\n*.cdn.ads.example\tCNAME\t.\n") {
|
||||
t.Errorf("rules zone:\n%s", z)
|
||||
}
|
||||
if strings.Index(z, "ads.example\t") > strings.Index(z, "*.ads.example\t") {
|
||||
t.Errorf("a name should come before its wildcard:\n%s", z)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatchFeed(t *testing.T) {
|
||||
l, _ := Parse(strings.NewReader("||tracker.example^\nexact.example\n||deep.a.example^\n"), Limits{})
|
||||
Compact(l)
|
||||
path := filepath.Join(t.TempDir(), "list.rpz")
|
||||
f, _ := os.Create(path)
|
||||
FeedZone(f, "t", 1, l)
|
||||
f.Close()
|
||||
for name, want := range map[string]string{
|
||||
"tracker.example": "tracker.example",
|
||||
"a.b.tracker.example": "*.tracker.example",
|
||||
"exact.example": "exact.example",
|
||||
"sub.exact.example": "",
|
||||
"x.deep.a.example": "*.deep.a.example",
|
||||
"a.example": "",
|
||||
"example": "",
|
||||
} {
|
||||
owner, ok, err := MatchFeed(path, name)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !ok {
|
||||
owner = ""
|
||||
}
|
||||
if owner != want {
|
||||
t.Errorf("%s: %q, want %q", name, owner, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNames(t *testing.T) {
|
||||
if ListZone("hagezi") != "hagezi.blocklist.jarl.invalid" || ListName("hagezi.blocklist.jarl.invalid.") != "hagezi" || ListName(RulesZone) != "" || ListName("x.y.blocklist.jarl.invalid") != "" {
|
||||
t.Error("zone names")
|
||||
}
|
||||
if n, err := NormalizeName("Ads.Example.COM."); err != nil || n != "ads.example.com" {
|
||||
t.Errorf("NormalizeName = %q, %v", n, err)
|
||||
}
|
||||
for _, bad := range []string{"com", "1.2.3.4", "a b.example", "*.example.com", ""} {
|
||||
if _, err := NormalizeName(bad); err == nil {
|
||||
t.Errorf("%q accepted", bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
// 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
|
||||
}
|
||||
@@ -0,0 +1,306 @@
|
||||
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
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// 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()
|
||||
}
|
||||
Reference in new issue
Block a user