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:
1 parent
436cd79ec9
commit
9fc23310bd
54 files changed
+4269
-85
No files matched your search
@@ -0,0 +1,95 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestBlockingAPI(t *testing.T) {
|
||||
e := newAPIEnv(t)
|
||||
lists := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/missing" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
w.Write([]byte("||ads.example^\n0.0.0.0 tracker.example\n"))
|
||||
}))
|
||||
defer lists.Close()
|
||||
|
||||
b := e.do("POST", "/blocking/lists", e.token, map[string]any{"name": "ads", "url": lists.URL + "/list.txt"}, 201)["object"].(map[string]any)
|
||||
if b["name"] != "ads" || b["state"] != "waiting" {
|
||||
t.Errorf("new list = %v", b)
|
||||
}
|
||||
got := e.do("POST", "/blocking/lists/ads/refresh", e.token, nil, 200)
|
||||
st, _ := got["status"].(map[string]any)
|
||||
if got["state"] != "ok" || st["entries"] != 2.0 || st["records"] != 3.0 {
|
||||
t.Errorf("after refresh = %v", got)
|
||||
}
|
||||
e.do("POST", "/blocking/rules", e.token, map[string]any{"name": "Good.Ads.Example.", "action": "allow"}, 201)
|
||||
r := e.do("POST", "/blocking/rules", e.token, map[string]any{"name": "more.example", "exact": true}, 201)["object"].(map[string]any)
|
||||
if r["name"] != "more.example" || r["action"] != "block" || r["exact"] != true {
|
||||
t.Errorf("new rule = %v", r)
|
||||
}
|
||||
for name, want := range map[string]string{"x.ads.example": "blocked", "a.good.ads.example": "allowed", "more.example": "blocked", "sub.more.example": "none"} {
|
||||
m := e.list("/blocking/check?name="+name, e.token)
|
||||
if len(m) != 1 || m[0]["result"] != want {
|
||||
t.Errorf("check %s = %v, want %s", name, m, want)
|
||||
}
|
||||
}
|
||||
if _, status := e.req("GET", "/blocking/check?name=com", e.token, nil); status != 400 {
|
||||
t.Errorf("check of a TLD = %d", status)
|
||||
}
|
||||
|
||||
// Staged changes show as pending.
|
||||
cs := itoa(int64(e.do("POST", "/changesets", e.token, map[string]any{"description": "more"}, 201)["id"].(float64)))
|
||||
e.do("POST", "/blocking/rules?changeset="+cs, e.token, map[string]any{"name": "staged.example"}, 202)
|
||||
e.do("PATCH", "/blocking/lists/ads?changeset="+cs, e.token, map[string]any{"disabled": true}, 202)
|
||||
pending := map[string]any{}
|
||||
for _, x := range e.list("/blocking/rules?changeset="+cs, e.token) {
|
||||
pending[x["name"].(string)] = x["pending"]
|
||||
}
|
||||
if pending["staged.example"] != "add" || pending["more.example"] != nil {
|
||||
t.Errorf("pending rules = %v", pending)
|
||||
}
|
||||
if l := e.list("/blocking/lists?changeset="+cs, e.token); len(l) != 1 || l[0]["pending"] != "change" || l[0]["state"] != "disabled" {
|
||||
t.Errorf("pending list = %v", l)
|
||||
}
|
||||
e.do("POST", "/changesets/"+cs+"/discard", e.token, nil, 204)
|
||||
|
||||
// Delete rules by name; a missing one is a 404 unless missing_ok.
|
||||
e.do("DELETE", "/blocking/rules?name=more.example", e.token, nil, 200)
|
||||
if _, status := e.req("DELETE", "/blocking/rules?name=more.example", e.token, nil); status != 404 {
|
||||
t.Errorf("deleting a missing rule = %d", status)
|
||||
}
|
||||
e.do("DELETE", "/blocking/rules?name=more.example&missing_ok=1", e.token, nil, 200)
|
||||
|
||||
// Readers see blocking but can't change it.
|
||||
e.do("POST", "/users", e.token, map[string]any{"username": "reader", "password": "reader-password-123", "grant_strings": []string{"blocking:read"}}, 201)
|
||||
var readerID int64
|
||||
for _, u := range e.list("/users", e.token) {
|
||||
if u["username"] == "reader" {
|
||||
readerID = int64(u["id"].(float64))
|
||||
}
|
||||
}
|
||||
rt := e.do("POST", "/users/"+itoa(readerID)+"/tokens", e.token, map[string]any{"name": "t"}, 201)["token"].(string)
|
||||
if l := e.list("/blocking/lists", rt); len(l) != 1 {
|
||||
t.Errorf("reader's lists = %v", l)
|
||||
}
|
||||
for _, c := range []struct{ method, path string }{{"POST", "/blocking/rules"}, {"POST", "/blocking/lists/ads/refresh"}, {"DELETE", "/blocking/lists/ads"}} {
|
||||
if _, status := e.req(c.method, c.path, rt, map[string]any{"name": "x.example"}); status != 403 {
|
||||
t.Errorf("reader %s %s = %d", c.method, c.path, status)
|
||||
}
|
||||
}
|
||||
// A list that can't be downloaded shows on the dashboard.
|
||||
e.do("POST", "/blocking/lists", e.token, map[string]any{"name": "gone", "url": lists.URL + "/missing"}, 201)
|
||||
if l := e.do("POST", "/blocking/lists/gone/refresh", e.token, nil, 200); l["state"] != "error" {
|
||||
t.Errorf("failed list = %v", l)
|
||||
}
|
||||
errs, _ := e.do("GET", "/status", e.token, nil, 200)["blocklist_errors"].([]any)
|
||||
if len(errs) != 1 || errs[0].(map[string]any)["name"] != "gone" {
|
||||
t.Errorf("status blocklist_errors = %v", errs)
|
||||
}
|
||||
e.do("DELETE", "/blocking/lists/gone", e.token, nil, 200)
|
||||
e.do("DELETE", "/blocking/lists/ads", e.token, nil, 200)
|
||||
}
|
||||
Reference in new issue
Block a user