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

+42
View File
@@ -0,0 +1,42 @@
package store
import (
"context"
"database/sql"
"jarl/internal/model"
)
// BlocklistStatuses returns the download status of every blocklist that
// has one.
func (s *Store) BlocklistStatuses(ctx context.Context) (map[int64]*model.BlocklistStatus, error) {
rows, err := s.db.QueryContext(ctx, `SELECT id, url, etag, last_modified, checked_at, updated_at, entries, records, skipped, sha256, error FROM blocklist_status`)
if err != nil {
return nil, err
}
out := map[int64]*model.BlocklistStatus{}
for rows.Next() {
st := &model.BlocklistStatus{}
var checked, updated sql.NullString
if err := rows.Scan(&st.ID, &st.URL, &st.ETag, &st.LastModified, &checked, &updated, &st.Entries, &st.Records, &st.Skipped, &st.SHA256, &st.Error); err != nil {
rows.Close()
return nil, err
}
st.CheckedAt, st.UpdatedAt = parseTSPtr(checked), parseTSPtr(updated)
out[st.ID] = st
}
return out, closeRows(rows)
}
// SetBlocklistStatus stores a blocklist's download status.
func (s *Store) SetBlocklistStatus(ctx context.Context, st *model.BlocklistStatus) error {
_, err := s.db.ExecContext(ctx, `INSERT OR REPLACE INTO blocklist_status (id, url, etag, last_modified, checked_at, updated_at, entries, records, skipped, sha256, error) VALUES (?,?,?,?,?,?,?,?,?,?,?)`,
st.ID, st.URL, st.ETag, st.LastModified, tsPtr(st.CheckedAt), tsPtr(st.UpdatedAt), st.Entries, st.Records, st.Skipped, st.SHA256, st.Error)
return err
}
// DeleteBlocklistStatus forgets a deleted blocklist's status.
func (s *Store) DeleteBlocklistStatus(ctx context.Context, id int64) error {
_, err := s.db.ExecContext(ctx, `DELETE FROM blocklist_status WHERE id = ?`, id)
return err
}
+62 -3
View File
@@ -149,6 +149,44 @@ func loadState(ctx context.Context, e execer) (*model.State, error) {
if err := closeRows(rows); err != nil {
return nil, err
}
rows, err = e.QueryContext(ctx, `SELECT id, name, url, view_ids, disabled, comment, version, created_at, updated_at, created_by, updated_by FROM blocklists`)
if err != nil {
return nil, err
}
for rows.Next() {
b := &model.Blocklist{}
var views, ca, ua string
if err := rows.Scan(&b.ID, &b.Name, &b.URL, &views, &b.Disabled, &b.Comment, &b.Version, &ca, &ua, &b.CreatedBy, &b.UpdatedBy); err != nil {
rows.Close()
return nil, err
}
json.Unmarshal([]byte(views), &b.ViewIDs)
b.CreatedAt, b.UpdatedAt = parseTS(ca), parseTS(ua)
st.Blocklists[b.ID] = b
}
if err := closeRows(rows); err != nil {
return nil, err
}
rows, err = e.QueryContext(ctx, `SELECT id, name, action, exact, view_ids, comment, version, created_at, updated_at, created_by, updated_by FROM block_rules`)
if err != nil {
return nil, err
}
for rows.Next() {
r := &model.BlockRule{}
var views, ca, ua string
if err := rows.Scan(&r.ID, &r.Name, &r.Action, &r.Exact, &views, &r.Comment, &r.Version, &ca, &ua, &r.CreatedBy, &r.UpdatedBy); err != nil {
rows.Close()
return nil, err
}
json.Unmarshal([]byte(views), &r.ViewIDs)
r.CreatedAt, r.UpdatedAt = parseTS(ca), parseTS(ua)
st.BlockRules[r.ID] = r
}
if err := closeRows(rows); err != nil {
return nil, err
}
return st, nil
}
@@ -166,7 +204,7 @@ func (t *Tx) SaveState(old, new *model.State) error {
// ReplaceState replaces the stored configuration with st (used by import
// and tests).
func (t *Tx) ReplaceState(st *model.State) error {
for _, tbl := range []string{"views", "acls", "tsig_keys", "zones", "records", "options"} {
for _, tbl := range []string{"views", "acls", "tsig_keys", "zones", "records", "options", "blocklists", "block_rules"} {
if _, err := t.tx.ExecContext(t.ctx, "DELETE FROM "+tbl); err != nil {
return err
}
@@ -226,11 +264,32 @@ func saveState(ctx context.Context, e execer, old, new *model.State) error {
}, func(id int64) error { return del(ctx, e, "records", id) }); err != nil {
return err
}
return diffMap(old.Options, new.Options, func(o *model.Option) error {
if err := diffMap(old.Options, new.Options, func(o *model.Option) error {
_, err := e.ExecContext(ctx, `INSERT OR REPLACE INTO options (id, scope, scope_id, name, value, position, comment, version, created_at, updated_at, created_by, updated_by) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)`,
o.ID, o.Scope, o.ScopeID, o.Name, o.Value, o.Position, o.Comment, o.Version, ts(o.CreatedAt), ts(o.UpdatedAt), o.CreatedBy, o.UpdatedBy)
return err
}, func(id int64) error { return del(ctx, e, "options", id) })
}, func(id int64) error { return del(ctx, e, "options", id) }); err != nil {
return err
}
if err := diffMap(old.Blocklists, new.Blocklists, func(b *model.Blocklist) error {
_, err := e.ExecContext(ctx, `INSERT OR REPLACE INTO blocklists (id, name, url, view_ids, disabled, comment, version, created_at, updated_at, created_by, updated_by) VALUES (?,?,?,?,?,?,?,?,?,?,?)`,
b.ID, b.Name, b.URL, jsonText(nonNilIDs(b.ViewIDs)), b.Disabled, b.Comment, b.Version, ts(b.CreatedAt), ts(b.UpdatedAt), b.CreatedBy, b.UpdatedBy)
return err
}, func(id int64) error { return del(ctx, e, "blocklists", id) }); err != nil {
return err
}
return diffMap(old.BlockRules, new.BlockRules, func(r *model.BlockRule) error {
_, err := e.ExecContext(ctx, `INSERT OR REPLACE INTO block_rules (id, name, action, exact, view_ids, comment, version, created_at, updated_at, created_by, updated_by) VALUES (?,?,?,?,?,?,?,?,?,?,?)`,
r.ID, r.Name, r.Action, r.Exact, jsonText(nonNilIDs(r.ViewIDs)), r.Comment, r.Version, ts(r.CreatedAt), ts(r.UpdatedAt), r.CreatedBy, r.UpdatedBy)
return err
}, func(id int64) error { return del(ctx, e, "block_rules", id) })
}
func nonNilIDs(ids []int64) []int64 {
if ids == nil {
return []int64{}
}
return ids
}
func nonNil(s []string) []string {
+37
View File
@@ -263,6 +263,43 @@ CREATE TABLE recovery_codes (
// 5: disabled records, kept but left out of the zone.
`
ALTER TABLE records ADD COLUMN disabled INTEGER NOT NULL DEFAULT 0;
`,
// 6: blocking (docs/blocking.md). blocklist_status holds what jarld
// learns downloading lists; it is not configuration.
`
CREATE TABLE blocklists (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
url TEXT NOT NULL,
view_ids TEXT NOT NULL DEFAULT '[]',
disabled INTEGER NOT NULL DEFAULT 0,
comment TEXT NOT NULL DEFAULT '',
version INTEGER NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL,
created_by TEXT NOT NULL DEFAULT '', updated_by TEXT NOT NULL DEFAULT ''
);
CREATE TABLE block_rules (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
action TEXT NOT NULL,
exact INTEGER NOT NULL DEFAULT 0,
view_ids TEXT NOT NULL DEFAULT '[]',
comment TEXT NOT NULL DEFAULT '',
version INTEGER NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL,
created_by TEXT NOT NULL DEFAULT '', updated_by TEXT NOT NULL DEFAULT ''
);
CREATE TABLE blocklist_status (
id INTEGER PRIMARY KEY,
url TEXT NOT NULL DEFAULT '',
etag TEXT NOT NULL DEFAULT '',
last_modified TEXT NOT NULL DEFAULT '',
checked_at TEXT,
updated_at TEXT,
entries INTEGER NOT NULL DEFAULT 0,
records INTEGER NOT NULL DEFAULT 0,
skipped INTEGER NOT NULL DEFAULT 0,
sha256 TEXT NOT NULL DEFAULT '',
error TEXT NOT NULL DEFAULT ''
);
`,
}