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

+272
View File
@@ -0,0 +1,272 @@
package engine
import (
"encoding/json"
"fmt"
"net/url"
"regexp"
"slices"
"strings"
"jarl/internal/auth"
"jarl/internal/blocking"
"jarl/internal/model"
)
// ---- blocking: blocklists and rules (docs/blocking.md)
var blocklistNameRe = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]{0,38}[a-z0-9])?$`)
func blockingAction() auth.Action { return auth.Action{Resource: auth.ResBlocking, Write: true} }
// viewIDs resolves the views of a blocklist or rule input: names or IDs,
// in view order, without duplicates. nil, nil means "not given".
func viewIDs(st *model.State, names *[]string, ids *[]int64) ([]int64, error) {
if names == nil && ids == nil {
return nil, nil
}
want := map[int64]bool{}
if names != nil {
for _, n := range *names {
v := st.ViewByName(strings.TrimSpace(n))
if v == nil {
return nil, errf(KindBadRequest, "view %q does not exist", n)
}
want[v.ID] = true
}
}
if ids != nil {
for _, id := range *ids {
if st.Views[id] == nil {
return nil, errf(KindBadRequest, "view %d does not exist", id)
}
want[id] = true
}
}
out := []int64{}
for _, v := range st.OrderedViews() {
if want[v.ID] {
out = append(out, v.ID)
}
}
return out, nil
}
// CheckBlocklistURL checks a blocklist URL: http or https, with a host.
func CheckBlocklistURL(raw string) (string, error) {
raw = strings.TrimSpace(raw)
u, err := url.Parse(raw)
if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" {
return "", fmt.Errorf("%q is not an http or https URL", raw)
}
return raw, nil
}
func applyBlocklistPut(st *model.State, op model.Op, c *applyCtx) error {
if err := c.require(blockingAction()); err != nil {
return err
}
var in BlocklistInput
if err := decode(op.Data, &in); err != nil {
return err
}
var b *model.Blocklist
if op.Kind == OpBlocklistCreate {
if op.ID == 0 || st.Blocklists[op.ID] != nil {
return errf(KindConflict, "blocklist ID %d is already in use", op.ID)
}
if in.Name == nil || in.URL == nil {
return errf(KindBadRequest, "a blocklist needs a name and a URL")
}
b = &model.Blocklist{ID: op.ID, ViewIDs: []int64{}}
} else {
b = st.Blocklists[op.ID]
if b == nil {
return errf(KindNotFound, "blocklist %d does not exist", op.ID)
}
if err := checkVersion(op, b.Version, "blocklist "+b.Name); err != nil {
return err
}
}
if in.Name != nil {
name := strings.ToLower(strings.TrimSpace(*in.Name))
if !blocklistNameRe.MatchString(name) {
return errf(KindBadRequest, "blocklist name %q: use 1 to 40 lowercase letters, digits and hyphens, like hagezi-pro", *in.Name)
}
if other := st.BlocklistByName(name); other != nil && other.ID != b.ID {
return errf(KindConflict, "blocklist %s already exists", name)
}
b.Name = name
}
if in.URL != nil {
u, err := CheckBlocklistURL(*in.URL)
if err != nil {
return errf(KindBadRequest, "%v", err)
}
b.URL = u
}
ids, err := viewIDs(st, in.Views, in.ViewIDs)
if err != nil {
return err
}
if ids != nil {
b.ViewIDs = ids
}
if in.Disabled != nil {
b.Disabled = *in.Disabled
}
if in.Comment != nil {
b.Comment = *in.Comment
}
b.Touch(c.user(), c.now)
st.Blocklists[b.ID] = b
return nil
}
func applyBlocklistDelete(st *model.State, op model.Op, c *applyCtx) error {
if err := c.require(blockingAction()); err != nil {
return err
}
b := st.Blocklists[op.ID]
if b == nil {
return errf(KindNotFound, "blocklist %d does not exist", op.ID)
}
if err := checkVersion(op, b.Version, "blocklist "+b.Name); err != nil {
return err
}
delete(st.Blocklists, b.ID)
return nil
}
func applyBlockRulePut(st *model.State, op model.Op, c *applyCtx) error {
if err := c.require(blockingAction()); err != nil {
return err
}
var in BlockRuleInput
if err := decode(op.Data, &in); err != nil {
return err
}
var r *model.BlockRule
if op.Kind == OpBlockRuleCreate {
if op.ID == 0 || st.BlockRules[op.ID] != nil {
return errf(KindConflict, "rule ID %d is already in use", op.ID)
}
if in.Name == nil {
return errf(KindBadRequest, "a rule needs a domain name")
}
r = &model.BlockRule{ID: op.ID, Action: model.BlockActionBlock, ViewIDs: []int64{}}
} else {
r = st.BlockRules[op.ID]
if r == nil {
return errf(KindNotFound, "rule %d does not exist", op.ID)
}
if err := checkVersion(op, r.Version, "rule "+r.Name); err != nil {
return err
}
}
if in.Name != nil {
n, err := blocking.NormalizeName(*in.Name)
if err != nil {
return errf(KindBadRequest, "%v", err)
}
r.Name = n
}
if in.Action != nil {
a := strings.ToLower(strings.TrimSpace(*in.Action))
if a != model.BlockActionBlock && a != model.BlockActionAllow {
return errf(KindBadRequest, "a rule's action is block or allow, not %q", *in.Action)
}
r.Action = a
}
if in.Exact != nil {
r.Exact = *in.Exact
}
ids, err := viewIDs(st, in.Views, in.ViewIDs)
if err != nil {
return err
}
if ids != nil {
r.ViewIDs = ids
}
if in.Comment != nil {
r.Comment = *in.Comment
}
r.Touch(c.user(), c.now)
st.BlockRules[r.ID] = r
return nil
}
func applyBlockRuleDelete(st *model.State, op model.Op, c *applyCtx) error {
if err := c.require(blockingAction()); err != nil {
return err
}
r := st.BlockRules[op.ID]
if r == nil {
return errf(KindNotFound, "rule %d does not exist", op.ID)
}
if err := checkVersion(op, r.Version, "rule "+r.Name); err != nil {
return err
}
delete(st.BlockRules, r.ID)
return nil
}
// blockingUsers lists the blocklists and rules that name a view.
func blockingUsers(st *model.State, viewID int64) []string {
var out []string
for _, b := range st.SortedBlocklists() {
if slices.Contains(b.ViewIDs, viewID) {
out = append(out, "blocklist "+b.Name)
}
}
for _, r := range st.SortedBlockRules() {
if slices.Contains(r.ViewIDs, viewID) {
out = append(out, ruleLabel(r))
}
}
return out
}
// ruleLabel describes a rule: "block ads.example (and below)".
func ruleLabel(r *model.BlockRule) string {
s := r.Action + " " + r.Name
if !r.Exact {
s += " (and below)"
}
return s
}
// ---- expansion
func (x *expander) blocklistCreate(req Request) ([]model.Op, error) {
return x.simpleCreate(req, OpBlocklistCreate, func(data json.RawMessage) (string, error) {
var in BlocklistInput
if err := decode(data, &in); err != nil {
return "", err
}
if in.Name == nil || in.URL == nil {
return "", errf(KindBadRequest, "a blocklist needs a name and a URL")
}
return "add blocklist " + *in.Name + " (" + *in.URL + ")", nil
})
}
func (x *expander) blockRuleCreate(req Request) ([]model.Op, error) {
return x.simpleCreate(req, OpBlockRuleCreate, func(data json.RawMessage) (string, error) {
var in BlockRuleInput
if err := decode(data, &in); err != nil {
return "", err
}
if in.Name == nil {
return "", errf(KindBadRequest, "a rule needs a domain name")
}
r := &model.BlockRule{Name: strings.ToLower(strings.TrimSuffix(strings.TrimSpace(*in.Name), ".")), Action: model.BlockActionBlock}
if in.Action != nil {
r.Action = strings.ToLower(*in.Action)
}
if in.Exact != nil {
r.Exact = *in.Exact
}
return ruleLabel(r), nil
})
}