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:
adamandClaude Opus 5.5 committed 2026-10-10 09:24:19 +00:00
1 parent 436cd79ec9
commit 9fc23310bd
54 files changed
+4269 -85

No files matched your search

+62 -5
View File
@@ -12,6 +12,8 @@ import (
"strings"
"sync"
"time"
"jarl/internal/blocking"
)
// Kinds of top lists.
@@ -21,10 +23,11 @@ const (
KindNXDomain = "nxdomain" // names answered with NXDOMAIN
KindServFail = "servfail" // names answered with SERVFAIL
KindRefused = "refused" // clients that were refused
KindBlocked = "blocked" // names blocked (docs/blocking.md)
)
// Kinds lists the top-list kinds.
var Kinds = []string{KindName, KindClient, KindNXDomain, KindServFail, KindRefused}
var Kinds = []string{KindName, KindClient, KindNXDomain, KindServFail, KindRefused, KindBlocked}
// OtherZone is the zone key for names outside every local zone (answered
// by recursion, or refused).
@@ -99,6 +102,9 @@ type Collector struct {
recentL []int64 // response log lines per minute, last 10 minutes
linesMin int64
ensureAt time.Time
// rewritten holds queries named blocked (client#port|name) until their
// response line arrives.
rewritten map[string]bool
}
// maxKeys bounds the per-hour maps; rarely seen keys are dropped first.
@@ -296,8 +302,15 @@ func (c *Collector) HandleLine(line string) {
func (c *Collector) handleLine(line string) {
ev, ok := ParseResponse(line, c.opts.Location)
if !ok {
if rw, ok := ParseRewrite(line, c.opts.Location); ok {
c.handleRewrite(rw)
}
return
}
blocked := c.rewritten[ev.Client.String()+"#"+ev.Port+"|"+ev.Name]
if blocked {
delete(c.rewritten, ev.Client.String()+"#"+ev.Port+"|"+ev.Name)
}
c.status.LinesRead++
c.linesMin++
t := ev.Time
@@ -324,12 +337,14 @@ func (c *Collector) handleLine(line string) {
tops := hours[hour]
tops[KindName][ev.Name]++
tops[KindClient][client]++
switch ev.RCode {
case "NXDOMAIN":
switch {
case blocked:
// Counted by handleRewrite; not a name that doesn't exist.
case ev.RCode == "NXDOMAIN":
tops[KindNXDomain][ev.Name]++
case "SERVFAIL":
case ev.RCode == "SERVFAIL":
tops[KindServFail][ev.Name]++
case "REFUSED":
case ev.RCode == "REFUSED":
tops[KindRefused][client]++
}
for _, m := range tops {
@@ -339,6 +354,48 @@ func (c *Collector) handleLine(line string) {
}
}
// handleRewrite counts a query that blocking answered: "blocked", and
// "blocklist:NAME" for the list (or "rules") that matched.
func (c *Collector) handleRewrite(rw Rewrite) {
if rw.Action == "PASSTHRU" {
return // an allow rule: answered normally
}
for _, p := range c.opts.Exclude {
if p.Contains(rw.Client) {
return
}
}
if c.rewritten == nil || len(c.rewritten) > 10_000 {
// Rewrites whose response line never came (named only logs
// responses while response logging is on) don't pile up.
c.rewritten = map[string]bool{}
}
c.rewritten[rw.Client.String()+"#"+rw.Port+"|"+rw.Name] = true
minute, hour := c.minuteOf(rw.Time), c.hourOf(rw.Time)
c.count(minute, "blocked", 1)
c.count(minute, "blocklist:"+policySource(rw.Via), 1)
hours := c.hours
if hour < c.liveHour {
hours = c.late
}
hours[hour] = c.hourMaps(hours, hour)
hours[hour][KindBlocked][rw.Name]++
}
// policySource names what a rewrite came from: a blocklist's name, "rules"
// for the rules entered in Jarl, or the policy zone for others.
func policySource(via string) string {
via = strings.TrimSuffix(via, ".")
if strings.HasSuffix(via, "."+blocking.RulesZone) {
return "rules"
}
if i := strings.LastIndex(via, ".blocklist.jarl.invalid"); i > 0 && i+len(".blocklist.jarl.invalid") == len(via) {
rest := via[:i]
return rest[strings.LastIndex(rest, ".")+1:]
}
return via
}
// shrink drops the rarest keys until the map is well under maxKeys.
func shrink(m map[string]int64) {
for limit := int64(1); len(m) > maxKeys*8/10; limit++ {