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

+155
View File
@@ -0,0 +1,155 @@
package render
import (
"fmt"
"path/filepath"
"slices"
"strings"
"jarl/internal/blocking"
"jarl/internal/model"
"jarl/internal/namedconf"
)
// KindPolicy is the kind of a rendered rules policy zone file.
const KindPolicy = "policy"
// PolicyZone is a response policy zone in named's configuration
// (docs/blocking.md).
type PolicyZone struct {
Name string // without the final dot
ViewID int64 // 0 without views
View string // "" without views
File string
ListID int64 // the blocklist; 0 for the rules zone
}
// BlocklistsDir holds the zone files of downloaded blocklists, one per
// list, shared by the views that use it.
func (p Paths) BlocklistsDir() string { return filepath.Join(p.CacheDir, "blocklists") }
// BlocklistFile returns the zone file of a blocklist. It is named after
// the list's ID, so renaming the list doesn't move it.
func (p Paths) BlocklistFile(id int64) string {
return filepath.Join(p.BlocklistsDir(), fmt.Sprintf("list-%d.rpz", id))
}
// rulesFile returns a view's rules zone file.
func (p Paths) rulesFile(view string) string {
return filepath.Join(p.ZoneDir, ViewDir(view), blocking.RulesZone+".rpz")
}
// PolicyZones returns the policy zones named loads, per view in view
// order, each view's in the order named checks them: the rules zone first
// (rules win over lists), then the enabled lists by name.
func PolicyZones(s *model.State, p Paths) []PolicyZone {
var out []PolicyZone
rules, lists := s.SortedBlockRules(), s.SortedBlocklists()
scopes := []int64{0}
if len(s.Views) > 0 {
scopes = nil
for _, v := range s.OrderedViews() {
scopes = append(scopes, v.ID)
}
}
for _, vid := range scopes {
view := ""
if v := s.Views[vid]; v != nil {
view = v.Name
}
for _, r := range rules {
if slices.Contains(s.BlockingViews(r.ViewIDs), vid) {
out = append(out, PolicyZone{Name: blocking.RulesZone, ViewID: vid, View: view, File: p.rulesFile(view)})
break
}
}
for _, b := range lists {
if !b.Disabled && slices.Contains(s.BlockingViews(b.ViewIDs), vid) {
out = append(out, PolicyZone{Name: blocking.ListZone(b.Name), ViewID: vid, View: view, File: p.BlocklistFile(b.ID), ListID: b.ID})
}
}
}
return out
}
// ViewRules returns the rules that apply in a view (0 without views).
func ViewRules(s *model.State, viewID int64) []blocking.Rule {
var out []blocking.Rule
for _, r := range s.SortedBlockRules() {
if slices.Contains(s.BlockingViews(r.ViewIDs), viewID) {
out = append(out, blocking.Rule{Name: r.Name, Action: r.Action, Exact: r.Exact})
}
}
return out
}
// writeResponsePolicy writes the response-policy statement for a view's
// policy zones (or the server's, without views). Rewrites are logged to
// Jarl's response log when statistics read it, and not at all otherwise
// (they would go to named's default log).
func writeResponsePolicy(w *confWriter, zones []PolicyZone, logRewrites bool) {
if len(zones) == 0 {
return
}
w.begin("blocking", 0, "blocking (rules and blocklists)")
w.write("\t// Blocking (docs/blocking.md): rules first, then blocklists.\n\tresponse-policy {\n")
for _, z := range zones {
// Apply a new version at once: Jarl reloads a list only when it
// changed (at most every blocking.refresh), so named's default
// delay for batching frequent updates only adds a wait.
opts := []string{"min-update-interval 0"}
if !logRewrites {
opts = append(opts, "log no")
}
w.write(fmt.Sprintf("\t\tzone %s%s;\n", namedconf.QuoteString(z.Name), prefixSpace(strings.Join(opts, " "))))
}
// Block without waiting for the real answer, and also for clients
// that ask for DNSSEC data.
w.write("\t} qname-wait-recurse no break-dnssec yes;\n")
w.end()
}
func prefixSpace(s string) string {
if s == "" {
return ""
}
return " " + s
}
// writePolicyZones writes the zone statements of policy zones and adds
// their files to the output: rules zone files are rendered, blocklist
// files only listed (jarld downloads them; see Output.Blocklists).
func writePolicyZones(w *confWriter, s *model.State, zones []PolicyZone, out *Output, indent int) {
tabs := strings.Repeat("\t", indent)
for _, z := range zones {
w.write("\n")
label := "blocking: your rules"
if z.ListID != 0 {
label = "blocking: blocklist " + s.Blocklists[z.ListID].Name
}
w.begin("blocking", z.ListID, label)
w.write(fmt.Sprintf("%szone %s {\n%s\ttype primary;\n%s\tfile %s;\n%s\tallow-query { none; };\n%s\tallow-transfer { none; };\n%s\tnotify no;\n%s};\n",
tabs, namedconf.QuoteString(z.Name), tabs, tabs, namedconf.QuoteString(z.File), tabs, tabs, tabs, tabs))
w.end()
if z.ListID != 0 {
out.Blocklists = appendUnique(out.Blocklists, z.File)
continue
}
title := "Blocking rules"
if z.View != "" {
title += " for view " + z.View
}
out.Files = append(out.Files, &File{Path: z.File, Content: []byte(blocking.RulesZoneFile(title, ViewRules(s, z.ViewID))), Mode: 0o640, Kind: KindPolicy})
}
}
// scopeZones returns the policy zones of one view (0 without views).
func scopeZones(all []PolicyZone, viewID int64) []PolicyZone {
var out []PolicyZone
for _, z := range all {
if z.ViewID == viewID {
out = append(out, z)
}
}
return out
}