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

+63 -1
View File
@@ -14,8 +14,10 @@ import (
"fmt"
"io/fs"
"log/slog"
"net/http"
"os"
"path/filepath"
"slices"
"sort"
"strings"
"sync"
@@ -23,6 +25,7 @@ import (
"jarl/internal/auth"
"jarl/internal/bind"
"jarl/internal/blocking"
"jarl/internal/dnsname"
"jarl/internal/model"
"jarl/internal/namedconf"
@@ -64,6 +67,12 @@ type Engine struct {
deploy *Deployer
mu sync.Mutex // serializes applies
Now func() time.Time
// Blocklist downloads (blocklists.go).
blMu sync.Mutex // serializes downloads
blKick chan struct{} // a list was added or changed
HTTP *http.Client // for downloads; nil: a default client
Limits BlocklistLimits
}
// New creates an engine.
@@ -78,6 +87,8 @@ func New(st *store.Store, tools *bind.Tools, cfg Config, log *slog.Logger) *Engi
store: st, tools: tools, cfg: cfg, log: log,
deploy: &Deployer{Group: cfg.FileGroup, NamedWritable: []string{cfg.Paths.ZoneDir, cfg.Paths.CacheDir}},
Now: time.Now,
blKick: make(chan struct{}, 1),
Limits: DefaultBlocklistLimits(),
}
}
@@ -383,6 +394,7 @@ type plan struct {
drift []DriftEntry
configChanged bool
reloadZones []*model.Zone
reloadPolicy []render.PolicyZone // rules zones whose file changed
verify map[int64]uint32
newZones bool
backup *Backup
@@ -446,7 +458,7 @@ func (e *Engine) makePlan(ctx context.Context, base, work *model.State, user str
}
}
for path, df := range deployed {
if pl.out.File(path) != nil || df.Kind != render.KindZone {
if pl.out.File(path) != nil || df.Kind != render.KindZone && df.Kind != render.KindPolicy {
continue
}
old, existed, _ := readFile(path)
@@ -484,6 +496,16 @@ func (e *Engine) makePlan(ctx context.Context, base, work *model.State, user str
if len(work.Zones) != len(base.Zones) {
pl.configChanged = true
}
// Rules zones whose content changed. New ones come with a change to
// named.conf, and reconfig loads them.
for _, z := range pl.out.Policy {
if z.ListID != 0 || !changedPaths[z.File] {
continue
}
if slices.Contains(baseOut.Policy, z) {
pl.reloadPolicy = append(pl.reloadPolicy, z)
}
}
if !e.cfg.SkipCheck {
if err := e.stagingCheck(ctx, pl); err != nil {
@@ -519,6 +541,16 @@ func (e *Engine) stagingCheck(ctx context.Context, pl *plan) error {
return err
}
}
// Blocklists as empty policy zones: their content is checked when
// they are downloaded.
for _, path := range sout.Blocklists {
if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil {
return err
}
if err := os.WriteFile(path, []byte(blocking.Placeholder()), 0o640); err != nil {
return err
}
}
main, err := e.stagingMain(dir, sp.MainConfig())
if err != nil {
return err
@@ -796,6 +828,12 @@ func (e *Engine) applyOps(ctx context.Context, cs *model.Changeset, reqs []Reque
res.ChangesetID = cs.ID
res.Applied = true
e.removeDerived(pl)
for _, op := range cs.Ops {
if strings.HasPrefix(op.Kind, "blocklist.") {
e.KickBlocklists()
break
}
}
e.log.Info("changeset applied", "changeset", cs.ID, "user", p.Name(), "ops", len(cs.Ops), "files", len(res.Files), "description", cs.Description)
return res, nil
}
@@ -829,6 +867,9 @@ func (e *Engine) deployAndReload(ctx context.Context, pl *plan, res *Result, rel
return &Error{Kind: KindBIND, Message: "writing configuration files failed (nothing was changed): " + err.Error()}
}
pl.backup = backup
if err := e.ensureBlocklistFiles(pl.out.Blocklists); err != nil {
e.log.Warn("creating a blocklist's empty zone file failed", "err", err)
}
pl.reloaded = reload
if !reload {
return nil
@@ -860,6 +901,11 @@ func (e *Engine) rollback(ctx context.Context, pl *plan) {
e.log.Error("rndc reload after restore failed", "zone", z.Name, "err", err)
}
}
for _, z := range pl.reloadPolicy {
if err := e.tools.ReloadZone(ctx, z.Name, z.View); err != nil {
e.log.Error("rndc reload after restore failed", "zone", z.Name, "err", err)
}
}
}
// namedView returns the view name to pass to rndc for a zone ("" without
@@ -885,6 +931,16 @@ func (e *Engine) reload(ctx context.Context, pl *plan) ([]string, error) {
}
done = append(done, "reload "+zoneDesc(pl.work, z))
}
for _, z := range pl.reloadPolicy {
label := "blocking rules"
if z.View != "" {
label += " [view " + z.View + "]"
}
if err := e.tools.ReloadZone(ctx, z.Name, z.View); err != nil {
return done, fmt.Errorf("reloading %s failed: %v", label, err)
}
done = append(done, "reload "+label)
}
// named reloads zones asynchronously and reports load errors only in
// its log, so wait until it serves the new serials.
deadline := time.Now().Add(e.cfg.ReloadTimeout)
@@ -1003,6 +1059,12 @@ func (e *Engine) Resync(ctx context.Context, p *auth.Principal, opts ApplyOption
pl.verify[z.ID] = z.SOA.Serial
}
}
pl.reloadPolicy = nil
for _, z := range pl.out.Policy {
if z.ListID == 0 {
pl.reloadPolicy = append(pl.reloadPolicy, z)
}
}
reload := !e.cfg.NoReload && !opts.NoReload
if adopted, aerr := e.Adopted(); reload && (aerr != nil || !adopted) {
reload = false