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 <noreply@anthropic.com>
This commit is contained in:
1 parent
9fc23310bd
commit
27d1f9e538
31 files changed
+416
-44
No files matched your search
+10
-3
@@ -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
|
- Web UI: a Blocking page, with well-known lists to pick and named's
|
||||||
memory per list. CLI: `jarl blocklist` and `jarl block`. API:
|
memory per list. CLI: `jarl blocklist` and `jarl block`. API:
|
||||||
`/blocking/lists`, `/blocking/rules`, `/blocking/check`.
|
`/blocking/lists`, `/blocking/rules`, `/blocking/check`.
|
||||||
- Statistics: blocked queries and their share, blocked per list, most
|
- Pausing: `jarl block pause [DURATION]` or the Blocking page lets every
|
||||||
blocked names (`jarl stats top blocked`). Blocked answers no longer
|
name through for a while (at most a day); blocking resumes by itself.
|
||||||
count as names that don't exist.
|
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.
|
- A `blocking` permission; database schema 6.
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|||||||
+75
-1
@@ -1,6 +1,7 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/url"
|
"net/url"
|
||||||
@@ -309,7 +310,11 @@ names below it (docs/blocking.md).`}
|
|||||||
if err := c.Get(ctx(), "/blocking/rules", nil, &rules); err != nil {
|
if err := c.Get(ctx(), "/blocking/rules", nil, &rules); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
note := pausedNote(c)
|
||||||
return output(rules, func(w io.Writer) {
|
return output(rules, func(w io.Writer) {
|
||||||
|
if note != "" {
|
||||||
|
fmt.Fprintln(w, note)
|
||||||
|
}
|
||||||
fmt.Fprintln(w, "ID\tACTION\tDOMAIN\tCOVERS\tVIEWS\tCOMMENT")
|
fmt.Fprintln(w, "ID\tACTION\tDOMAIN\tCOVERS\tVIEWS\tCOMMENT")
|
||||||
for _, r := range rules {
|
for _, r := range rules {
|
||||||
covers := "and below"
|
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 {
|
if err := c.Get(ctx(), "/blocking/check", q("name", args[0]), &matches); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
note := pausedNote(c)
|
||||||
return output(matches, func(w io.Writer) {
|
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 {
|
if len(matches) == 0 {
|
||||||
fmt.Fprintln(w, "Blocking isn't set up in any view.")
|
fmt.Fprintln(w, "Blocking isn't set up in any view.")
|
||||||
return
|
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
|
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"
|
||||||
|
}
|
||||||
+7
-3
@@ -128,13 +128,14 @@ export the data.`,
|
|||||||
|
|
||||||
var limit int
|
var limit int
|
||||||
top := &cobra.Command{
|
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",
|
Short: "The most queried names, busiest clients, names with errors, refused clients, blocked names",
|
||||||
Example: ` jarl stats top names --since 7d
|
Example: ` jarl stats top names --since 7d
|
||||||
jarl stats top clients --limit 50
|
jarl stats top clients --limit 50
|
||||||
jarl stats top nxdomain # misconfigured hosts and typos
|
jarl stats top nxdomain # misconfigured hosts and typos
|
||||||
jarl stats top refused # who is asking for what they may not have
|
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),
|
Args: cobra.MaximumNArgs(1),
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
kind := stats.KindName
|
kind := stats.KindName
|
||||||
@@ -143,6 +144,9 @@ export the data.`,
|
|||||||
if kind == "nxdomain" || kind == "servfail" {
|
if kind == "nxdomain" || kind == "servfail" {
|
||||||
kind = strings.ToLower(args[0])
|
kind = strings.ToLower(args[0])
|
||||||
}
|
}
|
||||||
|
if kind == "blocked-client" {
|
||||||
|
kind = stats.KindBlockedClient
|
||||||
|
}
|
||||||
}
|
}
|
||||||
c, err := newClient()
|
c, err := newClient()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -157,7 +161,7 @@ export the data.`,
|
|||||||
for _, e := range es {
|
for _, e := range es {
|
||||||
total += e.Count
|
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 == "" {
|
if head == "" {
|
||||||
head = "NAME"
|
head = "NAME"
|
||||||
}
|
}
|
||||||
|
|||||||
+8
-3
@@ -339,22 +339,27 @@ func serveCmd() *cobra.Command {
|
|||||||
|
|
||||||
// blocklistLoop keeps blocklists up to date (docs/blocking.md): every few
|
// 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
|
// 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) {
|
func blocklistLoop(ctx context.Context, a *app) {
|
||||||
select {
|
select {
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
return
|
return
|
||||||
case <-time.After(10 * time.Second): // let jarld finish starting
|
case <-time.After(10 * time.Second): // let jarld finish starting
|
||||||
}
|
}
|
||||||
t := time.NewTicker(5 * time.Minute)
|
|
||||||
defer t.Stop()
|
|
||||||
for {
|
for {
|
||||||
a.eng.RefreshDue(ctx)
|
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 {
|
select {
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
|
t.Stop()
|
||||||
return
|
return
|
||||||
case <-t.C:
|
case <-t.C:
|
||||||
case <-a.eng.BlocklistKicks():
|
case <-a.eng.BlocklistKicks():
|
||||||
|
t.Stop()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+7
-3
@@ -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) |
|
| 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}` | |
|
| PATCH / DELETE | `/blocking/rules/{id}` | |
|
||||||
| DELETE | `/blocking/rules` | `?name=DOMAIN[&view=V][&missing_ok=1]`: the rules for a domain |
|
| 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
|
Lists and rules are configuration like the rest: they can be staged, and
|
||||||
listing with `?changeset=` marks them `pending`.
|
listing with `?changeset=` marks them `pending`.
|
||||||
@@ -484,6 +487,7 @@ An **Operation** is `{"op": "...", "id": 0, "base_version": 0, "data": {...}}`:
|
|||||||
| `settings.update` | | settings fields |
|
| `settings.update` | | settings fields |
|
||||||
| `blocklist.create` / `blocklist.update` / `blocklist.delete` | blocklist (update/delete) | list fields; views as `views` (names) or `view_ids` |
|
| `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 |
|
| `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
|
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
|
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 |
|
| 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/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) |
|
| 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` |
|
| 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/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/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/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/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}]` |
|
| GET | `/stats/breakdown` | `group` (qtype, rcode, view, zone, answer, transport, rtt, dnssec, cache, ratelimit, blocklist), `since` | `[{key, count}]` |
|
||||||
|
|
||||||
|
|||||||
+23
-4
@@ -17,6 +17,7 @@ does the blocking itself; jarld only prepares the lists.
|
|||||||
- [Your rules](#your-rules)
|
- [Your rules](#your-rules)
|
||||||
- [Blocklists](#blocklists)
|
- [Blocklists](#blocklists)
|
||||||
- [Checking a name](#checking-a-name)
|
- [Checking a name](#checking-a-name)
|
||||||
|
- [Pausing](#pausing)
|
||||||
- [Statistics](#statistics)
|
- [Statistics](#statistics)
|
||||||
- [Memory and performance](#memory-and-performance)
|
- [Memory and performance](#memory-and-performance)
|
||||||
- [How it works](#how-it-works)
|
- [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'
|
In the web UI a blocked name gets an **Allow** button, and the statistics'
|
||||||
most blocked names link to this check.
|
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
|
## Statistics
|
||||||
|
|
||||||
With [query statistics](stats.md) on (including `query_log`), Jarl counts
|
With [query statistics](stats.md) on (including `query_log`), Jarl counts
|
||||||
what blocking does: blocked queries over time and as a share of all
|
what blocking does: blocked queries over time (next to all queries) and
|
||||||
queries, which rules and lists blocked them, and the most blocked names
|
as a share of them, which rules and lists blocked them, the most blocked
|
||||||
(`jarl stats summary`, `jarl stats top blocked`, `jarl stats breakdown
|
names, and the clients with the most blocked queries (`jarl stats summary`,
|
||||||
blocklist`). Blocked answers are left out of the NXDOMAIN figures, which
|
`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.
|
are about names that really don't exist.
|
||||||
|
|
||||||
Without statistics named doesn't log what it blocks at all.
|
Without statistics named doesn't log what it blocks at all.
|
||||||
|
|||||||
+3
-1
@@ -419,7 +419,7 @@ working.
|
|||||||
```sh
|
```sh
|
||||||
jarl stats status
|
jarl stats status
|
||||||
jarl stats summary --since 7d
|
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 timeline --since 6h [--key rcode:NXDOMAIN] [--table]
|
||||||
jarl stats heatmap --since 28d
|
jarl stats heatmap --since 28d
|
||||||
jarl stats breakdown qtype | rcode | view | zone | answer | rtt | blocklist
|
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 list
|
||||||
jarl block delete DOMAIN [--view V] | --id ID
|
jarl block delete DOMAIN [--view V] | --id ID
|
||||||
jarl block check DOMAIN # blocked? by which rule or list?
|
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).
|
See [blocking.md](blocking.md).
|
||||||
|
|||||||
+1
-1
@@ -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
|
**Top lists** (per hour and per day, the `top_n` largest): `name` (most
|
||||||
queried names), `client`, `nxdomain` (names answered NXDOMAIN), `servfail`
|
queried names), `client`, `nxdomain` (names answered NXDOMAIN), `servfail`
|
||||||
(names answered SERVFAIL), `refused` (clients that were refused), `blocked`
|
(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
|
aren't counted as names that don't exist: not in the `nxdomain` list, and
|
||||||
not in the summary's NXDOMAIN ratio.
|
not in the summary's NXDOMAIN ratio.
|
||||||
|
|
||||||
|
|||||||
@@ -245,6 +245,8 @@ type StatusResponse struct {
|
|||||||
// BlocklistErrors are blocklists whose last update failed (for those
|
// BlocklistErrors are blocklists whose last update failed (for those
|
||||||
// who may see blocking).
|
// who may see blocking).
|
||||||
BlocklistErrors []BlocklistError `json:"blocklist_errors,omitempty"`
|
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
|
// BlocklistError is a blocklist whose last update failed. UpdatedAt is
|
||||||
|
|||||||
@@ -139,6 +139,13 @@ func RuleEntries(rules []Rule) map[string]string {
|
|||||||
return e
|
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.
|
// RulesZoneFile renders the rules zone of a view.
|
||||||
func RulesZoneFile(title string, rules []Rule) string {
|
func RulesZoneFile(title string, rules []Rule) string {
|
||||||
e := RuleEntries(rules)
|
e := RuleEntries(rules)
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"jarl/internal/blocking"
|
"jarl/internal/blocking"
|
||||||
"jarl/internal/model"
|
"jarl/internal/model"
|
||||||
@@ -229,3 +230,54 @@ func TestBlocklistRefresh(t *testing.T) {
|
|||||||
t.Error("the deleted list's status is still there")
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,12 +1,14 @@
|
|||||||
package engine
|
package engine
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/url"
|
"net/url"
|
||||||
"regexp"
|
"regexp"
|
||||||
"slices"
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"jarl/internal/auth"
|
"jarl/internal/auth"
|
||||||
"jarl/internal/blocking"
|
"jarl/internal/blocking"
|
||||||
@@ -270,3 +272,37 @@ func (x *expander) blockRuleCreate(req Request) ([]model.Op, error) {
|
|||||||
return ruleLabel(r), nil
|
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{}
|
||||||
|
}
|
||||||
@@ -17,6 +17,9 @@ func DiffOps(from, to *model.State) []model.Op {
|
|||||||
add := func(kind string, id int64, base int, data any, summary string) {
|
add := func(kind string, id int64, base int, data any, summary string) {
|
||||||
ops = append(ops, newOp(kind, id, base, data, summary))
|
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)) {
|
if !reflect.DeepEqual(stripVersion(from.Settings), stripVersion(to.Settings)) {
|
||||||
s := to.Settings
|
s := to.Settings
|
||||||
add(OpSettingsUpdate, 0, 0, SettingsInput{DefaultTTL: &s.DefaultTTL, Nameservers: model.Ptr(append([]string{}, s.Nameservers...)), RName: &s.RName,
|
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 {
|
func stripVersion(s model.Settings) model.Settings {
|
||||||
s.Version = 0
|
s.Version = 0
|
||||||
|
s.BlockingPausedUntil = nil // its own operation
|
||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -829,7 +829,7 @@ func (e *Engine) applyOps(ctx context.Context, cs *model.Changeset, reqs []Reque
|
|||||||
res.Applied = true
|
res.Applied = true
|
||||||
e.removeDerived(pl)
|
e.removeDerived(pl)
|
||||||
for _, op := range cs.Ops {
|
for _, op := range cs.Ops {
|
||||||
if strings.HasPrefix(op.Kind, "blocklist.") {
|
if strings.HasPrefix(op.Kind, "blocklist.") || op.Kind == OpBlockingPause {
|
||||||
e.KickBlocklists()
|
e.KickBlocklists()
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -234,6 +234,16 @@ func (x *expander) expand(req Request) ([]model.Op, error) {
|
|||||||
verb = "delete"
|
verb = "delete"
|
||||||
}
|
}
|
||||||
return []model.Op{x.versioned(req, b.Version, verb+" blocklist "+b.Name)}, nil
|
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:
|
case OpBlockRuleCreate:
|
||||||
return x.blockRuleCreate(req)
|
return x.blockRuleCreate(req)
|
||||||
case OpBlockRuleUpdate, OpBlockRuleDelete:
|
case OpBlockRuleUpdate, OpBlockRuleDelete:
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package engine
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"time"
|
||||||
|
|
||||||
"jarl/internal/model"
|
"jarl/internal/model"
|
||||||
)
|
)
|
||||||
@@ -36,6 +37,7 @@ const (
|
|||||||
OpBlockRuleCreate = "blockrule.create"
|
OpBlockRuleCreate = "blockrule.create"
|
||||||
OpBlockRuleUpdate = "blockrule.update"
|
OpBlockRuleUpdate = "blockrule.update"
|
||||||
OpBlockRuleDelete = "blockrule.delete"
|
OpBlockRuleDelete = "blockrule.delete"
|
||||||
|
OpBlockingPause = "blocking.pause"
|
||||||
|
|
||||||
// Requests only: disabling and enabling records, by ID or by match
|
// Requests only: disabling and enabling records, by ID or by match
|
||||||
// (zone, name, type and optionally data), become record.update
|
// (zone, name, type and optionally data), become record.update
|
||||||
@@ -48,7 +50,7 @@ const (
|
|||||||
var OpKinds = []string{OpZoneCreate, OpZoneUpdate, OpZoneDelete, OpRecordCreate, OpRecordUpdate, OpRecordDelete, OpRRSetReplace,
|
var OpKinds = []string{OpZoneCreate, OpZoneUpdate, OpZoneDelete, OpRecordCreate, OpRecordUpdate, OpRecordDelete, OpRRSetReplace,
|
||||||
OpViewCreate, OpViewUpdate, OpViewDelete, OpViewReorder, OpACLCreate, OpACLUpdate, OpACLDelete,
|
OpViewCreate, OpViewUpdate, OpViewDelete, OpViewReorder, OpACLCreate, OpACLUpdate, OpACLDelete,
|
||||||
OpTSIGCreate, OpTSIGUpdate, OpTSIGDelete, OpOptionCreate, OpOptionUpdate, OpOptionDelete, OpSettingsUpdate,
|
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
|
// 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"
|
// 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"`
|
ViewIDs *[]int64 `json:"view_ids,omitempty"`
|
||||||
Comment *string `json:"comment,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
|
||||||
@@ -106,6 +106,8 @@ func applyOp(st *model.State, op model.Op, c *applyCtx) error {
|
|||||||
err = applyBlockRulePut(st, op, c)
|
err = applyBlockRulePut(st, op, c)
|
||||||
case OpBlockRuleDelete:
|
case OpBlockRuleDelete:
|
||||||
err = applyBlockRuleDelete(st, op, c)
|
err = applyBlockRuleDelete(st, op, c)
|
||||||
|
case OpBlockingPause:
|
||||||
|
err = applyBlockingPause(st, op, c)
|
||||||
default:
|
default:
|
||||||
err = errf(KindBadRequest, "unknown operation %q", op.Kind)
|
err = errf(KindBadRequest, "unknown operation %q", op.Kind)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -208,6 +208,9 @@ type Settings struct {
|
|||||||
Expire uint32 `json:"expire"`
|
Expire uint32 `json:"expire"`
|
||||||
NegativeTTL uint32 `json:"negative_ttl"`
|
NegativeTTL uint32 `json:"negative_ttl"`
|
||||||
SerialPolicy string `json:"serial_policy"`
|
SerialPolicy string `json:"serial_policy"`
|
||||||
|
// 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"`
|
Version int `json:"version"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -57,17 +57,25 @@ func PolicyZones(s *model.State, p Paths) []PolicyZone {
|
|||||||
if v := s.Views[vid]; v != nil {
|
if v := s.Views[vid]; v != nil {
|
||||||
view = v.Name
|
view = v.Name
|
||||||
}
|
}
|
||||||
|
var zones []PolicyZone
|
||||||
|
hasRules := false
|
||||||
for _, r := range rules {
|
for _, r := range rules {
|
||||||
if slices.Contains(s.BlockingViews(r.ViewIDs), vid) {
|
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
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for _, b := range lists {
|
for _, b := range lists {
|
||||||
if !b.Disabled && slices.Contains(s.BlockingViews(b.ViewIDs), vid) {
|
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
|
return out
|
||||||
}
|
}
|
||||||
@@ -139,7 +147,11 @@ func writePolicyZones(w *confWriter, s *model.State, zones []PolicyZone, out *Ou
|
|||||||
if z.View != "" {
|
if z.View != "" {
|
||||||
title += " for view " + 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})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -81,6 +81,22 @@ func TestBlockingAPI(t *testing.T) {
|
|||||||
t.Errorf("reader %s %s = %d", c.method, c.path, status)
|
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.
|
// 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)
|
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" {
|
if l := e.do("POST", "/blocking/lists/gone/refresh", e.token, nil, 200); l["state"] != "error" {
|
||||||
|
|||||||
@@ -366,3 +366,38 @@ func (s *Server) blocklistErrors(ctx context.Context) []api.BlocklistError {
|
|||||||
}
|
}
|
||||||
return out
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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}) {
|
if p.Can(auth.Action{Resource: auth.ResBlocking}) {
|
||||||
resp.BlocklistErrors = s.blocklistErrors(r.Context())
|
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)
|
writeJSON(w, http.StatusOK, resp)
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -82,7 +82,7 @@ func (s *Server) statsTop(w http.ResponseWriter, r *http.Request, p *auth.Princi
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return badRequest("%v", err)
|
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 {
|
if err := s.nameClients(r, p, es); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -69,6 +69,9 @@ func (s *Server) routes() {
|
|||||||
m.HandleFunc("PATCH "+p+"/blocking/rules/{id}", s.authed(s.updateBlockRule))
|
m.HandleFunc("PATCH "+p+"/blocking/rules/{id}", s.authed(s.updateBlockRule))
|
||||||
m.HandleFunc("DELETE "+p+"/blocking/rules/{id}", s.authed(s.deleteBlockRule))
|
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/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("GET "+p+"/acls", s.authed(s.listACLs))
|
||||||
m.HandleFunc("POST "+p+"/acls", s.authed(s.createACL))
|
m.HandleFunc("POST "+p+"/acls", s.authed(s.createACL))
|
||||||
m.HandleFunc("GET "+p+"/acls/{id}", s.authed(s.getACL))
|
m.HandleFunc("GET "+p+"/acls/{id}", s.authed(s.getACL))
|
||||||
|
|||||||
@@ -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}) {
|
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)
|
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" {
|
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)
|
t.Errorf("NXDOMAIN names should leave out blocked ones: %v", nx)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,10 +24,12 @@ const (
|
|||||||
KindServFail = "servfail" // names answered with SERVFAIL
|
KindServFail = "servfail" // names answered with SERVFAIL
|
||||||
KindRefused = "refused" // clients that were refused
|
KindRefused = "refused" // clients that were refused
|
||||||
KindBlocked = "blocked" // names blocked (docs/blocking.md)
|
KindBlocked = "blocked" // names blocked (docs/blocking.md)
|
||||||
|
// KindBlockedClient: clients with the most blocked queries.
|
||||||
|
KindBlockedClient = "blocked_client"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Kinds lists the top-list kinds.
|
// 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
|
// OtherZone is the zone key for names outside every local zone (answered
|
||||||
// by recursion, or refused).
|
// by recursion, or refused).
|
||||||
@@ -380,6 +382,7 @@ func (c *Collector) handleRewrite(rw Rewrite) {
|
|||||||
}
|
}
|
||||||
hours[hour] = c.hourMaps(hours, hour)
|
hours[hour] = c.hourMaps(hours, hour)
|
||||||
hours[hour][KindBlocked][rw.Name]++
|
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"
|
// policySource names what a rewrite came from: a blocklist's name, "rules"
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ let refreshTimer = null;
|
|||||||
|
|
||||||
export async function render(el, params, query) {
|
export async function render(el, params, query) {
|
||||||
clearTimeout(refreshTimer);
|
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 writable = can("blocking", true);
|
||||||
const recursive = lists.concat(rules).find((x) => x.auto_views);
|
const recursive = lists.concat(rules).find((x) => x.auto_views);
|
||||||
const autoViews = recursive ? recursive.views : null;
|
const autoViews = recursive ? recursive.views : null;
|
||||||
@@ -39,10 +39,16 @@ export async function render(el, params, query) {
|
|||||||
} catch (e) { mount(checkOut, errorBox(e)); }
|
} catch (e) { mount(checkOut, errorBox(e)); }
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const paused = status.paused_until;
|
||||||
mount(el,
|
mount(el,
|
||||||
h("div", { class: "page-head" },
|
h("div", { class: "page-head" },
|
||||||
h("div", { class: "grow" }, h("h1", null, "Blocking"),
|
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("div", { class: "panel" },
|
||||||
h("form", { class: "actions", onsubmit: runCheck },
|
h("form", { class: "actions", onsubmit: runCheck },
|
||||||
h("label", { class: "muted" }, "Check a name"), checkName, h("button", { type: "submit" }, "Check")),
|
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));
|
listsPanel(lists, views, autoViews, writable));
|
||||||
if (checkName.value) runCheck();
|
if (checkName.value) runCheck();
|
||||||
|
|
||||||
// Lists are downloaded in the background: show when they're done.
|
// Lists are downloaded in the background, and a pause ends by itself:
|
||||||
if (lists.some((l) => l.state === "waiting")) {
|
// show it when it happens.
|
||||||
refreshTimer = setTimeout(() => { if (location.hash.startsWith("#/blocking") && el.isConnected) rerender(); }, 4000);
|
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) {
|
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; }
|
} catch (e) { toast(e.message, "err", 8000); return false; }
|
||||||
}
|
}
|
||||||
|
|
||||||
// viewPicker returns checkboxes for the views (none ticked: automatic).
|
// viewPicker is a compact choice of views: it shows the choice, and opens
|
||||||
function viewPicker(views, autoViews, selected) {
|
// a list of checkboxes (none ticked: every view that offers recursion).
|
||||||
|
function viewPicker(views, autoViews) {
|
||||||
if (!views.length) return { el: null, value: () => [] };
|
if (!views.length) return { el: null, value: () => [] };
|
||||||
const boxes = views.map((v) => checkbox(v.name, (selected || []).includes(v.name)));
|
const boxes = views.map((v) => checkbox(v.name, false));
|
||||||
const hint = autoViews && autoViews.length ? "none ticked: " + autoViews.join(", ") + " (every view that offers recursion)" : "none ticked: every view that offers recursion";
|
const value = () => views.filter((v, i) => boxes[i].input.checked).map((v) => v.name);
|
||||||
return {
|
const summary = h("summary");
|
||||||
el: h("div", { class: "f" }, h("label", null, "Views"), h("div", { class: "actions" }, boxes, h("span", { class: "muted small" }, hint))),
|
const show = () => {
|
||||||
value: () => views.filter((v, i) => boxes[i].input.checked).map((v) => v.name),
|
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) {
|
function viewsCell(x) {
|
||||||
if (!x.views.length) return h("span", { class: "muted" }, "none");
|
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(", ");
|
return x.auto_views ? [x.views.join(", "), h("span", { class: "muted small" }, " (auto)")] : x.views.join(", ");
|
||||||
|
|||||||
@@ -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";
|
import { get, state } from "../api.js";
|
||||||
|
|
||||||
export async function render(el) {
|
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("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.")));
|
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 || []) {
|
for (const e of st.blocklist_errors || []) {
|
||||||
parts.push(h("div", { class: "msg warn" },
|
parts.push(h("div", { class: "msg warn" },
|
||||||
h("strong", null, "Blocklist " + e.name + " couldn't be updated: "), e.error + ". ",
|
h("strong", null, "Blocklist " + e.name + " couldn't be updated: "), e.error + ". ",
|
||||||
|
|||||||
@@ -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));
|
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.
|
// 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 q = timeline.series.find((x) => x.key === "queries") || { values: timeline.times.map(() => 0) };
|
||||||
const qSeries = [{ label: "Queries", values: q.values, slot: 1 }];
|
const qSeries = [{ label: "Queries", values: q.values, slot: 1 }];
|
||||||
parts.push(chartCard("Queries", "per " + stepName(timeline.step) + ", " + rangeLabel,
|
const bl = timeline.series.find((x) => x.key === "blocked");
|
||||||
lineChart({ series: qSeries, times: timeline.times, step, area: true }), timeTable(qSeries, timeline.times, step), { wide: true }));
|
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.
|
// 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 }));
|
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("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 + "." })),
|
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.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));
|
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 {
|
} 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."));
|
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) {
|
async function loadAll(since, heatSince, queryLog) {
|
||||||
const p = {
|
const p = {
|
||||||
summary: get("/stats/summary", { since, top: 0 }),
|
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(",") }),
|
errors: get("/stats/timeline", { since, key: failures.map((f) => f.key).join(",") }),
|
||||||
heat: get("/stats/heatmap", { since: heatSince }),
|
heat: get("/stats/heatmap", { since: heatSince }),
|
||||||
};
|
};
|
||||||
const groups = ["qtype", "rcode", "view", "zone", "answer", "rtt", "blocklist"];
|
const groups = ["qtype", "rcode", "view", "zone", "answer", "rtt", "blocklist"];
|
||||||
groups.forEach((g) => { p["b_" + g] = get("/stats/breakdown", { group: g, since }); });
|
groups.forEach((g) => { p["b_" + g] = get("/stats/breakdown", { group: g, since }); });
|
||||||
if (queryLog) {
|
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 keys = Object.keys(p);
|
||||||
const vals = await Promise.all(Object.values(p));
|
const vals = await Promise.all(Object.values(p));
|
||||||
|
|||||||
@@ -443,5 +443,8 @@ footer.site a:hover { color: var(--accent); text-decoration-color: currentColor;
|
|||||||
.query-log li.pending code { opacity: 0.85; }
|
.query-log li.pending code { opacity: 0.85; }
|
||||||
.err-text { color: var(--err); }
|
.err-text { color: var(--err); }
|
||||||
ul.plain { list-style: none; margin: 8px 0; padding: 0; }
|
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; }
|
ul.plain li { padding: 3px 0; }
|
||||||
.query-details { margin: 8px 0; }
|
.query-details { margin: 8px 0; }
|
||||||
@@ -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.
|
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
|
run "block check" 0 $J block check deep.ok.tracker.adbraun.test
|
||||||
contains "allowed by your rule"
|
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
|
# 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.
|
# remembers that date: change it a second later, as a real list would.
|
||||||
sleep 1.1
|
sleep 1.1
|
||||||
|
|||||||
Reference in new issue
Block a user