From 27d1f9e538c4e22313c04162b26fd539118ec6f8 Mon Sep 17 00:00:00 2001 From: adam Date: Sat, 10 Oct 2026 11:10:44 +0000 Subject: [PATCH] Pause blocking, blocked clients and a blocked line in the statistics, compact view picker 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 --- CHANGELOG.md | 13 +++- cmd/jarl/blocking.go | 76 ++++++++++++++++++- cmd/jarl/stats.go | 10 ++- cmd/jarld/main.go | 11 ++- docs/api.md | 10 ++- docs/blocking.md | 27 ++++++- docs/cli.md | 4 +- docs/stats.md | 2 +- internal/api/types.go | 2 + internal/blocking/zone.go | 7 ++ internal/engine/blocking_test.go | 52 +++++++++++++ internal/engine/blockops.go | 36 +++++++++ internal/engine/diffops.go | 4 + internal/engine/engine.go | 2 +- internal/engine/expand.go | 10 +++ internal/engine/inputs.go | 13 +++- internal/engine/ops.go | 2 + internal/model/model.go | 5 +- internal/render/blocking.go | 18 ++++- internal/server/blocking_test.go | 16 ++++ internal/server/h_blocking.go | 35 +++++++++ internal/server/h_changes.go | 3 + internal/server/h_stats.go | 2 +- internal/server/routes.go | 3 + internal/stats/blocking_test.go | 3 + internal/stats/collector.go | 5 +- internal/web/static/assets/pages/blocking.js | 61 +++++++++++---- internal/web/static/assets/pages/dashboard.js | 6 +- internal/web/static/assets/pages/stats.js | 13 +++- internal/web/static/assets/style.css | 3 + scripts/e2e.sh | 8 ++ 31 files changed, 417 insertions(+), 45 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 365bd21..4d845ad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,9 +26,16 @@ All notable changes to Jarl are documented here. The format follows - Web UI: a Blocking page, with well-known lists to pick and named's memory per list. CLI: `jarl blocklist` and `jarl block`. API: `/blocking/lists`, `/blocking/rules`, `/blocking/check`. - - Statistics: blocked queries and their share, blocked per list, most - blocked names (`jarl stats top blocked`). Blocked answers no longer - count as names that don't exist. + - Pausing: `jarl block pause [DURATION]` or the Blocking page lets every + name through for a while (at most a day); blocking resumes by itself. + The lists stay loaded, so it takes effect at once. + - Statistics: blocked queries over time (a line on the queries chart) + and their share, blocked per list, the most blocked names and the + clients with the most blocked queries (`jarl stats top blocked`, + `top blocked-clients`). Blocked answers no longer count as names that + don't exist. + - The view choice in the web UI's forms is a compact dropdown, however + many views there are. - A `blocking` permission; database schema 6. ### Fixed diff --git a/cmd/jarl/blocking.go b/cmd/jarl/blocking.go index 4afe5df..f6d433b 100644 --- a/cmd/jarl/blocking.go +++ b/cmd/jarl/blocking.go @@ -1,6 +1,7 @@ package main import ( + "context" "fmt" "io" "net/url" @@ -309,7 +310,11 @@ names below it (docs/blocking.md).`} if err := c.Get(ctx(), "/blocking/rules", nil, &rules); err != nil { return err } + note := pausedNote(c) return output(rules, func(w io.Writer) { + if note != "" { + fmt.Fprintln(w, note) + } fmt.Fprintln(w, "ID\tACTION\tDOMAIN\tCOVERS\tVIEWS\tCOMMENT") for _, r := range rules { covers := "and below" @@ -372,7 +377,11 @@ names below it (docs/blocking.md).`} if err := c.Get(ctx(), "/blocking/check", q("name", args[0]), &matches); err != nil { return err } + note := pausedNote(c) return output(matches, func(w io.Writer) { + if note != "" { + fmt.Fprint(w, note+"While it is, every name is answered. Otherwise:\n") + } if len(matches) == 0 { fmt.Fprintln(w, "Blocking isn't set up in any view.") return @@ -395,6 +404,71 @@ names below it (docs/blocking.md).`} }, } - cmd.AddCommand(addRule("block"), addRule("allow"), list, del, check) + pause := &cobra.Command{ + Use: "pause [DURATION]", + Short: "Let every name through for a while (default 10m, at most 24h)", + Long: `Pause blocking: every name is answered normally until the time is up, +then blocking resumes by itself. Useful to see whether blocking is what +breaks a site. The lists stay loaded, so pausing and resuming take effect +at once. + + jarl block pause # 10 minutes + jarl block pause 1h + jarl block resume`, + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + d := 10 * time.Minute + if len(args) == 1 { + var err error + if d, err = time.ParseDuration(args[0]); err != nil || d < time.Minute { + return fmt.Errorf("invalid duration %q (like 10m or 2h)", args[0]) + } + } + c, err := newClient() + if err != nil { + return err + } + resp, err := c.Mutate(ctx(), "POST", "/blocking/pause", nil, map[string]any{"minutes": int(d.Round(time.Minute) / time.Minute)}) + if err != nil { + return err + } + if err := report(resp); err != nil || flagQuiet || !resp.Applied { + return err + } + fmt.Printf("Blocking is paused until %s.\n", time.Now().Add(d.Round(time.Minute)).Format("15:04")) + return nil + }, + } + resume := &cobra.Command{ + Use: "resume", + Short: "End a pause: block again now", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + c, err := newClient() + if err != nil { + return err + } + resp, err := c.Mutate(ctx(), "POST", "/blocking/resume", nil, nil) + if err != nil { + return err + } + return report(resp) + }, + } + + cmd.AddCommand(addRule("block"), addRule("allow"), list, del, check, pause, resume) return cmd } + +// pausedNote returns a line about a pause of blocking, or "". +func pausedNote(c interface { + Get(context.Context, string, url.Values, any) error +}) string { + var st struct { + PausedUntil *time.Time `json:"paused_until"` + } + if c.Get(ctx(), "/blocking/status", nil, &st) != nil || st.PausedUntil == nil { + return "" + } + return "Blocking is paused until " + st.PausedUntil.Local().Format("2006-01-02 15:04") + " ('jarl block resume' ends it).\n" +} diff --git a/cmd/jarl/stats.go b/cmd/jarl/stats.go index 0fe34c6..103aced 100644 --- a/cmd/jarl/stats.go +++ b/cmd/jarl/stats.go @@ -128,13 +128,14 @@ export the data.`, var limit int top := &cobra.Command{ - Use: "top [names|clients|nxdomain|servfail|refused|blocked]", + Use: "top [names|clients|nxdomain|servfail|refused|blocked|blocked-clients]", Short: "The most queried names, busiest clients, names with errors, refused clients, blocked names", Example: ` jarl stats top names --since 7d jarl stats top clients --limit 50 jarl stats top nxdomain # misconfigured hosts and typos jarl stats top refused # who is asking for what they may not have - jarl stats top blocked # what blocking stops most (docs/blocking.md)`, + jarl stats top blocked # what blocking stops most (docs/blocking.md) + jarl stats top blocked-clients # the devices blocking stops most`, Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { kind := stats.KindName @@ -143,6 +144,9 @@ export the data.`, if kind == "nxdomain" || kind == "servfail" { kind = strings.ToLower(args[0]) } + if kind == "blocked-client" { + kind = stats.KindBlockedClient + } } c, err := newClient() if err != nil { @@ -157,7 +161,7 @@ export the data.`, for _, e := range es { total += e.Count } - head := map[string]string{stats.KindClient: "CLIENT", stats.KindRefused: "CLIENT"}[kind] + head := map[string]string{stats.KindClient: "CLIENT", stats.KindRefused: "CLIENT", stats.KindBlockedClient: "CLIENT"}[kind] if head == "" { head = "NAME" } diff --git a/cmd/jarld/main.go b/cmd/jarld/main.go index af29378..9f18f83 100644 --- a/cmd/jarld/main.go +++ b/cmd/jarld/main.go @@ -339,22 +339,27 @@ func serveCmd() *cobra.Command { // blocklistLoop keeps blocklists up to date (docs/blocking.md): every few // minutes it downloads the lists that are due, and at once when a list -// was added or changed. +// was added or changed. It also ends a pause of blocking on time. func blocklistLoop(ctx context.Context, a *app) { select { case <-ctx.Done(): return case <-time.After(10 * time.Second): // let jarld finish starting } - t := time.NewTicker(5 * time.Minute) - defer t.Stop() for { a.eng.RefreshDue(ctx) + wait := 5 * time.Minute + if until := a.eng.ResumeBlocking(ctx); !until.IsZero() { + wait = min(wait, max(time.Until(until), time.Second)) + } + t := time.NewTimer(wait) select { case <-ctx.Done(): + t.Stop() return case <-t.C: case <-a.eng.BlocklistKicks(): + t.Stop() } } } diff --git a/docs/api.md b/docs/api.md index d66c602..0159f8f 100644 --- a/docs/api.md +++ b/docs/api.md @@ -426,7 +426,10 @@ Needs `blocking:read` (`blocking:write` to change). See | POST | `/blocking/rules` | `{"name": "doubleclick.net", "action": "block", "exact": false, "views": [], "comment": ""}` (`action` block or allow; `exact`: only the name, not the names below it) | | PATCH / DELETE | `/blocking/rules/{id}` | | | DELETE | `/blocking/rules` | `?name=DOMAIN[&view=V][&missing_ok=1]`: the rules for a domain | -| GET | `/blocking/check` | `?name=DOMAIN`: per view with blocking, `{view, result: blocked\|allowed\|none, entry, rule_id, list}` | +| GET | `/blocking/check` | `?name=DOMAIN`: per view with blocking, `{view, result: blocked\|allowed\|none, entry, rule_id, list}` (what applies when blocking isn't paused) | +| GET | `/blocking/status` | `{paused_until}` (null when not paused) | +| POST | `/blocking/pause` | `{"minutes": 15}` (1 to 1440): let every name through until then; jarld resumes by itself | +| POST | `/blocking/resume` | end a pause now | Lists and rules are configuration like the rest: they can be staged, and listing with `?changeset=` marks them `pending`. @@ -484,6 +487,7 @@ An **Operation** is `{"op": "...", "id": 0, "base_version": 0, "data": {...}}`: | `settings.update` | | settings fields | | `blocklist.create` / `blocklist.update` / `blocklist.delete` | blocklist (update/delete) | list fields; views as `views` (names) or `view_ids` | | `blockrule.create` / `blockrule.update` / `blockrule.delete` | rule (update/delete) | rule fields, likewise | +| `blocking.pause` | | `{"until": "2026-10-10T15:04:05Z"}`, or `null` to resume | Operations later in a batch can refer to objects created earlier in it by name (zone and view names). One request can therefore create a view, a zone @@ -493,7 +497,7 @@ in it and its records. | Method | Path | Notes | |--------|------|-------| -| GET | `/status` | named status, adoption, drift, open changesets, current problems, object counts; `expiring_tokens` (tokens that expire within 14 days or expired in the last 14: all for administrators, otherwise the caller's); `backup` (administrators: `last_at`, `last_file`, `last_size`, `last_error`, `last_error_at`, `dir`, `warn_after` in seconds, `stale`); `blocklist_errors` (with `blocking:read`: lists whose last update failed, `{name, error, updated_at}`) | +| GET | `/status` | named status, adoption, drift, open changesets, current problems, object counts; `expiring_tokens` (tokens that expire within 14 days or expired in the last 14: all for administrators, otherwise the caller's); `backup` (administrators: `last_at`, `last_file`, `last_size`, `last_error`, `last_error_at`, `dir`, `warn_after` in seconds, `stale`); `blocklist_errors` (with `blocking:read`: lists whose last update failed, `{name, error, updated_at}`); `blocking_paused_until` (with `blocking:read`, while paused) | | POST | `/system/check` | validation + named-checkconf of the current configuration (`options:read`) | | POST | `/system/resync` | `{"dry_run": false, "force": false}`: rewrite every file from the database and reload (admin) | | GET | `/audit` | `?user=&action=&outcome=&object_type=&changeset=&q=&since=&until=&limit=200&before=ID` | @@ -508,7 +512,7 @@ Needs `stats:read`. When statistics are disabled, every endpoint but | GET | `/stats/status` | | `{enabled, query_log, statistics_channel, log_file, last_poll, last_log_line, log_lines_read, first_data, retention, warnings}` | | GET | `/stats/summary` | `since` (90m, 24h, 7d, 2w; default 24h), `top` (default 10) | totals, `avg_qps`, `peak`, `rcodes`, `qtypes`, `views`, `zones`, `answers`, `transport`, `cache_hit_ratio`, `nxdomain_ratio` (without blocked answers), `blocked`, `blocked_ratio`, `blocklists` (blocked per list; `rules` for your rules), `tops` | | GET | `/stats/timeline` | `since`, `step` (auto, 1m, 5m, 15m, 1h, 6h, 1d), `key` (repeatable or comma separated; `rcode:*` = every counter of a group; default `queries`) | `{step, times: [unix...], series: [{key, values}]}` | -| GET | `/stats/top` | `kind` (name, client, nxdomain, servfail, refused, blocked), `since`, `limit` (default 20) | `[{key, count}]`; client and refused entries add `name`, the host name of Jarl's record for the address (the summary's client list too) | +| GET | `/stats/top` | `kind` (name, client, nxdomain, servfail, refused, blocked, blocked_client), `since`, `limit` (default 20) | `[{key, count}]`; client, refused and blocked_client entries add `name`, the host name of Jarl's record for the address (the summary's client list too) | | GET | `/stats/heatmap` | `since` (default 28d), `key` (default queries) | `{key, from, avg[7][24], samples[7][24]}`, Monday first, local time, `-1` = no data | | GET | `/stats/breakdown` | `group` (qtype, rcode, view, zone, answer, transport, rtt, dnssec, cache, ratelimit, blocklist), `since` | `[{key, count}]` | diff --git a/docs/blocking.md b/docs/blocking.md index d185565..81b515f 100644 --- a/docs/blocking.md +++ b/docs/blocking.md @@ -17,6 +17,7 @@ does the blocking itself; jarld only prepares the lists. - [Your rules](#your-rules) - [Blocklists](#blocklists) - [Checking a name](#checking-a-name) +- [Pausing](#pausing) - [Statistics](#statistics) - [Memory and performance](#memory-and-performance) - [How it works](#how-it-works) @@ -163,13 +164,31 @@ Views without blocking aren't listed. In the web UI a blocked name gets an **Allow** button, and the statistics' most blocked names link to this check. +## Pausing + +To see whether blocking is what breaks a site, pause it: every name is +answered normally until the time is up (at most a day), then blocking +resumes by itself, also if jarld was restarted in between. + +```sh +jarl block pause # 10 minutes; or: jarl block pause 1h +jarl block resume # end it now +``` + +In the web UI, **Pause blocking** is at the top of the Blocking page, and +the dashboard says while blocking is paused. The lists stay loaded (the +rules zone just lets everything through), so pausing and resuming take +effect within a second. Pausing and resuming are recorded in the history +like other changes. + ## Statistics With [query statistics](stats.md) on (including `query_log`), Jarl counts -what blocking does: blocked queries over time and as a share of all -queries, which rules and lists blocked them, and the most blocked names -(`jarl stats summary`, `jarl stats top blocked`, `jarl stats breakdown -blocklist`). Blocked answers are left out of the NXDOMAIN figures, which +what blocking does: blocked queries over time (next to all queries) and +as a share of them, which rules and lists blocked them, the most blocked +names, and the clients with the most blocked queries (`jarl stats summary`, +`jarl stats top blocked`, `jarl stats top blocked-clients`, `jarl stats +breakdown blocklist`). Blocked answers are left out of the NXDOMAIN figures, which are about names that really don't exist. Without statistics named doesn't log what it blocks at all. diff --git a/docs/cli.md b/docs/cli.md index b607510..f9808b6 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -419,7 +419,7 @@ working. ```sh jarl stats status jarl stats summary --since 7d -jarl stats top names | clients | nxdomain | servfail | refused | blocked [--since 24h] [--limit 20] +jarl stats top names | clients | nxdomain | servfail | refused | blocked | blocked-clients [--since 24h] [--limit 20] jarl stats timeline --since 6h [--key rcode:NXDOMAIN] [--table] jarl stats heatmap --since 28d jarl stats breakdown qtype | rcode | view | zone | answer | rtt | blocklist @@ -442,6 +442,8 @@ jarl block allow DOMAIN [--exact] [--view V]... # an exception that beats every jarl block list jarl block delete DOMAIN [--view V] | --id ID jarl block check DOMAIN # blocked? by which rule or list? +jarl block pause [DURATION] # let everything through for a while (default 10m, at most 24h) +jarl block resume ``` See [blocking.md](blocking.md). diff --git a/docs/stats.md b/docs/stats.md index 7e0aebb..7e26656 100644 --- a/docs/stats.md +++ b/docs/stats.md @@ -107,7 +107,7 @@ lists, views, zones). named's own counters can't be filtered. **Top lists** (per hour and per day, the `top_n` largest): `name` (most queried names), `client`, `nxdomain` (names answered NXDOMAIN), `servfail` (names answered SERVFAIL), `refused` (clients that were refused), `blocked` -(names blocking answered). Blocked answers are NXDOMAIN too, but they +(names blocking answered), `blocked_client` (clients by blocked queries). Blocked answers are NXDOMAIN too, but they aren't counted as names that don't exist: not in the `nxdomain` list, and not in the summary's NXDOMAIN ratio. diff --git a/internal/api/types.go b/internal/api/types.go index c148542..aac1dd2 100644 --- a/internal/api/types.go +++ b/internal/api/types.go @@ -245,6 +245,8 @@ type StatusResponse struct { // BlocklistErrors are blocklists whose last update failed (for those // who may see blocking). BlocklistErrors []BlocklistError `json:"blocklist_errors,omitempty"` + // BlockingPausedUntil is set while blocking is paused. + BlockingPausedUntil *time.Time `json:"blocking_paused_until,omitempty"` } // BlocklistError is a blocklist whose last update failed. UpdatedAt is diff --git a/internal/blocking/zone.go b/internal/blocking/zone.go index 8f8b55a..5123d88 100644 --- a/internal/blocking/zone.go +++ b/internal/blocking/zone.go @@ -139,6 +139,13 @@ func RuleEntries(rules []Rule) map[string]string { return e } +// PausedZoneFile renders a rules zone that lets every name through, for +// while blocking is paused: the rules zone comes first, and its match +// stops named from looking at the lists. +func PausedZoneFile(title string) string { + return header(title+" (blocking is paused)", 1) + "*\tCNAME\trpz-passthru.\n" +} + // RulesZoneFile renders the rules zone of a view. func RulesZoneFile(title string, rules []Rule) string { e := RuleEntries(rules) diff --git a/internal/engine/blocking_test.go b/internal/engine/blocking_test.go index 75907d7..3741acb 100644 --- a/internal/engine/blocking_test.go +++ b/internal/engine/blocking_test.go @@ -10,6 +10,7 @@ import ( "strings" "sync/atomic" "testing" + "time" "jarl/internal/blocking" "jarl/internal/model" @@ -229,3 +230,54 @@ func TestBlocklistRefresh(t *testing.T) { 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) + } +} diff --git a/internal/engine/blockops.go b/internal/engine/blockops.go index da64e98..6361d1e 100644 --- a/internal/engine/blockops.go +++ b/internal/engine/blockops.go @@ -1,12 +1,14 @@ package engine import ( + "context" "encoding/json" "fmt" "net/url" "regexp" "slices" "strings" + "time" "jarl/internal/auth" "jarl/internal/blocking" @@ -270,3 +272,37 @@ func (x *expander) blockRuleCreate(req Request) ([]model.Op, error) { return ruleLabel(r), nil }) } + +func applyBlockingPause(st *model.State, op model.Op, c *applyCtx) error { + if err := c.require(blockingAction()); err != nil { + return err + } + var in BlockingPauseInput + if err := decode(op.Data, &in); err != nil { + return err + } + if in.Until != nil && in.Until.Sub(c.now) > MaxBlockingPause+time.Minute { + return errf(KindBadRequest, "blocking can be paused for at most %s; disable lists to stop using them", MaxBlockingPause) + } + st.Settings.BlockingPausedUntil = in.Until + return nil +} + +// ResumeBlocking ends a pause whose time is up, and returns when the +// current pause ends (zero when blocking isn't paused). +func (e *Engine) ResumeBlocking(ctx context.Context) time.Time { + st, err := e.store.LoadState(ctx) + if err != nil || st.Settings.BlockingPausedUntil == nil { + return time.Time{} + } + until := *st.Settings.BlockingPausedUntil + if e.Now().Before(until) { + return until + } + if _, err := e.Submit(ctx, []Request{NewRequest(OpBlockingPause, 0, BlockingPauseInput{})}, auth.System, "system", "resume blocking after the pause", ApplyOptions{}); err != nil { + e.log.Warn("resuming blocking after the pause failed; it will be tried again", "err", err) + return e.Now().Add(time.Minute) + } + e.log.Info("blocking resumed after the pause") + return time.Time{} +} diff --git a/internal/engine/diffops.go b/internal/engine/diffops.go index d2972c1..219553d 100644 --- a/internal/engine/diffops.go +++ b/internal/engine/diffops.go @@ -17,6 +17,9 @@ func DiffOps(from, to *model.State) []model.Op { add := func(kind string, id int64, base int, data any, summary string) { ops = append(ops, newOp(kind, id, base, data, summary)) } + if !reflect.DeepEqual(from.Settings.BlockingPausedUntil, to.Settings.BlockingPausedUntil) { + add(OpBlockingPause, 0, 0, BlockingPauseInput{Until: to.Settings.BlockingPausedUntil}, map[bool]string{true: "pause blocking", false: "resume blocking"}[to.Settings.BlockingPausedUntil != nil]) + } if !reflect.DeepEqual(stripVersion(from.Settings), stripVersion(to.Settings)) { s := to.Settings add(OpSettingsUpdate, 0, 0, SettingsInput{DefaultTTL: &s.DefaultTTL, Nameservers: model.Ptr(append([]string{}, s.Nameservers...)), RName: &s.RName, @@ -184,6 +187,7 @@ func DiffOps(from, to *model.State) []model.Op { func stripVersion(s model.Settings) model.Settings { s.Version = 0 + s.BlockingPausedUntil = nil // its own operation return s } diff --git a/internal/engine/engine.go b/internal/engine/engine.go index df1c273..0cb9214 100644 --- a/internal/engine/engine.go +++ b/internal/engine/engine.go @@ -829,7 +829,7 @@ func (e *Engine) applyOps(ctx context.Context, cs *model.Changeset, reqs []Reque res.Applied = true e.removeDerived(pl) for _, op := range cs.Ops { - if strings.HasPrefix(op.Kind, "blocklist.") { + if strings.HasPrefix(op.Kind, "blocklist.") || op.Kind == OpBlockingPause { e.KickBlocklists() break } diff --git a/internal/engine/expand.go b/internal/engine/expand.go index a6bcf88..63fe00a 100644 --- a/internal/engine/expand.go +++ b/internal/engine/expand.go @@ -234,6 +234,16 @@ func (x *expander) expand(req Request) ([]model.Op, error) { verb = "delete" } return []model.Op{x.versioned(req, b.Version, verb+" blocklist "+b.Name)}, nil + case OpBlockingPause: + var in BlockingPauseInput + if err := decode(req.Data, &in); err != nil { + return nil, err + } + summary := "resume blocking" + if in.Until != nil { + summary = "pause blocking until " + in.Until.Local().Format("2006-01-02 15:04") + } + return []model.Op{newOpRaw(OpBlockingPause, 0, 0, req.Data, summary)}, nil case OpBlockRuleCreate: return x.blockRuleCreate(req) case OpBlockRuleUpdate, OpBlockRuleDelete: diff --git a/internal/engine/inputs.go b/internal/engine/inputs.go index c6379ec..72916b7 100644 --- a/internal/engine/inputs.go +++ b/internal/engine/inputs.go @@ -2,6 +2,7 @@ package engine import ( "encoding/json" + "time" "jarl/internal/model" ) @@ -36,6 +37,7 @@ const ( OpBlockRuleCreate = "blockrule.create" OpBlockRuleUpdate = "blockrule.update" OpBlockRuleDelete = "blockrule.delete" + OpBlockingPause = "blocking.pause" // Requests only: disabling and enabling records, by ID or by match // (zone, name, type and optionally data), become record.update @@ -48,7 +50,7 @@ const ( var OpKinds = []string{OpZoneCreate, OpZoneUpdate, OpZoneDelete, OpRecordCreate, OpRecordUpdate, OpRecordDelete, OpRRSetReplace, OpViewCreate, OpViewUpdate, OpViewDelete, OpViewReorder, OpACLCreate, OpACLUpdate, OpACLDelete, OpTSIGCreate, OpTSIGUpdate, OpTSIGDelete, OpOptionCreate, OpOptionUpdate, OpOptionDelete, OpSettingsUpdate, - OpBlocklistCreate, OpBlocklistUpdate, OpBlocklistDelete, OpBlockRuleCreate, OpBlockRuleUpdate, OpBlockRuleDelete} + OpBlocklistCreate, OpBlocklistUpdate, OpBlocklistDelete, OpBlockRuleCreate, OpBlockRuleUpdate, OpBlockRuleDelete, OpBlockingPause} // Request is an operation as submitted by a client. Requests are expanded // into one or more stored operations (for example an A record with "ptr" @@ -244,3 +246,12 @@ type BlockRuleInput struct { ViewIDs *[]int64 `json:"view_ids,omitempty"` Comment *string `json:"comment,omitempty"` } + +// BlockingPauseInput pauses blocking until a time, or resumes it (null). +type BlockingPauseInput struct { + Until *time.Time `json:"until"` +} + +// MaxBlockingPause is the longest pause: blocking shouldn't stay off by +// mistake (disable lists to stop using them). +const MaxBlockingPause = 24 * time.Hour diff --git a/internal/engine/ops.go b/internal/engine/ops.go index 2c2076a..0bc4a93 100644 --- a/internal/engine/ops.go +++ b/internal/engine/ops.go @@ -106,6 +106,8 @@ func applyOp(st *model.State, op model.Op, c *applyCtx) error { err = applyBlockRulePut(st, op, c) case OpBlockRuleDelete: err = applyBlockRuleDelete(st, op, c) + case OpBlockingPause: + err = applyBlockingPause(st, op, c) default: err = errf(KindBadRequest, "unknown operation %q", op.Kind) } diff --git a/internal/model/model.go b/internal/model/model.go index c1c16c0..64a01ed 100644 --- a/internal/model/model.go +++ b/internal/model/model.go @@ -208,7 +208,10 @@ type Settings struct { Expire uint32 `json:"expire"` NegativeTTL uint32 `json:"negative_ttl"` SerialPolicy string `json:"serial_policy"` - Version int `json:"version"` + // BlockingPausedUntil, while set, lets every name through blocking + // (docs/blocking.md); jarld clears it when the time is up. + BlockingPausedUntil *time.Time `json:"blocking_paused_until,omitempty"` + Version int `json:"version"` } // DefaultSettings returns the settings of a fresh installation. diff --git a/internal/render/blocking.go b/internal/render/blocking.go index 3d464f9..70564fe 100644 --- a/internal/render/blocking.go +++ b/internal/render/blocking.go @@ -57,17 +57,25 @@ func PolicyZones(s *model.State, p Paths) []PolicyZone { if v := s.Views[vid]; v != nil { view = v.Name } + var zones []PolicyZone + hasRules := false for _, r := range rules { if slices.Contains(s.BlockingViews(r.ViewIDs), vid) { - out = append(out, PolicyZone{Name: blocking.RulesZone, ViewID: vid, View: view, File: p.rulesFile(view)}) + hasRules = true break } } for _, b := range lists { if !b.Disabled && slices.Contains(s.BlockingViews(b.ViewIDs), vid) { - out = append(out, PolicyZone{Name: blocking.ListZone(b.Name), ViewID: vid, View: view, File: p.BlocklistFile(b.ID), ListID: b.ID}) + zones = append(zones, PolicyZone{Name: blocking.ListZone(b.Name), ViewID: vid, View: view, File: p.BlocklistFile(b.ID), ListID: b.ID}) } } + // While paused, every view with blocking gets a rules zone: it + // lets everything through. + if hasRules || s.Settings.BlockingPausedUntil != nil && len(zones) > 0 { + out = append(out, PolicyZone{Name: blocking.RulesZone, ViewID: vid, View: view, File: p.rulesFile(view)}) + } + out = append(out, zones...) } return out } @@ -139,7 +147,11 @@ func writePolicyZones(w *confWriter, s *model.State, zones []PolicyZone, out *Ou if z.View != "" { title += " for view " + z.View } - out.Files = append(out.Files, &File{Path: z.File, Content: []byte(blocking.RulesZoneFile(title, ViewRules(s, z.ViewID))), Mode: 0o640, Kind: KindPolicy}) + content := blocking.RulesZoneFile(title, ViewRules(s, z.ViewID)) + if s.Settings.BlockingPausedUntil != nil { + content = blocking.PausedZoneFile(title) + } + out.Files = append(out.Files, &File{Path: z.File, Content: []byte(content), Mode: 0o640, Kind: KindPolicy}) } } diff --git a/internal/server/blocking_test.go b/internal/server/blocking_test.go index 2535e02..1e0276b 100644 --- a/internal/server/blocking_test.go +++ b/internal/server/blocking_test.go @@ -81,6 +81,22 @@ func TestBlockingAPI(t *testing.T) { t.Errorf("reader %s %s = %d", c.method, c.path, status) } } + // Pausing. + if _, status := e.req("POST", "/blocking/pause", e.token, map[string]any{"minutes": 0}); status != 400 { + t.Errorf("a 0-minute pause = %d", status) + } + e.do("POST", "/blocking/pause", e.token, map[string]any{"minutes": 30}, 201) + if st := e.do("GET", "/blocking/status", e.token, nil, 200); st["paused_until"] == nil { + t.Errorf("status while paused = %v", st) + } + if st := e.do("GET", "/status", e.token, nil, 200); st["blocking_paused_until"] == nil { + t.Errorf("dashboard while paused = %v", st["blocking_paused_until"]) + } + e.do("POST", "/blocking/resume", e.token, nil, 201) + if st := e.do("GET", "/blocking/status", e.token, nil, 200); st["paused_until"] != nil { + t.Errorf("status after resuming = %v", st) + } + // 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" { diff --git a/internal/server/h_blocking.go b/internal/server/h_blocking.go index 20215d9..cdf2661 100644 --- a/internal/server/h_blocking.go +++ b/internal/server/h_blocking.go @@ -366,3 +366,38 @@ func (s *Server) blocklistErrors(ctx context.Context) []api.BlocklistError { } return out } + +// blockingStatus: GET /blocking/status says whether blocking is paused. +func (s *Server) blockingStatus(w http.ResponseWriter, r *http.Request, p *auth.Principal) error { + if err := requireBlocking(p); err != nil { + return err + } + st, err := s.store.LoadState(r.Context()) + if err != nil { + return err + } + writeJSON(w, http.StatusOK, map[string]any{"paused_until": st.Settings.BlockingPausedUntil}) + return nil +} + +// pauseBlocking: POST /blocking/pause {"minutes": 10} lets every name +// through until then; POST /blocking/resume ends it. +func (s *Server) pauseBlocking(resume bool) func(http.ResponseWriter, *http.Request, *auth.Principal) error { + return func(w http.ResponseWriter, r *http.Request, p *auth.Principal) error { + var in engine.BlockingPauseInput + if !resume { + var body struct { + Minutes int `json:"minutes"` + } + if err := decodeJSON(r, &body); err != nil { + return err + } + if body.Minutes < 1 || time.Duration(body.Minutes)*time.Minute > engine.MaxBlockingPause { + return badRequest("pause for 1 to %d minutes", int(engine.MaxBlockingPause/time.Minute)) + } + until := time.Now().Add(time.Duration(body.Minutes) * time.Minute).Truncate(time.Second) + in.Until = &until + } + return s.mutate(w, r, p, "", []engine.Request{engine.NewRequest(engine.OpBlockingPause, 0, in)}, nil) + } +} diff --git a/internal/server/h_changes.go b/internal/server/h_changes.go index bf15bc6..342fecc 100644 --- a/internal/server/h_changes.go +++ b/internal/server/h_changes.go @@ -281,6 +281,9 @@ func (s *Server) status(w http.ResponseWriter, r *http.Request, p *auth.Principa } if p.Can(auth.Action{Resource: auth.ResBlocking}) { resp.BlocklistErrors = s.blocklistErrors(r.Context()) + if st, err := s.store.LoadState(r.Context()); err == nil { + resp.BlockingPausedUntil = st.Settings.BlockingPausedUntil + } } writeJSON(w, http.StatusOK, resp) return nil diff --git a/internal/server/h_stats.go b/internal/server/h_stats.go index 6a16b0b..0c8b90c 100644 --- a/internal/server/h_stats.go +++ b/internal/server/h_stats.go @@ -82,7 +82,7 @@ func (s *Server) statsTop(w http.ResponseWriter, r *http.Request, p *auth.Princi if err != nil { return badRequest("%v", err) } - if kind == stats.KindClient || kind == stats.KindRefused { + if kind == stats.KindClient || kind == stats.KindRefused || kind == stats.KindBlockedClient { if err := s.nameClients(r, p, es); err != nil { return err } diff --git a/internal/server/routes.go b/internal/server/routes.go index 418ffbf..321c3ad 100644 --- a/internal/server/routes.go +++ b/internal/server/routes.go @@ -69,6 +69,9 @@ func (s *Server) routes() { m.HandleFunc("PATCH "+p+"/blocking/rules/{id}", s.authed(s.updateBlockRule)) m.HandleFunc("DELETE "+p+"/blocking/rules/{id}", s.authed(s.deleteBlockRule)) m.HandleFunc("GET "+p+"/blocking/check", s.authed(s.checkBlocking)) + m.HandleFunc("GET "+p+"/blocking/status", s.authed(s.blockingStatus)) + m.HandleFunc("POST "+p+"/blocking/pause", s.authed(s.pauseBlocking(false))) + m.HandleFunc("POST "+p+"/blocking/resume", s.authed(s.pauseBlocking(true))) m.HandleFunc("GET "+p+"/acls", s.authed(s.listACLs)) m.HandleFunc("POST "+p+"/acls", s.authed(s.createACL)) m.HandleFunc("GET "+p+"/acls/{id}", s.authed(s.getACL)) diff --git a/internal/stats/blocking_test.go b/internal/stats/blocking_test.go index a6ec81a..f8aba3d 100644 --- a/internal/stats/blocking_test.go +++ b/internal/stats/blocking_test.go @@ -93,6 +93,9 @@ func TestCollectorBlocking(t *testing.T) { if b, _ := store.TopsAt(ctx, ResHour, m, KindBlocked); len(b) != 2 || b[0] != (Entry{Key: "ads.example", Count: 2}) { t.Errorf("blocked names = %v", b) } + if bc, _ := store.TopsAt(ctx, ResHour, m, KindBlockedClient); len(bc) != 2 || bc[0] != (Entry{Key: "10.0.0.5", Count: 2}) { + t.Errorf("blocked clients = %v", bc) + } if nx, _ := store.TopsAt(ctx, ResHour, m, KindNXDomain); len(nx) != 1 || nx[0].Key != "typo.example" { t.Errorf("NXDOMAIN names should leave out blocked ones: %v", nx) } diff --git a/internal/stats/collector.go b/internal/stats/collector.go index e00744a..48e4e45 100644 --- a/internal/stats/collector.go +++ b/internal/stats/collector.go @@ -24,10 +24,12 @@ const ( KindServFail = "servfail" // names answered with SERVFAIL KindRefused = "refused" // clients that were refused KindBlocked = "blocked" // names blocked (docs/blocking.md) + // KindBlockedClient: clients with the most blocked queries. + KindBlockedClient = "blocked_client" ) // Kinds lists the top-list kinds. -var Kinds = []string{KindName, KindClient, KindNXDomain, KindServFail, KindRefused, KindBlocked} +var Kinds = []string{KindName, KindClient, KindNXDomain, KindServFail, KindRefused, KindBlocked, KindBlockedClient} // OtherZone is the zone key for names outside every local zone (answered // by recursion, or refused). @@ -380,6 +382,7 @@ func (c *Collector) handleRewrite(rw Rewrite) { } hours[hour] = c.hourMaps(hours, hour) hours[hour][KindBlocked][rw.Name]++ + hours[hour][KindBlockedClient][clientKey(rw.Client, c.opts.Anonymize)]++ } // policySource names what a rewrite came from: a blocklist's name, "rules" diff --git a/internal/web/static/assets/pages/blocking.js b/internal/web/static/assets/pages/blocking.js index 4350ff3..4693c2c 100644 --- a/internal/web/static/assets/pages/blocking.js +++ b/internal/web/static/assets/pages/blocking.js @@ -22,7 +22,7 @@ let refreshTimer = null; export async function render(el, params, query) { clearTimeout(refreshTimer); - const [lists, rules, views] = await Promise.all([get("/blocking/lists", draftQuery()), get("/blocking/rules", draftQuery()), get("/views")]); + const [lists, rules, views, status] = await Promise.all([get("/blocking/lists", draftQuery()), get("/blocking/rules", draftQuery()), get("/views"), get("/blocking/status")]); const writable = can("blocking", true); const recursive = lists.concat(rules).find((x) => x.auto_views); const autoViews = recursive ? recursive.views : null; @@ -39,10 +39,16 @@ export async function render(el, params, query) { } catch (e) { mount(checkOut, errorBox(e)); } }; + const paused = status.paused_until; mount(el, h("div", { class: "page-head" }, h("div", { class: "grow" }, h("h1", null, "Blocking"), - h("div", { class: "muted" }, "Answer \"doesn't exist\" for ads, trackers and malware, for the devices that use this server to resolve names. Your rules come first, then the blocklists."))), + h("div", { class: "muted" }, "Answer \"doesn't exist\" for ads, trackers and malware, for the devices that use this server to resolve names. Your rules come first, then the blocklists.")), + writable && !paused && (lists.length || rules.length) ? pauseControls() : null), + paused ? h("div", { class: "msg warn" }, + h("strong", null, "Blocking is paused until " + new Date(paused).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }) + ": "), + "every name is answered normally. It resumes by itself. ", + writable ? h("button", { class: "small", onclick: () => pause(0) }, "Resume now") : null) : null, h("div", { class: "panel" }, h("form", { class: "actions", onsubmit: runCheck }, h("label", { class: "muted" }, "Check a name"), checkName, h("button", { type: "submit" }, "Check")), @@ -51,10 +57,27 @@ export async function render(el, params, query) { listsPanel(lists, views, autoViews, writable)); if (checkName.value) runCheck(); - // Lists are downloaded in the background: show when they're done. - if (lists.some((l) => l.state === "waiting")) { - refreshTimer = setTimeout(() => { if (location.hash.startsWith("#/blocking") && el.isConnected) rerender(); }, 4000); - } + // Lists are downloaded in the background, and a pause ends by itself: + // show it when it happens. + let wait = 0; + if (lists.some((l) => l.state === "waiting")) wait = 4000; + if (paused) wait = Math.max(1000, Math.min(wait || Infinity, new Date(paused) - Date.now() + 3000)); + if (wait) refreshTimer = setTimeout(() => { if (location.hash.startsWith("#/blocking") && el.isConnected) rerender(); }, wait); +} + +function pauseControls() { + const length = select([["5", "5 minutes"], ["15", "15 minutes"], ["60", "1 hour"], ["240", "4 hours"]], "15"); + return h("div", { class: "actions" }, length, h("button", { title: "Answer every name normally for a while, to see whether blocking is what breaks something", onclick: () => pause(Number(length.value)) }, "Pause blocking")); +} + +// pause pauses blocking for minutes, or resumes it (0). It applies at +// once, also with a draft open: it's for trying something now. +async function pause(minutes) { + try { + await api("POST", minutes ? "/blocking/pause" : "/blocking/resume", minutes ? { body: { minutes } } : {}); + toast(minutes ? "Blocking paused" : "Blocking resumed"); + rerender(); + } catch (e) { toast(e.message, "err", 8000); } } function checkResult(name, res, writable) { @@ -82,17 +105,29 @@ async function addRule(name, action, exact, views, comment) { } catch (e) { toast(e.message, "err", 8000); return false; } } -// viewPicker returns checkboxes for the views (none ticked: automatic). -function viewPicker(views, autoViews, selected) { +// viewPicker is a compact choice of views: it shows the choice, and opens +// a list of checkboxes (none ticked: every view that offers recursion). +function viewPicker(views, autoViews) { if (!views.length) return { el: null, value: () => [] }; - const boxes = views.map((v) => checkbox(v.name, (selected || []).includes(v.name))); - const hint = autoViews && autoViews.length ? "none ticked: " + autoViews.join(", ") + " (every view that offers recursion)" : "none ticked: every view that offers recursion"; - return { - el: h("div", { class: "f" }, h("label", null, "Views"), h("div", { class: "actions" }, boxes, h("span", { class: "muted small" }, hint))), - value: () => views.filter((v, i) => boxes[i].input.checked).map((v) => v.name), + const boxes = views.map((v) => checkbox(v.name, false)); + const value = () => views.filter((v, i) => boxes[i].input.checked).map((v) => v.name); + const summary = h("summary"); + const show = () => { + const sel = value(); + summary.textContent = sel.length ? sel.join(", ") : "automatic" + (autoViews && autoViews.length ? " (" + autoViews.join(", ") + ")" : ""); }; + boxes.forEach((b) => b.input.addEventListener("change", show)); + show(); + const picker = h("details", { class: "view-picker" }, summary, + h("div", { class: "view-picker-list" }, h("div", { class: "muted small" }, "None ticked: every view that offers recursion"), boxes)); + return { el: h("div", { class: "f" }, h("label", null, "Views"), picker), value }; } +// An open view picker closes when you click elsewhere. +document.addEventListener("click", (ev) => { + for (const d of document.querySelectorAll("details.view-picker[open]")) if (!d.contains(ev.target)) d.open = false; +}); + function viewsCell(x) { if (!x.views.length) return h("span", { class: "muted" }, "none"); return x.auto_views ? [x.views.join(", "), h("span", { class: "muted small" }, " (auto)")] : x.views.join(", "); diff --git a/internal/web/static/assets/pages/dashboard.js b/internal/web/static/assets/pages/dashboard.js index 15b2975..d0efb19 100644 --- a/internal/web/static/assets/pages/dashboard.js +++ b/internal/web/static/assets/pages/dashboard.js @@ -1,4 +1,4 @@ -import { h, mount, time, relTime, problemsList, badge, plural } from "../ui.js"; +import { h, mount, time, fmtTime, relTime, problemsList, badge, plural } from "../ui.js"; import { get, state } from "../api.js"; export async function render(el) { @@ -40,6 +40,10 @@ export async function render(el) { h("strong", null, "The last backup failed "), time(b.last_error_at), ": " + b.last_error, h("div", { class: "small" }, "See 'journalctl -u jarl-backup' on the server."))); } + if (st.blocking_paused_until) { + parts.push(h("div", { class: "msg info" }, + h("strong", null, "Blocking is paused until " + fmtTime(st.blocking_paused_until) + "."), " It resumes by itself. ", h("a", { href: "#/blocking" }, "Blocking"))); + } for (const e of st.blocklist_errors || []) { parts.push(h("div", { class: "msg warn" }, h("strong", null, "Blocklist " + e.name + " couldn't be updated: "), e.error + ". ", diff --git a/internal/web/static/assets/pages/stats.js b/internal/web/static/assets/pages/stats.js index 3adcca7..892d36b 100644 --- a/internal/web/static/assets/pages/stats.js +++ b/internal/web/static/assets/pages/stats.js @@ -60,10 +60,14 @@ export async function render(el, params, query) { s.blocked_ratio != null ? tile("Blocked", pct(s.blocked_ratio), compact(s.blocked) + " queries, by your rules and blocklists") : null)); // Volume over time: one series, so no legend box; the title names it. + // With blocking, a second line shows how many of them were blocked. const q = timeline.series.find((x) => x.key === "queries") || { values: timeline.times.map(() => 0) }; const qSeries = [{ label: "Queries", values: q.values, slot: 1 }]; - parts.push(chartCard("Queries", "per " + stepName(timeline.step) + ", " + rangeLabel, - lineChart({ series: qSeries, times: timeline.times, step, area: true }), timeTable(qSeries, timeline.times, step), { wide: true })); + const bl = timeline.series.find((x) => x.key === "blocked"); + if (bl && bl.values.some((v) => v > 0)) qSeries.push({ label: "Blocked", values: bl.values, slot: 2 }); + parts.push(chartCard(qSeries.length > 1 ? "Queries and blocked queries" : "Queries", "per " + stepName(timeline.step) + ", " + rangeLabel, + lineChart({ series: qSeries, times: timeline.times, step, area: true }), timeTable(qSeries, timeline.times, step), + { wide: true, legend: qSeries.length > 1 ? legend(qSeries) : null })); // Failed answers: three fixed series, legend plus end labels. const fSeries = failures.map((f) => ({ ...f, values: (errors.series.find((x) => x.key === f.key) || { values: errors.times.map(() => 0) }).values })); @@ -86,6 +90,7 @@ export async function render(el, params, query) { listCard("Names that don't exist", "NXDOMAIN answers: typos, removed hosts, misconfigured clients, malware", barList(t.nxdomain, { empty: "None " + rangeLabel + "." })), listCard("Refused clients", "clients asking for something they may not have (often internet scanners)", barList(t.refused, { empty: "None " + rangeLabel + "." })), t.servfail.length ? listCard("Names that failed to resolve", "SERVFAIL answers", barList(t.servfail)) : null, + t.blocked_client.length ? listCard("Clients with the most blocked queries", "often a smart TV, phone or app phoning home", barList(t.blocked_client)) : null, t.blocked.length ? listCard("Most blocked names", "answered \"doesn't exist\" by blocking; click a name to see why, or to allow it", barList(t.blocked, { href: (k) => "#/blocking?check=" + encodeURIComponent(k) })) : null)); } else { parts.push(h("div", { class: "msg info" }, "Top names and clients need the response log: set stats.query_log: true in /etc/jarl/jarl.yaml and restart jarl.")); @@ -110,14 +115,14 @@ export async function render(el, params, query) { async function loadAll(since, heatSince, queryLog) { const p = { summary: get("/stats/summary", { since, top: 0 }), - timeline: get("/stats/timeline", { since, key: "queries" }), + timeline: get("/stats/timeline", { since, key: "queries,blocked" }), errors: get("/stats/timeline", { since, key: failures.map((f) => f.key).join(",") }), heat: get("/stats/heatmap", { since: heatSince }), }; const groups = ["qtype", "rcode", "view", "zone", "answer", "rtt", "blocklist"]; groups.forEach((g) => { p["b_" + g] = get("/stats/breakdown", { group: g, since }); }); if (queryLog) { - for (const k of ["name", "client", "nxdomain", "refused", "servfail", "blocked"]) p["t_" + k] = get("/stats/top", { kind: k, since, limit: 15 }); + for (const k of ["name", "client", "nxdomain", "refused", "servfail", "blocked", "blocked_client"]) p["t_" + k] = get("/stats/top", { kind: k, since, limit: 15 }); } const keys = Object.keys(p); const vals = await Promise.all(Object.values(p)); diff --git a/internal/web/static/assets/style.css b/internal/web/static/assets/style.css index 6a70624..d553d0e 100644 --- a/internal/web/static/assets/style.css +++ b/internal/web/static/assets/style.css @@ -443,5 +443,8 @@ footer.site a:hover { color: var(--accent); text-decoration-color: currentColor; .query-log li.pending code { opacity: 0.85; } .err-text { color: var(--err); } ul.plain { list-style: none; margin: 8px 0; padding: 0; } +details.view-picker { position: relative; } +details.view-picker summary { cursor: pointer; padding: 5px 8px; border: 1px solid var(--border); border-radius: var(--radius); background: var(--panel); min-width: 12em; max-width: 24em; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +details.view-picker[open] .view-picker-list { position: absolute; z-index: 20; top: calc(100% + 4px); left: 0; min-width: 14em; max-height: 260px; overflow-y: auto; display: flex; flex-direction: column; gap: 4px; padding: 8px 10px; background: var(--panel); border: 1px solid var(--border); border-radius: var(--radius); box-shadow: var(--shadow); } ul.plain li { padding: 3px 0; } .query-details { margin: 8px 0; } diff --git a/scripts/e2e.sh b/scripts/e2e.sh index a2ab4c0..5ecd7ea 100755 --- a/scripts/e2e.sh +++ b/scripts/e2e.sh @@ -444,6 +444,14 @@ expect_rpz "an allow rule beats the list" deep.ok.tracker.adbraun.test none expect_rpz "the rest of the list still blocks" x.tracker.adbraun.test test.blocklist.jarl.invalid. run "block check" 0 $J block check deep.ok.tracker.adbraun.test contains "allowed by your rule" +run "block pause" 0 $J block pause 5m +expect_rpz "paused: a listed name is answered" x.tracker.adbraun.test none +expect_rpz "paused: so is a blocked rule's name" blocked.adbraun.test none +run "block list says it's paused" 0 $J block list +contains "Blocking is paused until" +run "block resume" 0 $J block resume +expect_rpz "resumed: the list blocks again" x.tracker.adbraun.test test.blocklist.jarl.invalid. +expect_rpz "resumed: the rule blocks again" blocked.adbraun.test rules.jarl.invalid. # The test server dates the file to the second, and the download above # remembers that date: change it a second later, as a real list would. sleep 1.1