jarl block pause|resume (and the Blocking page) lets every name through for a while; jarld resumes on time. The lists stay loaded: the rules zone just passes everything. Statistics gain the clients with the most blocked queries and blocked queries on the queries chart. The view choice in the blocking forms is a dropdown. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
284 lines
11 KiB
Go
284 lines
11 KiB
Go
package engine
|
|
|
|
import (
|
|
"context"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
|
|
"jarl/internal/blocking"
|
|
"jarl/internal/model"
|
|
)
|
|
|
|
func blockRule(name, action string, exact bool, views ...string) Request {
|
|
in := BlockRuleInput{Name: &name, Action: &action, Exact: &exact}
|
|
if len(views) > 0 {
|
|
in.Views = &views
|
|
}
|
|
return NewRequest(OpBlockRuleCreate, 0, in)
|
|
}
|
|
|
|
func blocklist(name, url string, views ...string) Request {
|
|
in := BlocklistInput{Name: &name, URL: &url}
|
|
if len(views) > 0 {
|
|
in.Views = &views
|
|
}
|
|
return NewRequest(OpBlocklistCreate, 0, in)
|
|
}
|
|
|
|
// listServer serves a blocklist with an ETag, and counts the downloads
|
|
// that sent content.
|
|
type listServer struct {
|
|
body atomic.Value
|
|
full atomic.Int32
|
|
calls atomic.Int32
|
|
}
|
|
|
|
func newListServer(t *testing.T, body string) (*listServer, *httptest.Server) {
|
|
ls := &listServer{}
|
|
ls.body.Store(body)
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
ls.calls.Add(1)
|
|
b := ls.body.Load().(string)
|
|
etag := `"` + string(rune('a'+len(b)%26)) + "x" + string(rune('a'+len(b)/26%26)) + `"`
|
|
if r.Header.Get("If-None-Match") == etag {
|
|
w.WriteHeader(http.StatusNotModified)
|
|
return
|
|
}
|
|
w.Header().Set("ETag", etag)
|
|
ls.full.Add(1)
|
|
w.Write([]byte(b))
|
|
}))
|
|
t.Cleanup(srv.Close)
|
|
return ls, srv
|
|
}
|
|
|
|
func TestBlockingRender(t *testing.T) {
|
|
env := newEnv(t)
|
|
setupZone(env)
|
|
env.submit(env.admin,
|
|
NewRequest(OpViewCreate, 0, ViewInput{Name: model.Ptr("internal"), MatchClients: &[]string{"10.0.0.0/8"}, AdoptZones: true}),
|
|
NewRequest(OpViewCreate, 0, ViewInput{Name: model.Ptr("external")}),
|
|
)
|
|
st := env.state()
|
|
ext := st.ViewByName("external")
|
|
env.submit(env.admin, NewRequest(OpOptionCreate, 0, OptionInput{Scope: model.Ptr(model.ScopeView), ScopeID: &ext.ID, Name: model.Ptr("recursion"), Value: model.Ptr("no")}))
|
|
|
|
_, srv := newListServer(t, "||ads.example^\n")
|
|
res := env.submit(env.admin,
|
|
blocklist("ads", srv.URL+"/list.txt"),
|
|
blockRule("tracker.example", "block", false),
|
|
blockRule("cdn.tracker.example", "allow", true),
|
|
)
|
|
if res.Ops[1].Summary != "block tracker.example (and below)" || res.Ops[2].Summary != "allow cdn.tracker.example" {
|
|
t.Errorf("summaries: %q, %q", res.Ops[1].Summary, res.Ops[2].Summary)
|
|
}
|
|
conf, _ := os.ReadFile(filepath.Join(env.dir, "etc/named.conf"))
|
|
c := string(conf)
|
|
// Automatic views: only internal offers recursion.
|
|
internal := c[strings.Index(c, `view "internal"`):strings.Index(c, `view "external"`)]
|
|
for _, want := range []string{
|
|
"response-policy {\n\t\tzone \"rules.jarl.invalid\" min-update-interval 0 log no;\n\t\tzone \"ads.blocklist.jarl.invalid\" min-update-interval 0 log no;\n\t} qname-wait-recurse no break-dnssec yes;",
|
|
`zone "rules.jarl.invalid" {`,
|
|
"allow-query { none; };",
|
|
} {
|
|
if !strings.Contains(internal, want) {
|
|
t.Errorf("view internal lacks %q:\n%s", want, internal)
|
|
}
|
|
}
|
|
if strings.Contains(c[strings.Index(c, `view "external"`):], "response-policy") {
|
|
t.Error("view external, without recursion, got blocking")
|
|
}
|
|
rules, err := os.ReadFile(filepath.Join(env.dir, "zones/internal/rules.jarl.invalid.rpz"))
|
|
if err != nil || !strings.Contains(string(rules), "cdn.tracker.example\tCNAME\trpz-passthru.\n*.cdn.tracker.example\tCNAME\t.\n") {
|
|
t.Errorf("rules zone: %v\n%s", err, rules)
|
|
}
|
|
// The list's file exists as an empty policy zone until downloaded.
|
|
b := env.state().BlocklistByName("ads")
|
|
listFile := filepath.Join(env.dir, "cache/blocklists/list-"+strconv.FormatInt(b.ID, 10)+".rpz")
|
|
if f, err := os.ReadFile(listFile); err != nil || !strings.Contains(string(f), "not downloaded yet") {
|
|
t.Errorf("placeholder: %v %s", err, f)
|
|
}
|
|
|
|
// A view a list or rule names can't be deleted.
|
|
res = env.submit(env.admin, NewRequest(OpBlocklistUpdate, b.ID, BlocklistInput{Views: &[]string{"external"}}))
|
|
if len(res.Problems) != 1 || res.Problems[0].Code != "blocking_no_recursion" {
|
|
t.Errorf("a list in a view without recursion: %+v", res.Problems)
|
|
}
|
|
if e := env.submitErr(env.admin, NewRequest(OpViewDelete, ext.ID, DeleteInput{Cascade: true})); e.Kind != KindConflict || !strings.Contains(e.Message, "blocklist ads") {
|
|
t.Errorf("deleting a used view: %+v", e)
|
|
}
|
|
st = env.state()
|
|
// A second rule for the same name and view is refused.
|
|
if e := env.submitErr(env.admin, blockRule("tracker.example", "allow", true)); e.Kind != KindValidation || !strings.Contains(e.Message, "two rules for tracker.example") {
|
|
t.Errorf("duplicate rule: %+v", e)
|
|
}
|
|
// So is a response-policy option where Jarl writes one.
|
|
in := st.ViewByName("internal")
|
|
if e := env.submitErr(env.admin, NewRequest(OpOptionCreate, 0, OptionInput{Scope: model.Ptr(model.ScopeView), ScopeID: &in.ID, Name: model.Ptr("response-policy"), Value: model.Ptr(`{ zone "x"; }`)})); e.Kind != KindValidation {
|
|
t.Errorf("response-policy option: %+v", e)
|
|
}
|
|
// Bad input.
|
|
for _, req := range []Request{blocklist("Bad Name", srv.URL), blocklist("ok", "ftp://x/list"), blockRule("com", "block", false), blockRule("a.example", "deny", false), blockRule("a.example", "block", false, "nope")} {
|
|
if e := env.submitErr(env.admin, req); e.Kind != KindBadRequest {
|
|
t.Errorf("%s: %+v", req.Data, e)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestBlocklistRefresh(t *testing.T) {
|
|
env := newEnv(t)
|
|
setupZone(env)
|
|
ls, srv := newListServer(t, "# test\n||ads.example^\n0.0.0.0 tracker.example\nnot a domain line\n")
|
|
env.submit(env.admin, blocklist("ads", srv.URL), blockRule("good.ads.example", "allow", false))
|
|
b := env.state().BlocklistByName("ads")
|
|
ctx := context.Background()
|
|
|
|
env.e.RefreshDue(ctx)
|
|
s, _ := env.st.BlocklistStatuses(ctx)
|
|
st := s[b.ID]
|
|
if st == nil || st.Error != "" || st.Entries != 2 || st.Records != 3 || st.Skipped != 1 || st.UpdatedAt == nil {
|
|
t.Fatalf("status after the first download: %+v", st)
|
|
}
|
|
file := env.e.cfg.Paths.BlocklistFile(b.ID)
|
|
if f, _ := os.ReadFile(file); !strings.Contains(string(f), "*.ads.example\tCNAME\t.\n") || !strings.Contains(string(f), "tracker.example\tCNAME\t.\n") {
|
|
t.Errorf("list zone:\n%s", f)
|
|
}
|
|
// Not due again: nothing is fetched.
|
|
env.e.RefreshDue(ctx)
|
|
if ls.calls.Load() != 1 {
|
|
t.Errorf("downloads after a second RefreshDue: %d", ls.calls.Load())
|
|
}
|
|
// A cleared cache leaves an empty placeholder: downloaded again at once.
|
|
os.Remove(file)
|
|
env.e.RefreshDue(ctx)
|
|
if ls.calls.Load() != 2 {
|
|
t.Errorf("downloads after the file was lost: %d", ls.calls.Load())
|
|
}
|
|
if f, _ := os.ReadFile(file); !strings.Contains(string(f), "*.ads.example\tCNAME\t.\n") {
|
|
t.Errorf("list zone after the file was lost:\n%s", f)
|
|
}
|
|
// Unchanged on the server: 304, no rewrite.
|
|
firstHash := st.SHA256
|
|
fullBefore := ls.full.Load()
|
|
env.e.RefreshBlocklist(ctx, b.ID, false)
|
|
if ls.full.Load() != fullBefore {
|
|
t.Errorf("a conditional download fetched the list again")
|
|
}
|
|
// Changed: rewritten.
|
|
ls.body.Store("||ads.example^\n||more.example^\n")
|
|
st, err := env.e.RefreshBlocklist(ctx, b.ID, false)
|
|
if err != nil || st.Entries != 2 || st.Records != 4 || st.SHA256 == firstHash {
|
|
t.Errorf("after a change: %+v, %v", st, err)
|
|
}
|
|
// A broken download keeps the previous version and reports why.
|
|
before, _ := os.ReadFile(file)
|
|
ls.body.Store("<html><body>Not found</body></html>\n")
|
|
st, err = env.e.RefreshBlocklist(ctx, b.ID, true)
|
|
if err == nil || !strings.Contains(st.Error, "no domains found") {
|
|
t.Errorf("garbage: %+v, %v", st, err)
|
|
}
|
|
if after, _ := os.ReadFile(file); string(after) != string(before) {
|
|
t.Error("a failed update changed the list's file")
|
|
}
|
|
// A failed attempt is retried after RetryAfter, not at every check.
|
|
calls := ls.calls.Load()
|
|
env.e.RefreshDue(ctx)
|
|
if ls.calls.Load() != calls {
|
|
t.Error("a failed list was tried again before RetryAfter")
|
|
}
|
|
// Too big.
|
|
env.e.Limits.MaxBytes = 10
|
|
ls.body.Store("||ads.example^\n||more.example^\n||third.example^\n")
|
|
if st, _ = env.e.RefreshBlocklist(ctx, b.ID, true); !strings.Contains(st.Error, "larger than") {
|
|
t.Errorf("size limit: %+v", st)
|
|
}
|
|
env.e.Limits = DefaultBlocklistLimits()
|
|
env.e.RefreshBlocklist(ctx, b.ID, true)
|
|
|
|
// What blocks what.
|
|
check := func(name string) blocking.Match {
|
|
m, err := env.e.CheckBlocking(env.state(), name)
|
|
if err != nil || len(m) != 1 {
|
|
t.Fatalf("check %s: %+v %v", name, m, err)
|
|
}
|
|
return m[0]
|
|
}
|
|
if m := check("x.ads.example"); m.Result != "blocked" || m.List != "ads" || m.Entry != "*.ads.example" {
|
|
t.Errorf("x.ads.example: %+v", m)
|
|
}
|
|
if m := check("a.good.ads.example"); m.Result != "allowed" || m.RuleID == 0 || m.Entry != "*.good.ads.example" {
|
|
t.Errorf("a.good.ads.example: %+v", m)
|
|
}
|
|
if m := check("fine.example"); m.Result != "none" {
|
|
t.Errorf("fine.example: %+v", m)
|
|
}
|
|
|
|
// Deleting the list removes its file and status.
|
|
env.submit(env.admin, NewRequest(OpBlocklistDelete, b.ID, nil))
|
|
env.e.RefreshDue(ctx)
|
|
if _, err := os.Stat(file); !os.IsNotExist(err) {
|
|
t.Errorf("the deleted list's file is still there: %v", err)
|
|
}
|
|
if s, _ := env.st.BlocklistStatuses(ctx); s[b.ID] != nil {
|
|
t.Error("the deleted list's status is still there")
|
|
}
|
|
}
|
|
|
|
func TestBlockingPause(t *testing.T) {
|
|
env := newEnv(t)
|
|
setupZone(env)
|
|
env.submit(env.admin, blocklist("ads", "https://example.org/list"))
|
|
rulesFile := filepath.Join(env.dir, "zones/_default/rules.jarl.invalid.rpz")
|
|
if _, err := os.Stat(rulesFile); !os.IsNotExist(err) {
|
|
t.Fatal("a rules zone without rules or a pause")
|
|
}
|
|
until := env.e.Now().Add(10 * time.Minute)
|
|
res := env.submit(env.admin, NewRequest(OpBlockingPause, 0, BlockingPauseInput{Until: &until}))
|
|
if !strings.HasPrefix(res.Ops[0].Summary, "pause blocking until ") {
|
|
t.Errorf("summary = %q", res.Ops[0].Summary)
|
|
}
|
|
f, err := os.ReadFile(rulesFile)
|
|
if err != nil || !strings.HasSuffix(string(f), "NS\tlocalhost.\n*\tCNAME\trpz-passthru.\n") {
|
|
t.Fatalf("paused rules zone: %v\n%s", err, f)
|
|
}
|
|
conf, _ := os.ReadFile(filepath.Join(env.dir, "etc/named.conf"))
|
|
if !strings.Contains(string(conf), "zone \"rules.jarl.invalid\" min-update-interval 0 log no;\n\t\tzone \"ads.") {
|
|
t.Errorf("the paused rules zone must come first:\n%s", conf)
|
|
}
|
|
// Not yet time: nothing happens.
|
|
if got := env.e.ResumeBlocking(context.Background()); !got.Equal(until) {
|
|
t.Errorf("ResumeBlocking before the end = %v", got)
|
|
}
|
|
// Time is up: resumed, and the rules zone is gone again.
|
|
env.submit(env.admin, blockRule("ads.example", "block", false))
|
|
env.e.Now = func() time.Time { return until.Add(time.Second) }
|
|
if got := env.e.ResumeBlocking(context.Background()); !got.IsZero() || env.state().Settings.BlockingPausedUntil != nil {
|
|
t.Fatalf("after the pause: %v, %v", got, env.state().Settings.BlockingPausedUntil)
|
|
}
|
|
if f, _ := os.ReadFile(rulesFile); !strings.Contains(string(f), "*.ads.example\tCNAME\t.\n") || strings.Contains(string(f), "passthru") {
|
|
t.Errorf("rules zone after the pause:\n%s", f)
|
|
}
|
|
// Reverting a pause resumes blocking.
|
|
until = env.e.Now().Add(time.Hour)
|
|
res = env.submit(env.admin, NewRequest(OpBlockingPause, 0, BlockingPauseInput{Until: &until}))
|
|
rev, err := env.e.Revert(context.Background(), res.ChangesetID, env.admin)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := env.e.Apply(context.Background(), rev.ID, env.admin, ApplyOptions{}); err != nil || env.state().Settings.BlockingPausedUntil != nil {
|
|
t.Errorf("revert of a pause: %v, %v", err, env.state().Settings.BlockingPausedUntil)
|
|
}
|
|
// At most a day.
|
|
long := env.e.Now().Add(48 * time.Hour)
|
|
if e := env.submitErr(env.admin, NewRequest(OpBlockingPause, 0, BlockingPauseInput{Until: &long})); e.Kind != KindBadRequest {
|
|
t.Errorf("a two-day pause: %+v", e)
|
|
}
|
|
}
|