Compare commits
6
Commits
blocking
...
v0.4.0-rc.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
59baccd636 | ||
|
|
02c393a6bb | ||
|
|
d4756444ac | ||
|
|
67f14b030f | ||
|
|
ad8eb3fc3d | ||
|
|
27d1f9e538 |
No files matched your search
+32
-3
@@ -26,13 +26,42 @@ 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.
|
||||
|
||||
### Changed
|
||||
|
||||
- Web UI options (server, top-level, view and zone): values with blocks,
|
||||
such as logging, are shown and edited laid out as in named.conf, over
|
||||
several lines with indentation. The editor grows with its content, Tab
|
||||
indents and Enter keeps the indentation (Esc, then Tab, moves on). With
|
||||
a draft open, the tables show the draft's values, so a staged edit stays
|
||||
visible after Save.
|
||||
- The pending-changes banner has an Apply now button: it applies your
|
||||
draft without going to the Changes page, and stays on the page you're
|
||||
on. If the changes are refused, a dialog says why and links to Changes.
|
||||
- Blocking page: blocklists and rules have an Edit button (name, URL,
|
||||
views, comment; a rule's action and scope). The list button that
|
||||
downloads now is called Refresh, like `jarl blocklist refresh`.
|
||||
- Records table: disabled records are hatched and have a "disabled" badge
|
||||
next to their name, instead of only a muted colour and a badge in the
|
||||
comment column.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Blocking page, checking a name: when your own block rule is for that very
|
||||
name, it offers to remove the rule instead of an allow rule, which was
|
||||
refused as a second rule for the same name. It also names the rule that
|
||||
matched rather than the policy zone entry.
|
||||
- docs/validation.md lacked the `disabled_conflict` warning added in 0.3.0.
|
||||
|
||||
## [0.3.0] - 2026-10-09
|
||||
|
||||
@@ -171,9 +171,11 @@ review step.
|
||||
|
||||
## Status
|
||||
|
||||
Version 0.3.0 is the current release. It adds disabled records,
|
||||
script-friendly CLI output and client names in the statistics, on top of
|
||||
0.2.0's DNSSEC signing, Porkbun support, two-factor login and view colours. Jarl is feature-complete for one server's configuration.
|
||||
Version 0.3.0 is the current release, and 0.4.0 is in testing
|
||||
(0.4.0-rc.1): it adds blocking (blocklists and your own rules, like
|
||||
Pi-hole, done by named). 0.3.0 added disabled records, script-friendly
|
||||
CLI output and client names in the statistics, on top of 0.2.0's DNSSEC
|
||||
signing, Porkbun support, two-factor login and view colours. Jarl is feature-complete for one server's configuration.
|
||||
Managing several servers from one jarld and dynamic updates are not
|
||||
handled yet ([concepts.md](docs/concepts.md#what-jarl-deliberately-doesnt-do-yet)).
|
||||
|
||||
|
||||
+75
-1
@@ -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"
|
||||
}
|
||||
+7
-3
@@ -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"
|
||||
}
|
||||
|
||||
+8
-3
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+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) |
|
||||
| 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}]` |
|
||||
|
||||
|
||||
+23
-4
@@ -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.
|
||||
|
||||
+3
-1
@@ -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).
|
||||
|
||||
+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
|
||||
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.
|
||||
|
||||
|
||||
@@ -77,6 +77,10 @@ type Option struct {
|
||||
Where string `json:"where"` // e.g. "view internal"
|
||||
Description string `json:"description,omitempty"`
|
||||
Status string `json:"status,omitempty"`
|
||||
// Formatted is the value laid out over several lines, as in
|
||||
// named.conf, when it has a block (for display and editing; Value is
|
||||
// the same on one line).
|
||||
Formatted string `json:"formatted,omitempty"`
|
||||
}
|
||||
|
||||
// User is a user with formatted grants.
|
||||
@@ -245,6 +249,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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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{}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -36,6 +36,24 @@ func Compact(items []Item) string {
|
||||
// CompactStmt returns the statement on a single line with its semicolon.
|
||||
func CompactStmt(s *Stmt) string { return Compact(s.Items) + ";" }
|
||||
|
||||
// FormatValue returns an option's value laid out as named.conf writes it:
|
||||
// blocks over several lines, indented with tabs. Values without a block,
|
||||
// and values that don't parse, are returned as they are.
|
||||
func FormatValue(name, value string) string {
|
||||
if !strings.Contains(value, "{") {
|
||||
return value
|
||||
}
|
||||
st, err := ParseOptionValue(name, value)
|
||||
if err != nil {
|
||||
return value
|
||||
}
|
||||
out := strings.TrimSuffix(strings.TrimRight(FormatStmt(st, 0), "\n"), ";")
|
||||
if rest, ok := strings.CutPrefix(out, st.Keyword()); ok {
|
||||
return strings.TrimLeft(rest, " ")
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
// QuoteString returns s as a named.conf quoted string.
|
||||
func QuoteString(s string) string {
|
||||
var sb strings.Builder
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
package namedconf
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestFormatValue(t *testing.T) {
|
||||
for _, c := range []struct{ name, value, want string }{
|
||||
{"logging", `{ channel c { file "/var/log/named/x.log" versions 3; print-time yes; }; category default { c; }; }`,
|
||||
// A short inner block stays on one line, as in named.conf.
|
||||
"{\n\tchannel c { file \"/var/log/named/x.log\" versions 3; print-time yes; };\n\tcategory default { c; };\n}"},
|
||||
{"allow-query", `{ allowed_clients; }`, `{ allowed_clients; }`}, // short: unchanged
|
||||
{"directory", `"/var/cache/bind"`, `"/var/cache/bind"`},
|
||||
{"logging", `{ broken`, `{ broken`}, // unparsable: as it is
|
||||
} {
|
||||
if got := FormatValue(c.name, c.value); got != c.want {
|
||||
t.Errorf("FormatValue(%s, %q) =\n%s\nwant\n%s", c.name, c.value, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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" {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"jarl/internal/dnsname"
|
||||
"jarl/internal/engine"
|
||||
"jarl/internal/model"
|
||||
"jarl/internal/namedconf"
|
||||
"jarl/internal/render"
|
||||
"jarl/internal/rr"
|
||||
"jarl/internal/version"
|
||||
@@ -100,6 +101,9 @@ func presentOption(st *model.State, o *model.Option) api.Option {
|
||||
if c, ok := catalog.Default().Lookup(scope, o.Name); ok {
|
||||
out.Description, out.Status = c.Description, c.Status
|
||||
}
|
||||
if f := namedconf.FormatValue(o.Name, o.Value); f != o.Value {
|
||||
out.Formatted = f
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Jarl web UI: router, layout and login.
|
||||
import { h, mount, toast, errorBox, plural, lastList } from "./ui.js";
|
||||
import { h, mount, toast, errorBox, plural, lastList, modal } from "./ui.js";
|
||||
import { wordmark, logo } from "./brand.js";
|
||||
import { api, get, state, loadDraft, onChange, setUnauthorizedHandler, setImmediate, setCodePrompt } from "./api.js";
|
||||
import { api, get, state, loadDraft, onChange, setUnauthorizedHandler, setImmediate, setCodePrompt, useDraft } from "./api.js";
|
||||
import * as totp from "./totp.js";
|
||||
import * as dashboard from "./pages/dashboard.js";
|
||||
import * as zones from "./pages/zones.js";
|
||||
@@ -207,7 +207,28 @@ function renderBar() {
|
||||
mount(barEl, n > 0 ? h("div", { class: "pending-bar" },
|
||||
h("strong", null, plural(n, "pending change")),
|
||||
h("span", { class: "muted" }, "Your edits are collected in changeset #" + d.id + " and are not live yet."),
|
||||
h("a", { class: "button primary small", href: "#/changes" }, "Review & apply")) : null);
|
||||
h("a", { class: "button small", href: "#/changes" }, "Review"),
|
||||
h("button", { class: "primary small apply-now", title: "Apply the pending changes now, without reviewing them first", onclick: (ev) => applyDraft(ev.target, d) }, "Apply now")) : null);
|
||||
}
|
||||
|
||||
// applyDraft applies the draft changeset from the banner and stays on the
|
||||
// page. If it is refused, a dialog says why and links to Changes.
|
||||
async function applyDraft(btn, d) {
|
||||
btn.disabled = true;
|
||||
btn.textContent = "Applying…";
|
||||
try {
|
||||
const r = await api("POST", "/changesets/" + d.id + "/apply", { body: {} });
|
||||
const res = r.result || {};
|
||||
toast("Applied changeset #" + d.id + (res.reloaded && res.reloaded.length ? " — named reloaded" : ""));
|
||||
(r.warnings || []).forEach((w) => toast(w, "warn", 10000));
|
||||
useDraft(null);
|
||||
rerender();
|
||||
} catch (e) {
|
||||
btn.disabled = false;
|
||||
btn.textContent = "Apply now";
|
||||
const review = await modal("The changes were not applied", errorBox(e), [{ label: "Close", value: false }, { label: "Review changes", kind: "primary", value: true }], { wide: true });
|
||||
if (review) location.hash = "#/changes";
|
||||
}
|
||||
}
|
||||
|
||||
async function logout() {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// Blocking: your block/allow rules and blocklists (docs/blocking.md).
|
||||
import { h, mount, table, input, select, checkbox, toast, errorBox, badge, confirmDialog, relTime, time } from "../ui.js";
|
||||
import { h, mount, table, field, input, select, checkbox, toast, errorBox, badge, confirmDialog, modal, relTime, time } from "../ui.js";
|
||||
import { get, change, describeResult, draftQuery, can, api } from "../api.js";
|
||||
import { rerender } from "../app.js";
|
||||
import { pendingBadge } from "./zones.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;
|
||||
@@ -35,14 +35,20 @@ export async function render(el, params, query) {
|
||||
if (!name) return;
|
||||
try {
|
||||
const res = await get("/blocking/check", { name });
|
||||
mount(checkOut, checkResult(name, res, writable));
|
||||
mount(checkOut, checkResult(name, res, rules, writable));
|
||||
} 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,27 +57,71 @@ 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 checkResult(name, res, writable) {
|
||||
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); }
|
||||
}
|
||||
|
||||
// checkResult shows what blocking does with a name, and offers the way
|
||||
// to stop it: an allow rule (it beats every list, and a rule for a parent
|
||||
// domain), or, when your own block rule is for this very name, removing
|
||||
// that rule (a second rule for the same name isn't allowed).
|
||||
function checkResult(name, res, rules, writable) {
|
||||
if (!res.length) return h("p", { class: "muted" }, "Blocking isn't used in any view yet: add a rule or a blocklist below.");
|
||||
const blocked = res.some((m) => m.result === "blocked");
|
||||
const n = name.trim().toLowerCase().replace(/\.$/, "");
|
||||
const ruleById = new Map(rules.map((r) => [r.id, r]));
|
||||
const blocked = res.filter((m) => m.result === "blocked");
|
||||
const ownBlocks = [...new Set(blocked.map((m) => ruleById.get(m.rule_id)).filter((r) => r && r.name === n))];
|
||||
const sameName = rules.filter((r) => r.name === n && !ownBlocks.includes(r));
|
||||
let action = null;
|
||||
if (writable && ownBlocks.length) {
|
||||
action = h("p", null, h("button", { class: "small", onclick: () => removeRules(ownBlocks) }, "Remove your block rule for " + n),
|
||||
h("span", { class: "muted small" }, ownBlocks.some((r) => !r.exact) ? " (it also covers the names below it)" : ""));
|
||||
} else if (writable && blocked.length && !sameName.length) {
|
||||
action = h("p", null, h("button", { class: "small", onclick: () => addRule(n, "allow", false, []) }, "Allow " + n),
|
||||
h("span", { class: "muted small" }, " adds an allow rule for it and the names below it, which beats every list and any rule for a parent domain."));
|
||||
} else if (writable && blocked.length) {
|
||||
action = h("p", { class: "muted small" }, "There already is a rule for " + n + " (in other views): change it under Your rules.");
|
||||
}
|
||||
return h("div", null,
|
||||
h("ul", { class: "plain" }, res.map((m) => h("li", null,
|
||||
m.view ? h("span", { class: "muted" }, "view " + m.view + ": ") : null,
|
||||
m.result === "blocked" ? badge("blocked", "err") : m.result === "allowed" ? badge("allowed", "ok") : badge("not blocked", ""),
|
||||
" ",
|
||||
m.list ? ["by blocklist ", h("strong", null, m.list), " (entry ", h("code", null, m.entry), ")"]
|
||||
: m.result !== "none" ? ["by your rule for ", h("code", null, ruleName(m.entry)), m.entry.startsWith("*.") ? " (names below it)" : ""] : null))),
|
||||
blocked && writable ? h("p", null, h("button", { class: "small", onclick: () => addRule(name, "allow", false, []) }, "Allow " + name),
|
||||
h("span", { class: "muted small" }, " adds an allow rule for it and the names below it, which beats every list.")) : null);
|
||||
h("ul", { class: "plain" }, res.map((m) => {
|
||||
const rule = ruleById.get(m.rule_id);
|
||||
return h("li", null,
|
||||
m.view ? h("span", { class: "muted" }, "view " + m.view + ": ") : null,
|
||||
m.result === "blocked" ? badge("blocked", "err") : m.result === "allowed" ? badge("allowed", "ok") : badge("not blocked", ""),
|
||||
" ",
|
||||
m.list ? ["by blocklist ", h("strong", null, m.list), " (entry ", h("code", null, m.entry), ")"]
|
||||
: m.result !== "none" ? ["by your rule to ", rule ? rule.action : "", " ", h("code", null, rule ? rule.name : m.entry.replace(/^\*\./, "")), rule && !rule.exact ? " and below" : ""] : null);
|
||||
})),
|
||||
action);
|
||||
}
|
||||
|
||||
const ruleName = (entry) => entry.replace(/^\*\./, "");
|
||||
async function removeRules(list) {
|
||||
try {
|
||||
let r;
|
||||
for (const rule of list) r = await change("DELETE", "/blocking/rules/" + rule.id);
|
||||
toast(describeResult(r));
|
||||
rerender();
|
||||
} catch (e) { toast(e.message, "err", 8000); }
|
||||
}
|
||||
|
||||
async function addRule(name, action, exact, views, comment) {
|
||||
try {
|
||||
@@ -82,17 +132,74 @@ 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) {
|
||||
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),
|
||||
// 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, selected = []) {
|
||||
if (!views.length) return { el: null, picker: null, value: () => [] };
|
||||
const boxes = views.map((v) => checkbox(v.name, selected.includes(v.name)));
|
||||
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), picker, value };
|
||||
}
|
||||
|
||||
// The views a list or rule names itself ([] when automatic).
|
||||
const ownViews = (x) => (x.auto_views ? [] : x.views);
|
||||
|
||||
// editDialog changes a list or rule: fields are [label, control, help]
|
||||
// rows, and data() the PATCH body.
|
||||
async function editDialog(title, path, rows, data) {
|
||||
const err = h("div");
|
||||
await modal(title, h("div", null, h("div", { class: "form-grid" }, rows.filter(Boolean).map(([l, c, help]) => field(l, c, help))), err), [
|
||||
{ label: "Cancel", value: null },
|
||||
{ label: "Save", kind: "primary", onclick: async () => {
|
||||
try {
|
||||
const r = await change("PATCH", path, data());
|
||||
toast(describeResult(r));
|
||||
rerender();
|
||||
return true;
|
||||
} catch (e) { mount(err, errorBox(e)); return false; }
|
||||
} },
|
||||
], { sticky: true });
|
||||
}
|
||||
|
||||
function editList(l, views, autoViews) {
|
||||
const name = input({ value: l.name, class: "mono" });
|
||||
const url = input({ value: l.url, class: "mono", style: { width: "100%" } });
|
||||
const comment = input({ value: l.comment || "" });
|
||||
const vp = viewPicker(views, autoViews, ownViews(l));
|
||||
editDialog("Edit blocklist " + l.name, "/blocking/lists/" + l.id, [
|
||||
["Name", name], ["URL", url, "A new URL is downloaded right after saving."],
|
||||
vp.picker ? ["Views", vp.picker, "None ticked: every view that offers recursion."] : null,
|
||||
["Comment", comment],
|
||||
], () => ({ name: name.value.trim(), url: url.value.trim(), views: vp.value(), comment: comment.value }));
|
||||
}
|
||||
|
||||
function editRule(r, views, autoViews) {
|
||||
const name = input({ value: r.name, class: "mono" });
|
||||
const action = select([["block", "Block"], ["allow", "Allow"]], r.action);
|
||||
const exact = checkbox("only this exact name", !!r.exact);
|
||||
const comment = input({ value: r.comment || "" });
|
||||
const vp = viewPicker(views, autoViews, ownViews(r));
|
||||
editDialog("Edit the rule for " + r.name, "/blocking/rules/" + r.id, [
|
||||
["Action", action], ["Domain", name], ["", exact],
|
||||
vp.picker ? ["Views", vp.picker, "None ticked: every view that offers recursion."] : null,
|
||||
["Comment", comment],
|
||||
], () => ({ name: name.value.trim(), action: action.value, exact: exact.input.checked, views: vp.value(), comment: comment.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(", ");
|
||||
@@ -128,7 +235,9 @@ function rulesPanel(rules, views, autoViews, writable) {
|
||||
{ label: "Domain", render: (r) => [h("span", { class: "mono" }, r.name), r.exact ? null : h("span", { class: "muted small" }, " and below")] },
|
||||
views.length ? { label: "Views", render: viewsCell } : null,
|
||||
{ label: "Comment", render: (r) => h("span", { class: "muted" }, r.comment || "") },
|
||||
writable ? { label: "", render: (r) => r.pending === "delete" ? "" : h("button", { class: "small danger", onclick: () => del("/blocking/rules/" + r.id, "the rule for " + r.name) }, "Delete"), tdClass: "actions-cell" } : null,
|
||||
writable ? { label: "", render: (r) => r.pending === "delete" ? "" : h("span", null,
|
||||
h("button", { class: "small", onclick: () => editRule(r, views, autoViews) }, "Edit"), " ",
|
||||
h("button", { class: "small danger", onclick: () => del("/blocking/rules/" + r.id, "the rule for " + r.name) }, "Delete")), tdClass: "actions-cell" } : null,
|
||||
].filter(Boolean), rules, { empty: "No rules yet.", rowClass: (r) => r.pending ? "pending-" + r.pending : "" }));
|
||||
}
|
||||
|
||||
@@ -167,7 +276,8 @@ function listsPanel(lists, views, autoViews, writable) {
|
||||
{ label: "Memory", render: (l) => l.memory_mb ? h("span", { title: "named's memory for this list, estimated (all its views)" }, "~" + l.memory_mb + " MB") : "–", tdClass: "small right" },
|
||||
{ label: "URL", render: (l) => h("a", { href: l.url, target: "_blank", rel: "noopener", class: "small mono", title: l.url }, shortURL(l.url)) },
|
||||
writable ? { label: "", render: (l) => l.pending === "delete" ? "" : h("span", null,
|
||||
h("button", { class: "small", disabled: l.state === "disabled" || !!l.pending, title: "Download now", onclick: () => refresh(l) }, "Update"), " ",
|
||||
h("button", { class: "small", onclick: () => editList(l, views, autoViews) }, "Edit"), " ",
|
||||
h("button", { class: "small", disabled: l.state === "disabled" || !!l.pending, title: "Download it now (it is checked daily anyway)", onclick: () => refresh(l) }, "Refresh"), " ",
|
||||
h("button", { class: "small", onclick: () => toggle(l) }, l.disabled ? "Enable" : "Disable"), " ",
|
||||
h("button", { class: "small danger", onclick: () => del("/blocking/lists/" + l.id, "blocklist " + l.name) }, "Delete")), tdClass: "actions-cell" } : null,
|
||||
].filter(Boolean), lists, { empty: "No blocklists yet.", rowClass: (l) => l.pending ? "pending-" + l.pending : l.disabled ? "disabled" : "" }),
|
||||
|
||||
@@ -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 + ". ",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { h, mount, table, field, input, select, textarea, toast, errorBox, badge, confirmDialog, lines } from "../ui.js";
|
||||
import { get, change, describeResult } from "../api.js";
|
||||
import { get, change, describeResult, draftQuery } from "../api.js";
|
||||
import { rerender } from "../app.js";
|
||||
import { emailOf } from "./zones.js";
|
||||
import { amlContext, suggestions } from "../aml.js";
|
||||
@@ -28,7 +28,7 @@ function valueWidget(stmt, value, ctx) {
|
||||
return { el: h("div", null, t, ctx ? suggestions(t, ctx) : null), get: () => "{ " + lines(t.value).map((l) => l.replace(/;$/, "") + ";").join(" ") + " }", hint: "One element per line: address or prefix, !negated, key NAME, ACL name, any, none, localhost, localnets. First match wins." };
|
||||
}
|
||||
if (kind === "raw") {
|
||||
const t = textarea(value || "", { class: "mono", rows: 3 });
|
||||
const t = codeArea(value || "");
|
||||
// Statements such as allow-transfer and listen-on take an address
|
||||
// match list after optional parameters: suggest names at the cursor.
|
||||
const takesList = stmt && /address_match_element/.test(stmt.grammar || "");
|
||||
@@ -39,9 +39,42 @@ function valueWidget(stmt, value, ctx) {
|
||||
return { el: i, get: () => i.value.trim() };
|
||||
}
|
||||
|
||||
// codeArea is a textarea for named.conf text: as tall as its content
|
||||
// (up to 30 lines), Tab indents, Enter keeps the line's indentation. After
|
||||
// Esc, Tab moves on to the next field again.
|
||||
function codeArea(value) {
|
||||
const t = textarea(value, { class: "mono code", spellcheck: "false" });
|
||||
const fit = () => { t.rows = Math.min(30, Math.max(3, t.value.split("\n").length + 1)); };
|
||||
fit();
|
||||
let tabMoves = false;
|
||||
const insert = (text) => {
|
||||
const { selectionStart: a, selectionEnd: b } = t;
|
||||
t.setRangeText(text, a, b, "end");
|
||||
fit();
|
||||
};
|
||||
t.addEventListener("input", fit);
|
||||
t.addEventListener("focus", () => { tabMoves = false; });
|
||||
t.addEventListener("keydown", (ev) => {
|
||||
if (ev.key === "Escape") { tabMoves = true; return; }
|
||||
if (ev.key === "Tab" && !tabMoves && !ev.shiftKey) {
|
||||
ev.preventDefault();
|
||||
insert("\t");
|
||||
} else if (ev.key === "Enter" && !ev.shiftKey && !ev.ctrlKey && !ev.metaKey) {
|
||||
const line = t.value.slice(0, t.selectionStart).split("\n").pop();
|
||||
let indent = line.match(/^\t*/)[0];
|
||||
if (line.trimEnd().endsWith("{")) indent += "\t";
|
||||
ev.preventDefault();
|
||||
insert("\n" + indent);
|
||||
}
|
||||
});
|
||||
return t;
|
||||
}
|
||||
|
||||
// optionsEditor renders the options of one scope.
|
||||
export async function optionsEditor(el, { scope, scope_id, catalogScope, title, intro }) {
|
||||
const [opts, cat, ctx] = await Promise.all([get("/options", { scope, scope_id }), catalog(catalogScope), amlContext()]);
|
||||
// With a draft open, show the options as they will be: a staged edit
|
||||
// stays visible after Save.
|
||||
const [opts, cat, ctx] = await Promise.all([get("/options", draftQuery({ scope, scope_id })), catalog(catalogScope), amlContext()]);
|
||||
const byName = Object.fromEntries(cat.map((s) => [s.name, s]));
|
||||
const err = h("div");
|
||||
const listEl = h("div");
|
||||
@@ -49,7 +82,7 @@ export async function optionsEditor(el, { scope, scope_id, catalogScope, title,
|
||||
const draw = () => {
|
||||
mount(listEl, table([
|
||||
{ label: "Option", render: (o) => h("span", null, h("strong", { class: "mono" }, o.name), o.status ? [" ", badge(o.status, "warn")] : null), tdClass: "nowrap" },
|
||||
{ label: "Value", render: (o) => editing === o.id ? editRow(o) : h("pre", { style: { padding: "2px 6px", whiteSpace: "pre-wrap" } }, o.value || "(no value)") },
|
||||
{ label: "Value", render: (o) => editing === o.id ? editRow(o) : h("pre", { class: "code", style: { padding: "2px 6px", whiteSpace: "pre-wrap" } }, o.formatted || o.value || "(no value)") },
|
||||
{ label: "Description", render: (o) => h("span", { class: "muted small" }, o.description || ""), tdClass: "small" },
|
||||
{ label: "", render: (o) => editing === o.id ? "" : h("span", null,
|
||||
h("button", { class: "small", onclick: () => { editing = o.id; draw(); } }, "Edit"), " ",
|
||||
@@ -57,7 +90,7 @@ export async function optionsEditor(el, { scope, scope_id, catalogScope, title,
|
||||
], opts, { empty: "No options set; BIND's defaults apply." }));
|
||||
};
|
||||
const editRow = (o) => {
|
||||
const w = valueWidget(byName[o.name], o.value, ctx);
|
||||
const w = valueWidget(byName[o.name], o.formatted || o.value, ctx);
|
||||
const e2 = h("div");
|
||||
return h("div", null, w.el, w.hint ? h("div", { class: "hint" }, w.hint) : null,
|
||||
h("div", { class: "actions", style: { marginTop: "6px" } },
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -453,11 +453,12 @@ async function recordsTab(el, zone, query) {
|
||||
const rows = current.filter((r) => (!q || r.name.includes(q) || r.data.toLowerCase().includes(q) || (r.comment || "").toLowerCase().includes(q)) && (!typeFilter.value || r.type === typeFilter.value) &&
|
||||
(!stateFilter.value || !!r.disabled === (stateFilter.value === "off")));
|
||||
mount(list, table([
|
||||
{ label: "Name", render: (r) => reverse && addressOf(r.name) ? [r.relative_name, " ", h("span", { class: "muted small" }, addressOf(r.name))] : r.relative_name, tdClass: "name" },
|
||||
{ label: "Name", render: (r) => [reverse && addressOf(r.name) ? [r.relative_name, " ", h("span", { class: "muted small" }, addressOf(r.name))] : r.relative_name,
|
||||
r.disabled && r.pending !== "disable" ? [" ", badge("disabled", "off")] : null], tdClass: "name" },
|
||||
{ label: "TTL", render: (r) => r.ttl !== undefined && r.ttl !== null ? String(r.ttl) : h("span", { class: "muted", title: "zone default" }, String(r.effective_ttl)), tdClass: "mono" },
|
||||
{ label: "Type", render: (r) => h("strong", null, r.type) },
|
||||
{ label: "Data", render: (r) => r.data, tdClass: "data" },
|
||||
{ label: "Comment", render: (r) => [r.disabled && r.pending !== "disable" ? [badge("disabled"), " "] : null, r.pending ? [pendingBadge(r.pending), " "] : null, h("span", { class: "muted" }, r.comment || "")] },
|
||||
{ label: "Comment", render: (r) => [r.pending ? [pendingBadge(r.pending), " "] : null, h("span", { class: "muted" }, r.comment || "")] },
|
||||
{ label: "", render: (r) => r.pending === "delete" ? "" : h("span", null,
|
||||
h("button", { class: "small", title: "Edit", onclick: () => edit(r) }, "Edit"), " ",
|
||||
r.disabled
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
--panel: #ffffff;
|
||||
--text: #1d2330;
|
||||
--muted: #5f6b7d;
|
||||
--hatch: rgba(95, 107, 125, 0.13);
|
||||
--border: #d9dee6;
|
||||
--accent: #2f5fb3;
|
||||
--accent-text: #ffffff;
|
||||
@@ -34,6 +35,7 @@
|
||||
--panel: #1c2027;
|
||||
--text: #e3e7ee;
|
||||
--muted: #9aa5b5;
|
||||
--hatch: rgba(154, 165, 181, 0.12);
|
||||
--border: #2f3540;
|
||||
--accent: #6d9be8;
|
||||
--accent-text: #0f1520;
|
||||
@@ -58,7 +60,7 @@
|
||||
}
|
||||
}
|
||||
:root[data-theme="dark"] {
|
||||
--bg: #14171c; --panel: #1c2027; --text: #e3e7ee; --muted: #9aa5b5; --border: #2f3540;
|
||||
--bg: #14171c; --panel: #1c2027; --text: #e3e7ee; --muted: #9aa5b5; --border: #2f3540; --hatch: rgba(154, 165, 181, 0.12);
|
||||
--accent: #6d9be8; --accent-text: #0f1520; --accent-soft: #23314a; --ok: #59c287; --ok-soft: #173326;
|
||||
--warn: #e5b450; --warn-soft: #3a2f15; --err: #f08077; --err-soft: #3c1f1d; --info-soft: #232833;
|
||||
--code-bg: #242933; --add: #183a25; --add-text: #9fe3b5; --del: #43201e; --del-text: #f7b2ac; --hunk: #23314a;
|
||||
@@ -114,6 +116,7 @@ main { padding: 16px; max-width: 1400px; margin: 0 auto; }
|
||||
padding: 8px 16px;
|
||||
}
|
||||
.pending-bar strong { color: var(--warn); }
|
||||
.pending-bar .apply-now { margin-left: auto; }
|
||||
/* The review/live switch in the header. */
|
||||
.mode-toggle { display: inline-flex; align-items: center; gap: 6px; padding: 3px 10px; border-radius: 999px; font-size: 12px; font-weight: 500; background: transparent; color: var(--muted); border: 1px solid var(--border); }
|
||||
.mode-toggle:hover { color: var(--text); border-color: var(--accent); }
|
||||
@@ -150,6 +153,9 @@ tr.pending-add td { background: var(--add); }
|
||||
tr.pending-change td, tr.pending-disable td, tr.pending-enable td { background: var(--warn-soft); }
|
||||
tr.pending-delete td { background: var(--del); text-decoration: line-through; color: var(--muted); }
|
||||
tr.disabled td:not(.actions-cell), tr.disabled td strong { color: var(--muted); }
|
||||
/* Disabled records: hatched, so they stand out from live and pending rows. */
|
||||
tr.disabled td { background-image: repeating-linear-gradient(135deg, transparent 0 7px, var(--hatch) 7px 9px); }
|
||||
.badge.off { background: var(--muted); color: var(--panel); }
|
||||
button.toggle { min-width: 5.2em; }
|
||||
.empty { color: var(--muted); padding: 18px; text-align: center; }
|
||||
|
||||
@@ -166,6 +172,7 @@ textarea { width: 100%; min-height: 80px; resize: vertical; }
|
||||
.form-grid > label { padding-top: 6px; color: var(--muted); }
|
||||
.form-grid .help { grid-column: 2; margin-top: -4px; color: var(--muted); font-size: 12px; }
|
||||
.form-grid input, .form-grid select { width: 100%; max-width: 560px; }
|
||||
.form-grid input[type=checkbox] { width: auto; }
|
||||
.inline-form { display: flex; gap: 6px; flex-wrap: wrap; align-items: flex-end; }
|
||||
.inline-form .f { display: flex; flex-direction: column; gap: 2px; }
|
||||
.inline-form .f label:not(.check) { font-size: 11px; color: var(--muted); text-transform: uppercase; letter-spacing: 0.3px; }
|
||||
@@ -443,5 +450,10 @@ 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; }
|
||||
.code { tab-size: 4; }
|
||||
textarea.code { width: 100%; white-space: pre; overflow-wrap: normal; overflow-x: auto; }
|
||||
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; }
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user