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("Not found\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) } }