Compare commits
9
Commits
blocking
...
v0.4.0-rc.2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
73d97ecc0a | ||
|
|
33060f86ce | ||
|
|
89663e366b | ||
|
|
59baccd636 | ||
|
|
02c393a6bb | ||
|
|
d4756444ac | ||
|
|
67f14b030f | ||
|
|
ad8eb3fc3d | ||
|
|
27d1f9e538 |
No files matched your search
+46
-3
@@ -8,6 +8,14 @@ All notable changes to Jarl are documented here. The format follows
|
|||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
|
- Disabled zones: a zone can be disabled instead of deleted. named stops
|
||||||
|
loading it (its zone statement is left out of named.conf), while Jarl
|
||||||
|
keeps it with its records, options and history, still editable;
|
||||||
|
enabling it serves it as it is then. Disabling says what clients get
|
||||||
|
instead (recursion, or REFUSED). Signed zones can't be disabled. Web
|
||||||
|
UI: Disable/Enable on the zone page, a badge in the zone list. CLI:
|
||||||
|
`jarl zone disable|enable`. API: `PATCH /zones/{zone}` with
|
||||||
|
`{"disabled": true}`. Database schema 7.
|
||||||
- Blocking (docs/blocking.md): named answers "doesn't exist" for ads,
|
- Blocking (docs/blocking.md): named answers "doesn't exist" for ads,
|
||||||
trackers and malware, using BIND's response policy zones.
|
trackers and malware, using BIND's response policy zones.
|
||||||
- Blocklists in Adblock, hosts or plain-domain format, downloaded by jarld
|
- Blocklists in Adblock, hosts or plain-domain format, downloaded by jarld
|
||||||
@@ -26,13 +34,48 @@ 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.
|
||||||
|
|
||||||
|
### 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
|
### Fixed
|
||||||
|
|
||||||
|
- Statistics: after a reconfig that added, removed or recreated views
|
||||||
|
(creating the first view, for instance), a minute could count everything
|
||||||
|
since named started: millions of queries in an hour. A per-view counter
|
||||||
|
(cache, upstream times, validation) going down was taken for a restart
|
||||||
|
of named. Now only a new boot time means a restart, and a counter that
|
||||||
|
goes down counts 0 for that minute.
|
||||||
|
- 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.
|
- docs/validation.md lacked the `disabled_conflict` warning added in 0.3.0.
|
||||||
|
|
||||||
## [0.3.0] - 2026-10-09
|
## [0.3.0] - 2026-10-09
|
||||||
|
|||||||
@@ -171,9 +171,11 @@ review step.
|
|||||||
|
|
||||||
## Status
|
## Status
|
||||||
|
|
||||||
Version 0.3.0 is the current release. It adds disabled records,
|
Version 0.3.0 is the current release, and 0.4.0 is in testing
|
||||||
script-friendly CLI output and client names in the statistics, on top of
|
(0.4.0-rc.2): it adds blocking (blocklists and your own rules, like
|
||||||
0.2.0's DNSSEC signing, Porkbun support, two-factor login and view colours. Jarl is feature-complete for one server's configuration.
|
Pi-hole, done by named) and disabling zones. 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
|
Managing several servers from one jarld and dynamic updates are not
|
||||||
handled yet ([concepts.md](docs/concepts.md#what-jarl-deliberately-doesnt-do-yet)).
|
handled yet ([concepts.md](docs/concepts.md#what-jarl-deliberately-doesnt-do-yet)).
|
||||||
|
|
||||||
|
|||||||
+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"
|
||||||
}
|
}
|
||||||
|
|||||||
+47
-4
@@ -45,7 +45,11 @@ func zoneCmd() *cobra.Command {
|
|||||||
if z.Type == "primary" {
|
if z.Type == "primary" {
|
||||||
serial, recs = fmt.Sprint(z.SOA.Serial), fmt.Sprint(z.Records)
|
serial, recs = fmt.Sprint(z.SOA.Serial), fmt.Sprint(z.Records)
|
||||||
}
|
}
|
||||||
fmt.Fprintf(w, "%d\t%s\t%s\t%s\t%s\t%s\n", z.ID, z.Display, z.View, z.Type, recs, serial)
|
typ := z.Type
|
||||||
|
if z.Disabled {
|
||||||
|
typ += " (disabled)"
|
||||||
|
}
|
||||||
|
fmt.Fprintf(w, "%d\t%s\t%s\t%s\t%s\t%s\n", z.ID, z.Display, z.View, typ, recs, serial)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
@@ -73,12 +77,15 @@ func zoneCmd() *cobra.Command {
|
|||||||
fmt.Fprintf(w, "View:\t%s\n", z.View)
|
fmt.Fprintf(w, "View:\t%s\n", z.View)
|
||||||
}
|
}
|
||||||
fmt.Fprintf(w, "Type:\t%s\n", z.Type)
|
fmt.Fprintf(w, "Type:\t%s\n", z.Type)
|
||||||
|
if z.Disabled {
|
||||||
|
fmt.Fprintf(w, "Disabled:\tyes: named doesn't load it ('jarl zone enable %s')\n", args[0])
|
||||||
|
}
|
||||||
if z.Type == "primary" {
|
if z.Type == "primary" {
|
||||||
fmt.Fprintf(w, "Serial:\t%d\n", z.SOA.Serial)
|
fmt.Fprintf(w, "Serial:\t%d\n", z.SOA.Serial)
|
||||||
fmt.Fprintf(w, "SOA:\t%s %s refresh %d retry %d expire %d minimum %d\n", z.SOA.MName, z.SOA.RName, z.SOA.Refresh, z.SOA.Retry, z.SOA.Expire, z.SOA.Minimum)
|
fmt.Fprintf(w, "SOA:\t%s %s refresh %d retry %d expire %d minimum %d\n", z.SOA.MName, z.SOA.RName, z.SOA.Refresh, z.SOA.Retry, z.SOA.Expire, z.SOA.Minimum)
|
||||||
fmt.Fprintf(w, "Default TTL:\t%d\n", z.DefaultTTL)
|
fmt.Fprintf(w, "Default TTL:\t%d\n", z.DefaultTTL)
|
||||||
if z.Disabled > 0 {
|
if z.DisabledRecords > 0 {
|
||||||
fmt.Fprintf(w, "Records:\t%d (%d disabled: jarl record list %s --disabled)\n", z.Records, z.Disabled, args[0])
|
fmt.Fprintf(w, "Records:\t%d (%d disabled: jarl record list %s --disabled)\n", z.Records, z.DisabledRecords, args[0])
|
||||||
} else {
|
} else {
|
||||||
fmt.Fprintf(w, "Records:\t%d\n", z.Records)
|
fmt.Fprintf(w, "Records:\t%d\n", z.Records)
|
||||||
}
|
}
|
||||||
@@ -306,6 +313,42 @@ and a network between octet boundaries, like a /22, gets one zone per /24
|
|||||||
del.Flags().StringVar(&view, "view", "", "view of the zone")
|
del.Flags().StringVar(&view, "view", "", "view of the zone")
|
||||||
del.Flags().BoolVarP(&yes, "yes", "y", false, "don't ask for confirmation")
|
del.Flags().BoolVarP(&yes, "yes", "y", false, "don't ask for confirmation")
|
||||||
|
|
||||||
|
toggle := func(disable bool) *cobra.Command {
|
||||||
|
verb := map[bool]string{true: "disable", false: "enable"}[disable]
|
||||||
|
long := `Load a disabled zone in named again, as it is in Jarl now (changes made
|
||||||
|
while it was disabled included).`
|
||||||
|
if disable {
|
||||||
|
long = `Disable a zone: it stays in Jarl with its records, options and history,
|
||||||
|
and can still be edited, but named no longer loads it. Its names are then
|
||||||
|
answered by recursion where the view offers it (the internet's version of
|
||||||
|
the zone, if any), and REFUSED elsewhere. Signed zones can't be disabled;
|
||||||
|
turn signing off first.
|
||||||
|
|
||||||
|
jarl zone disable old.adbraun.lol
|
||||||
|
jarl zone enable old.adbraun.lol`
|
||||||
|
}
|
||||||
|
c := &cobra.Command{
|
||||||
|
Use: verb + " ZONE",
|
||||||
|
Short: map[bool]string{true: "Stop serving a zone, keeping it in Jarl", false: "Serve a disabled zone again"}[disable],
|
||||||
|
Long: long,
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
c, err := newClient()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
path, qv := zonePath(args[0], view)
|
||||||
|
resp, err := c.Mutate(ctx(), "PATCH", path, qv, map[string]any{"disabled": disable})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return report(resp)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
c.Flags().StringVar(&view, "view", "", "view of the zone")
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
fileCmd := &cobra.Command{
|
fileCmd := &cobra.Command{
|
||||||
Use: "file ZONE",
|
Use: "file ZONE",
|
||||||
Short: "Print the zone file Jarl generates",
|
Short: "Print the zone file Jarl generates",
|
||||||
@@ -373,7 +416,7 @@ equal to the file: records not in the file are deleted.`,
|
|||||||
importCmd.Flags().BoolVar(&replace, "replace", false, "delete records that are not in the file")
|
importCmd.Flags().BoolVar(&replace, "replace", false, "delete records that are not in the file")
|
||||||
importCmd.Flags().BoolVar(&useSOA, "use-soa", false, "also take SOA timers and $TTL from the file")
|
importCmd.Flags().BoolVar(&useSOA, "use-soa", false, "also take SOA timers and $TTL from the file")
|
||||||
|
|
||||||
cmd.AddCommand(list, show, create, update, del, fileCmd, importCmd, zoneSerialsCmd(), zoneDelegationCmd(), zoneDNSSECCmd(), zoneRegistrarCmd())
|
cmd.AddCommand(list, show, create, update, del, toggle(true), toggle(false), fileCmd, importCmd, zoneSerialsCmd(), zoneDelegationCmd(), zoneDNSSECCmd(), zoneRegistrarCmd())
|
||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+9
-4
@@ -167,7 +167,7 @@ func zoneIndex(a *app) stats.ZoneIndex {
|
|||||||
}
|
}
|
||||||
out := map[string][]string{}
|
out := map[string][]string{}
|
||||||
for _, z := range st.Zones {
|
for _, z := range st.Zones {
|
||||||
if z.Type != model.ZonePrimary && z.Type != model.ZoneSecondary && z.Type != model.ZoneMirror {
|
if z.Type != model.ZonePrimary && z.Type != model.ZoneSecondary && z.Type != model.ZoneMirror || z.Disabled {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
v := st.ViewName(z)
|
v := st.ViewName(z)
|
||||||
@@ -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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+8
-4
@@ -160,7 +160,7 @@ may be the name.
|
|||||||
| GET | `/zones` | `?view=`, `?type=`, `?q=` (name contains) |
|
| GET | `/zones` | `?view=`, `?type=`, `?q=` (name contains) |
|
||||||
| POST | `/zones` | ZoneInput (below) |
|
| POST | `/zones` | ZoneInput (below) |
|
||||||
| GET | `/zones/{zone}` | |
|
| GET | `/zones/{zone}` | |
|
||||||
| PATCH | `/zones/{zone}` | ZoneInput (only the fields to change) |
|
| PATCH | `/zones/{zone}` | ZoneInput (only the fields to change); `{"disabled": true}` disables the zone: named stops loading it, Jarl keeps it |
|
||||||
| DELETE | `/zones/{zone}` | deletes the zone with its records and options |
|
| DELETE | `/zones/{zone}` | deletes the zone with its records and options |
|
||||||
| GET | `/zones/{zone}/file` | the generated zone file (text/plain) |
|
| GET | `/zones/{zone}/file` | the generated zone file (text/plain) |
|
||||||
| GET | `/zones/{zone}/serials` | asks the zone's other name servers for their serial (below) |
|
| GET | `/zones/{zone}/serials` | asks the zone's other name servers for their serial (below) |
|
||||||
@@ -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.
|
||||||
|
|||||||
+12
-1
@@ -182,12 +182,21 @@ jarl zone create consul --type forward --forwarder "127.0.0.1 port 8600"
|
|||||||
jarl zone update adbraun.lol --ttl 3600 --contact hostmaster@adbraun.lol --refresh 7200
|
jarl zone update adbraun.lol --ttl 3600 --contact hostmaster@adbraun.lol --refresh 7200
|
||||||
jarl zone update adbraun.lol --move-to-view internal
|
jarl zone update adbraun.lol --move-to-view internal
|
||||||
jarl zone delete lab.adbraun.lol [-y]
|
jarl zone delete lab.adbraun.lol [-y]
|
||||||
|
jarl zone disable lab.adbraun.lol [--view V] # named stops serving it; kept (and editable) in Jarl
|
||||||
|
jarl zone enable lab.adbraun.lol [--view V]
|
||||||
jarl zone file adbraun.lol # the generated zone file
|
jarl zone file adbraun.lol # the generated zone file
|
||||||
jarl zone import adbraun.lol db.adbraun.lol [--replace] [--use-soa]
|
jarl zone import adbraun.lol db.adbraun.lol [--replace] [--use-soa]
|
||||||
jarl zone serials adbraun.lol # are the secondaries (or primaries) on the current serial?
|
jarl zone serials adbraun.lol # are the secondaries (or primaries) on the current serial?
|
||||||
jarl zone delegation adbraun.lol [--resolver 9.9.9.9] # does the parent zone delegate it correctly?
|
jarl zone delegation adbraun.lol [--resolver 9.9.9.9] # does the parent zone delegate it correctly?
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**Disabling a zone** leaves it out of named's configuration but keeps it in
|
||||||
|
Jarl: records, options and history, which can still be edited. Its names
|
||||||
|
are then answered by recursion where the view offers it (the internet's
|
||||||
|
version of the zone, if any), and REFUSED elsewhere; `disable` says which.
|
||||||
|
Enabling it serves it as it is then. A signed zone can't be disabled (turn
|
||||||
|
signing off first), nor a zone another view shares with `in-view`.
|
||||||
|
|
||||||
Reverse names follow the dots (the nibbles in IPv6), so a reverse zone
|
Reverse names follow the dots (the nibbles in IPv6), so a reverse zone
|
||||||
covers a /8, /16 or /24 (a multiple of /4 for IPv6). A network in between is
|
covers a /8, /16 or /24 (a multiple of /4 for IPv6). A network in between is
|
||||||
covered by several zones, created together in one changeset: a /22 by four
|
covered by several zones, created together in one changeset: a /22 by four
|
||||||
@@ -419,7 +428,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 +451,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.
|
||||||
|
|
||||||
|
|||||||
@@ -57,6 +57,8 @@ showing them.
|
|||||||
| `zone_missing_option` | error | Secondary, stub and mirror zones need `primaries` |
|
| `zone_missing_option` | error | Secondary, stub and mirror zones need `primaries` |
|
||||||
| `zone_file_required` / `zone_file_invalid` / `zone_file_managed` | error | Hint zones need a file. Forward zones have none. Primary zone files are Jarl's |
|
| `zone_file_required` / `zone_file_invalid` / `zone_file_managed` | error | Hint zones need a file. Forward zones have none. Primary zone files are Jarl's |
|
||||||
| `zone_inview_invalid`, `zone_inview_option` | error | `in-view` zones only inside views, and only with `forward`/`forwarders` |
|
| `zone_inview_invalid`, `zone_inview_option` | error | `in-view` zones only inside views, and only with `forward`/`forwarders` |
|
||||||
|
| `zone_inview_disabled` | error | An `in-view` zone can't share a disabled zone |
|
||||||
|
| `zone_disabled_signed` | error | A signed zone can't be disabled: turn signing off first |
|
||||||
| `zone_records_unmanaged` | error | Only primary zones can have records in Jarl |
|
| `zone_records_unmanaged` | error | Only primary zones can have records in Jarl |
|
||||||
| `zone_serial_policy` | error | `date`, `increment` or `unixtime` |
|
| `zone_serial_policy` | error | `date`, `increment` or `unixtime` |
|
||||||
| `zone_no_forwarders`, `zone_hint_name`, `zone_name_slash` | info / warning | Unusual but legal setups |
|
| `zone_no_forwarders`, `zone_hint_name`, `zone_name_slash` | info / warning | Unusual but legal setups |
|
||||||
|
|||||||
@@ -22,8 +22,8 @@ type Zone struct {
|
|||||||
Display string `json:"display_name"`
|
Display string `json:"display_name"`
|
||||||
View string `json:"view,omitempty"`
|
View string `json:"view,omitempty"`
|
||||||
Records int `json:"record_count"`
|
Records int `json:"record_count"`
|
||||||
// Disabled is how many of the records are disabled.
|
// DisabledRecords is how many of the records are disabled.
|
||||||
Disabled int `json:"disabled_count,omitempty"`
|
DisabledRecords int `json:"disabled_count,omitempty"`
|
||||||
ZoneFile string `json:"zone_file,omitempty"` // the file named loads
|
ZoneFile string `json:"zone_file,omitempty"` // the file named loads
|
||||||
RName string `json:"rname_email,omitempty"`
|
RName string `json:"rname_email,omitempty"`
|
||||||
// Registrar names the registrar Jarl can manage the domain at
|
// Registrar names the registrar Jarl can manage the domain at
|
||||||
@@ -77,6 +77,10 @@ type Option struct {
|
|||||||
Where string `json:"where"` // e.g. "view internal"
|
Where string `json:"where"` // e.g. "view internal"
|
||||||
Description string `json:"description,omitempty"`
|
Description string `json:"description,omitempty"`
|
||||||
Status string `json:"status,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.
|
// User is a user with formatted grants.
|
||||||
@@ -245,6 +249,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,
|
||||||
@@ -60,7 +63,7 @@ func DiffOps(from, to *model.State) []model.Op {
|
|||||||
n := to.Zones[k]
|
n := to.Zones[k]
|
||||||
s := n.SOA
|
s := n.SOA
|
||||||
in := ZoneInput{Name: &n.Name, ViewID: &n.ViewID, Type: &n.Type, InView: &n.InView, File: &n.File,
|
in := ZoneInput{Name: &n.Name, ViewID: &n.ViewID, Type: &n.Type, InView: &n.InView, File: &n.File,
|
||||||
DefaultTTL: &n.DefaultTTL, SerialPolicy: &n.SerialPolicy, DNSSECPolicy: &n.DNSSECPolicy, Comment: &n.Comment}
|
DefaultTTL: &n.DefaultTTL, SerialPolicy: &n.SerialPolicy, DNSSECPolicy: &n.DNSSECPolicy, Comment: &n.Comment, Disabled: &n.Disabled}
|
||||||
if n.Type == model.ZonePrimary {
|
if n.Type == model.ZonePrimary {
|
||||||
in.SOA = &SOAInput{MName: &s.MName, RName: &s.RName, Serial: &s.Serial, Refresh: &s.Refresh, Retry: &s.Retry, Expire: &s.Expire, Minimum: &s.Minimum}
|
in.SOA = &SOAInput{MName: &s.MName, RName: &s.RName, Serial: &s.Serial, Refresh: &s.Refresh, Retry: &s.Retry, Expire: &s.Expire, Minimum: &s.Minimum}
|
||||||
}
|
}
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -477,12 +477,12 @@ func (e *Engine) makePlan(ctx context.Context, base, work *model.State, user str
|
|||||||
changedPaths[f.Path] = true
|
changedPaths[f.Path] = true
|
||||||
}
|
}
|
||||||
for _, z := range work.SortedZones() {
|
for _, z := range work.SortedZones() {
|
||||||
if z.Type != model.ZonePrimary {
|
if z.Type != model.ZonePrimary || z.Disabled {
|
||||||
continue
|
continue // disabled zones aren't in named: nothing to reload
|
||||||
}
|
}
|
||||||
path := pl.out.ZoneFiles[z.ID]
|
path := pl.out.ZoneFiles[z.ID]
|
||||||
old := base.Zones[z.ID]
|
old := base.Zones[z.ID]
|
||||||
existedInNamed := old != nil && old.Type == model.ZonePrimary && baseOut.ZoneFiles[old.ID] == path
|
existedInNamed := old != nil && old.Type == model.ZonePrimary && !old.Disabled && baseOut.ZoneFiles[old.ID] == path
|
||||||
if !existedInNamed {
|
if !existedInNamed {
|
||||||
pl.newZones = true
|
pl.newZones = true
|
||||||
pl.verify[z.ID] = z.SOA.Serial
|
pl.verify[z.ID] = z.SOA.Serial
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
@@ -1054,7 +1054,7 @@ func (e *Engine) Resync(ctx context.Context, p *auth.Principal, opts ApplyOption
|
|||||||
pl.configChanged = true
|
pl.configChanged = true
|
||||||
pl.reloadZones = nil
|
pl.reloadZones = nil
|
||||||
for _, z := range work.SortedZones() {
|
for _, z := range work.SortedZones() {
|
||||||
if z.Type == model.ZonePrimary {
|
if z.Type == model.ZonePrimary && !z.Disabled {
|
||||||
pl.reloadZones = append(pl.reloadZones, z)
|
pl.reloadZones = append(pl.reloadZones, z)
|
||||||
pl.verify[z.ID] = z.SOA.Serial
|
pl.verify[z.ID] = z.SOA.Serial
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -145,6 +145,12 @@ func (x *expander) expand(req Request) ([]model.Op, error) {
|
|||||||
if n := len(x.st.ZoneRecords(z.ID)); n > 0 {
|
if n := len(x.st.ZoneRecords(z.ID)); n > 0 {
|
||||||
verb += fmt.Sprintf(" (with its %d records)", n)
|
verb += fmt.Sprintf(" (with its %d records)", n)
|
||||||
}
|
}
|
||||||
|
} else if d := zoneToggle(req, z); d != nil {
|
||||||
|
if *d {
|
||||||
|
x.warnings = append(x.warnings, disabledZoneEffect(x.st, z))
|
||||||
|
return []model.Op{x.versioned(req, z.Version, "disable zone "+zoneDesc(x.st, z))}, nil
|
||||||
|
}
|
||||||
|
return []model.Op{x.versioned(req, z.Version, "enable zone "+zoneDesc(x.st, z))}, nil
|
||||||
}
|
}
|
||||||
return []model.Op{x.versioned(req, z.Version, "zone "+verb+" "+zoneDesc(x.st, z))}, nil
|
return []model.Op{x.versioned(req, z.Version, "zone "+verb+" "+zoneDesc(x.st, z))}, nil
|
||||||
case OpRecordCreate:
|
case OpRecordCreate:
|
||||||
@@ -234,6 +240,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:
|
||||||
@@ -1019,3 +1035,31 @@ func RedactOps(ops []model.Op) []model.Op {
|
|||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// zoneToggle returns the new disabled state when a zone update only
|
||||||
|
// disables or enables the zone (nil otherwise).
|
||||||
|
func zoneToggle(req Request, z *model.Zone) *bool {
|
||||||
|
var in ZoneInput
|
||||||
|
if decode(req.Data, &in) != nil || in.Disabled == nil || *in.Disabled == z.Disabled {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
only := ZoneInput{Disabled: in.Disabled}
|
||||||
|
if string(mustJSON(in)) != string(mustJSON(only)) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return in.Disabled
|
||||||
|
}
|
||||||
|
|
||||||
|
// disabledZoneEffect says what clients get once named no longer loads a
|
||||||
|
// zone: in a view that offers recursion, answers from recursion (the
|
||||||
|
// internet's version of the zone, if it has one); elsewhere, REFUSED.
|
||||||
|
func disabledZoneEffect(st *model.State, z *model.Zone) string {
|
||||||
|
where := "this server"
|
||||||
|
if v := st.ViewName(z); v != "" {
|
||||||
|
where = "view " + v
|
||||||
|
}
|
||||||
|
if st.RecursionOn(z.ViewID) {
|
||||||
|
return fmt.Sprintf("once zone %s is disabled, clients of %s that may use recursion get its names from recursion: the internet's version of the zone, if there is one", dnsname.Display(z.Name), where)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("once zone %s is disabled, %s answers REFUSED for its names", dnsname.Display(z.Name), where)
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
@@ -106,6 +108,8 @@ type ZoneInput struct {
|
|||||||
// finished, off ("").
|
// finished, off ("").
|
||||||
DNSSECPolicy *string `json:"dnssec_policy,omitempty"`
|
DNSSECPolicy *string `json:"dnssec_policy,omitempty"`
|
||||||
Comment *string `json:"comment,omitempty"`
|
Comment *string `json:"comment,omitempty"`
|
||||||
|
// Disabled keeps the zone in Jarl but out of named's configuration.
|
||||||
|
Disabled *bool `json:"disabled,omitempty"`
|
||||||
|
|
||||||
// Create only: name servers for the apex NS records (default: the
|
// Create only: name servers for the apex NS records (default: the
|
||||||
// configured default name servers), additional records, and a zone to
|
// configured default name servers), additional records, and a zone to
|
||||||
@@ -244,3 +248,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)
|
||||||
}
|
}
|
||||||
@@ -238,6 +240,9 @@ func setZoneFields(st *model.State, z *model.Zone, in *ZoneInput, creating bool)
|
|||||||
if in.SerialPolicy != nil {
|
if in.SerialPolicy != nil {
|
||||||
z.SerialPolicy = strings.ToLower(strings.TrimSpace(*in.SerialPolicy))
|
z.SerialPolicy = strings.ToLower(strings.TrimSpace(*in.SerialPolicy))
|
||||||
}
|
}
|
||||||
|
if in.Disabled != nil {
|
||||||
|
z.Disabled = *in.Disabled
|
||||||
|
}
|
||||||
if in.DNSSECPolicy != nil {
|
if in.DNSSECPolicy != nil {
|
||||||
p := strings.Trim(strings.TrimSpace(*in.DNSSECPolicy), `"`)
|
p := strings.Trim(strings.TrimSpace(*in.DNSSECPolicy), `"`)
|
||||||
switch strings.ToLower(p) {
|
switch strings.ToLower(p) {
|
||||||
|
|||||||
@@ -101,8 +101,8 @@ func (e *Engine) guardSerials(ctx context.Context, base, work *model.State, bump
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
old := base.Zones[z.ID]
|
old := base.Zones[z.ID]
|
||||||
if old == nil || old.Type != model.ZonePrimary || old.ViewID != z.ViewID {
|
if old == nil || old.Type != model.ZonePrimary || old.ViewID != z.ViewID || old.Disabled || z.Disabled {
|
||||||
continue // new to named: nothing loaded to compare with
|
continue // new to named (or not in it): nothing loaded to compare with
|
||||||
}
|
}
|
||||||
path := render.ZoneFilePath(work, z, e.cfg.Paths)
|
path := render.ZoneFilePath(work, z, e.cfg.Paths)
|
||||||
if cur, ok, _ := readFile(path); ok && string(cur) == render.ZoneFile(work, z) {
|
if cur, ok, _ := readFile(path); ok && string(cur) == render.ZoneFile(work, z) {
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
package engine
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"jarl/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestZoneDisable(t *testing.T) {
|
||||||
|
env := newEnv(t)
|
||||||
|
setupZone(env)
|
||||||
|
z := env.state().ZonesNamed("example.com")[0]
|
||||||
|
confPath := filepath.Join(env.dir, "etc/named.conf")
|
||||||
|
zonePath := filepath.Join(env.dir, "zones/_default/example.com.zone")
|
||||||
|
|
||||||
|
res := env.submit(env.admin, NewRequest(OpZoneUpdate, z.ID, ZoneInput{Disabled: model.Ptr(true)}))
|
||||||
|
if res.Ops[0].Summary != "disable zone example.com" || len(res.Warnings) != 1 || !strings.Contains(res.Warnings[0], "recursion") {
|
||||||
|
t.Errorf("disable: %q, warnings %v", res.Ops[0].Summary, res.Warnings)
|
||||||
|
}
|
||||||
|
conf, _ := os.ReadFile(confPath)
|
||||||
|
if strings.Contains(string(conf), "zone \"example.com\" {") || !strings.Contains(string(conf), `// zone "example.com" is disabled in Jarl`) {
|
||||||
|
t.Errorf("named.conf with the zone disabled:\n%s", conf)
|
||||||
|
}
|
||||||
|
// Still edited, and its file kept up to date.
|
||||||
|
env.submit(env.admin, rec("example.com", "www", "A", "192.0.2.10"))
|
||||||
|
if f, err := os.ReadFile(zonePath); err != nil || !strings.Contains(string(f), "www\t\tIN\tA\t192.0.2.10") {
|
||||||
|
t.Errorf("zone file while disabled: %v\n%s", err, f)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enabling brings it back.
|
||||||
|
res = env.submit(env.admin, NewRequest(OpZoneUpdate, z.ID, ZoneInput{Disabled: model.Ptr(false)}))
|
||||||
|
if res.Ops[0].Summary != "enable zone example.com" {
|
||||||
|
t.Errorf("enable: %q", res.Ops[0].Summary)
|
||||||
|
}
|
||||||
|
if conf, _ := os.ReadFile(confPath); !strings.Contains(string(conf), "zone \"example.com\" {") {
|
||||||
|
t.Errorf("named.conf after enabling:\n%s", conf)
|
||||||
|
}
|
||||||
|
// Reverting a disable enables the zone again.
|
||||||
|
res = env.submit(env.admin, NewRequest(OpZoneUpdate, z.ID, ZoneInput{Disabled: model.Ptr(true)}))
|
||||||
|
rev, err := env.e.Revert(env.ctx, res.ChangesetID, env.admin)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := env.e.Apply(env.ctx, rev.ID, env.admin, ApplyOptions{}); err != nil || env.state().Zones[z.ID].Disabled {
|
||||||
|
t.Errorf("revert of a disable: %v, disabled=%v", err, env.state().Zones[z.ID].Disabled)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Signed zones can't be disabled.
|
||||||
|
env.submit(env.admin, NewRequest(OpZoneUpdate, z.ID, ZoneInput{DNSSECPolicy: model.Ptr("default")}))
|
||||||
|
if e := env.submitErr(env.admin, NewRequest(OpZoneUpdate, z.ID, ZoneInput{Disabled: model.Ptr(true)})); e.Kind != KindValidation || !strings.Contains(e.Message, "signed zone can't be disabled") {
|
||||||
|
t.Errorf("disabling a signed zone: %+v", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestZoneDisableInView(t *testing.T) {
|
||||||
|
env := newEnv(t)
|
||||||
|
setupZone(env)
|
||||||
|
env.submit(env.admin,
|
||||||
|
NewRequest(OpViewCreate, 0, ViewInput{Name: model.Ptr("internal"), MatchClients: &[]string{"10.0.0.0/8"}, AdoptZones: true}),
|
||||||
|
NewRequest(OpViewCreate, 0, ViewInput{Name: model.Ptr("external")}),
|
||||||
|
)
|
||||||
|
st := env.state()
|
||||||
|
ext := st.ViewByName("external")
|
||||||
|
env.submit(env.admin, NewRequest(OpZoneCreate, 0, ZoneInput{Name: model.Ptr("example.com"), ViewID: &ext.ID, Type: model.Ptr(model.ZoneInView), InView: model.Ptr("internal")}))
|
||||||
|
in := st.ZonesNamed("example.com")[0]
|
||||||
|
// The zone another view shares can't be disabled alone.
|
||||||
|
if e := env.submitErr(env.admin, NewRequest(OpZoneUpdate, in.ID, ZoneInput{Disabled: model.Ptr(true)})); e.Kind != KindValidation || !strings.Contains(e.Message, "is disabled, so this zone can't share it") {
|
||||||
|
t.Errorf("disabling a shared zone: %+v", e)
|
||||||
|
}
|
||||||
|
// Without recursion, the warning says REFUSED.
|
||||||
|
env.submit(env.admin, NewRequest(OpOptionCreate, 0, OptionInput{Scope: model.Ptr(model.ScopeView), ScopeID: &ext.ID, Name: model.Ptr("recursion"), Value: model.Ptr("no")}))
|
||||||
|
env.submit(env.admin, NewRequest(OpZoneCreate, 0, ZoneInput{Name: model.Ptr("public.example"), ViewID: &ext.ID}))
|
||||||
|
pub := env.state().FindZone("public.example", ext.ID)
|
||||||
|
res := env.submit(env.admin, NewRequest(OpZoneUpdate, pub.ID, ZoneInput{Disabled: model.Ptr(true)}))
|
||||||
|
if len(res.Warnings) != 1 || !strings.Contains(res.Warnings[0], "view external answers REFUSED") {
|
||||||
|
t.Errorf("warnings = %v", res.Warnings)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -128,6 +128,9 @@ type Zone struct {
|
|||||||
// signing is being removed safely (docs/dnssec.md).
|
// signing is being removed safely (docs/dnssec.md).
|
||||||
DNSSECPolicy string `json:"dnssec_policy,omitempty"`
|
DNSSECPolicy string `json:"dnssec_policy,omitempty"`
|
||||||
Comment string `json:"comment"`
|
Comment string `json:"comment"`
|
||||||
|
// Disabled zones stay in Jarl, records and all, but named doesn't
|
||||||
|
// load them: their zone statement is left out of named.conf.
|
||||||
|
Disabled bool `json:"disabled,omitempty"`
|
||||||
Meta
|
Meta
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -208,6 +211,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"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -36,6 +36,24 @@ func Compact(items []Item) string {
|
|||||||
// CompactStmt returns the statement on a single line with its semicolon.
|
// CompactStmt returns the statement on a single line with its semicolon.
|
||||||
func CompactStmt(s *Stmt) string { return Compact(s.Items) + ";" }
|
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.
|
// QuoteString returns s as a named.conf quoted string.
|
||||||
func QuoteString(s string) string {
|
func QuoteString(s string) string {
|
||||||
var sb strings.Builder
|
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 {
|
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})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -398,6 +398,12 @@ func renderZone(w *confWriter, s *model.State, z *model.Zone, p Paths, out *Outp
|
|||||||
if v := s.ViewName(z); v != "" {
|
if v := s.ViewName(z); v != "" {
|
||||||
label += " [view " + v + "]"
|
label += " [view " + v + "]"
|
||||||
}
|
}
|
||||||
|
if z.Disabled {
|
||||||
|
// Kept in Jarl (its zone file too, so enabling it is quick), not
|
||||||
|
// loaded by named.
|
||||||
|
w.write(fmt.Sprintf("%s// zone %s is disabled in Jarl: named doesn't load it.\n", tabs, namedconf.QuoteString(dnsname.Display(z.Name))))
|
||||||
|
return
|
||||||
|
}
|
||||||
w.begin("zone", z.ID, label)
|
w.begin("zone", z.ID, label)
|
||||||
if z.Comment != "" {
|
if z.Comment != "" {
|
||||||
w.write(indentLines(commentBlock("//", z.Comment), indent))
|
w.write(indentLines(commentBlock("//", z.Comment), indent))
|
||||||
|
|||||||
@@ -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" {
|
||||||
|
|||||||
@@ -157,7 +157,7 @@ func primaryZonesFor(st *model.State, p *auth.Principal) []acme.Zone {
|
|||||||
var out []acme.Zone
|
var out []acme.Zone
|
||||||
for _, z := range st.SortedZones() {
|
for _, z := range st.SortedZones() {
|
||||||
vn := st.ViewName(z)
|
vn := st.ViewName(z)
|
||||||
if z.Type == model.ZonePrimary && p.CanSeeZone(z.Name, vn) {
|
if z.Type == model.ZonePrimary && !z.Disabled && p.CanSeeZone(z.Name, vn) {
|
||||||
out = append(out, acme.Zone{Name: z.Name, View: vn})
|
out = append(out, acme.Zone{Name: z.Name, View: vn})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,13 +9,14 @@ import (
|
|||||||
"jarl/internal/dnsname"
|
"jarl/internal/dnsname"
|
||||||
"jarl/internal/engine"
|
"jarl/internal/engine"
|
||||||
"jarl/internal/model"
|
"jarl/internal/model"
|
||||||
|
"jarl/internal/namedconf"
|
||||||
"jarl/internal/render"
|
"jarl/internal/render"
|
||||||
"jarl/internal/rr"
|
"jarl/internal/rr"
|
||||||
"jarl/internal/version"
|
"jarl/internal/version"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (s *Server) presentZone(st *model.State, z *model.Zone, counts map[int64]recordCount) api.Zone {
|
func (s *Server) presentZone(st *model.State, z *model.Zone, counts map[int64]recordCount) api.Zone {
|
||||||
out := api.Zone{Zone: *z, Display: dnsname.Display(z.Name), View: st.ViewName(z), Records: counts[z.ID].all, Disabled: counts[z.ID].disabled}
|
out := api.Zone{Zone: *z, Display: dnsname.Display(z.Name), View: st.ViewName(z), Records: counts[z.ID].all, DisabledRecords: counts[z.ID].disabled}
|
||||||
out.ZoneFile = render.ZoneFilePath(st, z, s.eng.Config().Paths)
|
out.ZoneFile = render.ZoneFilePath(st, z, s.eng.Config().Paths)
|
||||||
if z.Type == model.ZonePrimary {
|
if z.Type == model.ZonePrimary {
|
||||||
out.RName = dnsname.EmailFromMailbox(z.SOA.RName)
|
out.RName = dnsname.EmailFromMailbox(z.SOA.RName)
|
||||||
@@ -100,6 +101,9 @@ func presentOption(st *model.State, o *model.Option) api.Option {
|
|||||||
if c, ok := catalog.Default().Lookup(scope, o.Name); ok {
|
if c, ok := catalog.Default().Lookup(scope, o.Name); ok {
|
||||||
out.Description, out.Status = c.Description, c.Status
|
out.Description, out.Status = c.Description, c.Status
|
||||||
}
|
}
|
||||||
|
if f := namedconf.FormatValue(o.Name, o.Value); f != o.Value {
|
||||||
|
out.Formatted = f
|
||||||
|
}
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
+8
-11
@@ -96,23 +96,20 @@ func Flatten(body []byte) (*Snapshot, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Delta returns the change from prev to cur. When named restarted (its
|
// Delta returns the change from prev to cur. When named restarted (its
|
||||||
// boot time changed or a counter went down), the counters started again
|
// boot time changed), the counters started again from zero, so the
|
||||||
// from zero, so the current values are the change.
|
// current values are the change. Without a restart, a counter can still
|
||||||
|
// go down: the per-view ones (cache, upstream times, validation) are
|
||||||
|
// summed over named's views, and a reconfig that adds, removes or
|
||||||
|
// recreates views changes that sum. Such a counter counts 0 for the
|
||||||
|
// minute; it must never make every counter count from zero, which would
|
||||||
|
// add everything since named started (millions of "queries" in a minute).
|
||||||
func Delta(prev, cur *Snapshot) map[string]int64 {
|
func Delta(prev, cur *Snapshot) map[string]int64 {
|
||||||
out := map[string]int64{}
|
out := map[string]int64{}
|
||||||
restarted := prev.BootTime != cur.BootTime
|
restarted := prev.BootTime != cur.BootTime
|
||||||
if !restarted {
|
|
||||||
for k, n := range cur.Values {
|
|
||||||
if n < prev.Values[k] {
|
|
||||||
restarted = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for k, n := range cur.Values {
|
for k, n := range cur.Values {
|
||||||
d := n
|
d := n
|
||||||
if !restarted {
|
if !restarted {
|
||||||
d = n - prev.Values[k]
|
d = max(n-prev.Values[k], 0)
|
||||||
}
|
}
|
||||||
if d != 0 || k == "queries" {
|
if d != 0 || k == "queries" {
|
||||||
out[k] = d
|
out[k] = d
|
||||||
|
|||||||
@@ -47,9 +47,15 @@ func TestFlattenAndDelta(t *testing.T) {
|
|||||||
if d := Delta(s2, s3); d["queries"] != 3 || d["qtype:A"] != 3 {
|
if d := Delta(s2, s3); d["queries"] != 3 || d["qtype:A"] != 3 {
|
||||||
t.Errorf("restart delta = %v", d)
|
t.Errorf("restart delta = %v", d)
|
||||||
}
|
}
|
||||||
// A counter going down also means a restart.
|
// A counter going down without a restart (views changed in a
|
||||||
s4 := &Snapshot{BootTime: s2.BootTime, Values: map[string]int64{"queries": 2}}
|
// reconfig) counts 0, and doesn't make the others count from zero.
|
||||||
if d := Delta(s2, s4); d["queries"] != 2 {
|
s4 := &Snapshot{BootTime: s2.BootTime, Values: map[string]int64{}}
|
||||||
t.Errorf("reset delta = %v", d)
|
for k, v := range s2.Values {
|
||||||
|
s4.Values[k] = v
|
||||||
|
}
|
||||||
|
s4.Values["queries"] += 4
|
||||||
|
s4.Values["cache:hits"] = 1 // was 4: a view's cache counters are gone
|
||||||
|
if d := Delta(s2, s4); d["queries"] != 4 || d["cache:hits"] != 0 || len(d) != 1 {
|
||||||
|
t.Errorf("delta with a counter going down = %v", d)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -92,14 +92,14 @@ func loadState(ctx context.Context, e execer) (*model.State, error) {
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
rows, err = e.QueryContext(ctx, `SELECT id, name, view_id, type, in_view, file, soa_mname, soa_rname, soa_serial, soa_refresh, soa_retry, soa_expire, soa_minimum, default_ttl, serial_policy, dnssec_policy, comment, version, created_at, updated_at, created_by, updated_by FROM zones`)
|
rows, err = e.QueryContext(ctx, `SELECT id, name, view_id, type, in_view, file, soa_mname, soa_rname, soa_serial, soa_refresh, soa_retry, soa_expire, soa_minimum, default_ttl, serial_policy, dnssec_policy, comment, disabled, version, created_at, updated_at, created_by, updated_by FROM zones`)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
z := &model.Zone{}
|
z := &model.Zone{}
|
||||||
var ca, ua string
|
var ca, ua string
|
||||||
if err := rows.Scan(&z.ID, &z.Name, &z.ViewID, &z.Type, &z.InView, &z.File, &z.SOA.MName, &z.SOA.RName, &z.SOA.Serial, &z.SOA.Refresh, &z.SOA.Retry, &z.SOA.Expire, &z.SOA.Minimum, &z.DefaultTTL, &z.SerialPolicy, &z.DNSSECPolicy, &z.Comment, &z.Version, &ca, &ua, &z.CreatedBy, &z.UpdatedBy); err != nil {
|
if err := rows.Scan(&z.ID, &z.Name, &z.ViewID, &z.Type, &z.InView, &z.File, &z.SOA.MName, &z.SOA.RName, &z.SOA.Serial, &z.SOA.Refresh, &z.SOA.Retry, &z.SOA.Expire, &z.SOA.Minimum, &z.DefaultTTL, &z.SerialPolicy, &z.DNSSECPolicy, &z.Comment, &z.Disabled, &z.Version, &ca, &ua, &z.CreatedBy, &z.UpdatedBy); err != nil {
|
||||||
rows.Close()
|
rows.Close()
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -247,8 +247,8 @@ func saveState(ctx context.Context, e execer, old, new *model.State) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := diffMap(old.Zones, new.Zones, func(z *model.Zone) error {
|
if err := diffMap(old.Zones, new.Zones, func(z *model.Zone) error {
|
||||||
_, err := e.ExecContext(ctx, `INSERT OR REPLACE INTO zones (id, name, view_id, type, in_view, file, soa_mname, soa_rname, soa_serial, soa_refresh, soa_retry, soa_expire, soa_minimum, default_ttl, serial_policy, dnssec_policy, comment, version, created_at, updated_at, created_by, updated_by) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
|
_, err := e.ExecContext(ctx, `INSERT OR REPLACE INTO zones (id, name, view_id, type, in_view, file, soa_mname, soa_rname, soa_serial, soa_refresh, soa_retry, soa_expire, soa_minimum, default_ttl, serial_policy, dnssec_policy, comment, disabled, version, created_at, updated_at, created_by, updated_by) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
|
||||||
z.ID, z.Name, z.ViewID, z.Type, z.InView, z.File, z.SOA.MName, z.SOA.RName, z.SOA.Serial, z.SOA.Refresh, z.SOA.Retry, z.SOA.Expire, z.SOA.Minimum, z.DefaultTTL, z.SerialPolicy, z.DNSSECPolicy, z.Comment, z.Version, ts(z.CreatedAt), ts(z.UpdatedAt), z.CreatedBy, z.UpdatedBy)
|
z.ID, z.Name, z.ViewID, z.Type, z.InView, z.File, z.SOA.MName, z.SOA.RName, z.SOA.Serial, z.SOA.Refresh, z.SOA.Retry, z.SOA.Expire, z.SOA.Minimum, z.DefaultTTL, z.SerialPolicy, z.DNSSECPolicy, z.Comment, z.Disabled, z.Version, ts(z.CreatedAt), ts(z.UpdatedAt), z.CreatedBy, z.UpdatedBy)
|
||||||
return err
|
return err
|
||||||
}, func(id int64) error { return del(ctx, e, "zones", id) }); err != nil {
|
}, func(id int64) error { return del(ctx, e, "zones", id) }); err != nil {
|
||||||
return err
|
return err
|
||||||
|
|||||||
@@ -300,6 +300,10 @@ CREATE TABLE blocklist_status (
|
|||||||
sha256 TEXT NOT NULL DEFAULT '',
|
sha256 TEXT NOT NULL DEFAULT '',
|
||||||
error TEXT NOT NULL DEFAULT ''
|
error TEXT NOT NULL DEFAULT ''
|
||||||
);
|
);
|
||||||
|
`,
|
||||||
|
// 7: disabled zones, kept but not loaded by named.
|
||||||
|
`
|
||||||
|
ALTER TABLE zones ADD COLUMN disabled INTEGER NOT NULL DEFAULT 0;
|
||||||
`,
|
`,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -48,6 +48,9 @@ func (c *checker) checkZones() {
|
|||||||
if err := dnsname.CheckZoneName(z.Name); err != nil {
|
if err := dnsname.CheckZoneName(z.Name); err != nil {
|
||||||
c.zoneProblem(z, Error, "zone_name_invalid", "invalid zone name: %v", err)
|
c.zoneProblem(z, Error, "zone_name_invalid", "invalid zone name: %v", err)
|
||||||
}
|
}
|
||||||
|
if z.Disabled && z.Signed() {
|
||||||
|
c.zoneProblem(z, Error, "zone_disabled_signed", "a signed zone can't be disabled: validating resolvers would lose its signed answers while the parent may still have its DS record; turn signing off first")
|
||||||
|
}
|
||||||
k := zkey{z.ViewID, z.Name}
|
k := zkey{z.ViewID, z.Name}
|
||||||
if seen[k] {
|
if seen[k] {
|
||||||
c.zoneProblem(z, Error, "zone_duplicate", "the zone is defined more than once in the same view")
|
c.zoneProblem(z, Error, "zone_duplicate", "the zone is defined more than once in the same view")
|
||||||
@@ -133,6 +136,8 @@ func (c *checker) checkInView(z *model.Zone, viewPos map[int64]int) {
|
|||||||
c.zoneProblem(z, Error, "zone_inview_invalid", "view %q has no zone %s to share", target.Name, dnsname.Display(z.Name))
|
c.zoneProblem(z, Error, "zone_inview_invalid", "view %q has no zone %s to share", target.Name, dnsname.Display(z.Name))
|
||||||
} else if src.Type != model.ZonePrimary && src.Type != model.ZoneSecondary {
|
} else if src.Type != model.ZonePrimary && src.Type != model.ZoneSecondary {
|
||||||
c.zoneProblem(z, Error, "zone_inview_invalid", "in-view can only share primary or secondary zones, but %s in view %q is %s", dnsname.Display(z.Name), target.Name, src.Type)
|
c.zoneProblem(z, Error, "zone_inview_invalid", "in-view can only share primary or secondary zones, but %s in view %q is %s", dnsname.Display(z.Name), target.Name, src.Type)
|
||||||
|
} else if src.Disabled && !z.Disabled {
|
||||||
|
c.zoneProblem(z, Error, "zone_inview_disabled", "%s in view %q is disabled, so this zone can't share it; disable this one too, or enable that one", dnsname.Display(z.Name), target.Name)
|
||||||
}
|
}
|
||||||
for _, o := range c.s.ScopedOptions(model.ScopeZone, z.ID) {
|
for _, o := range c.s.ScopedOptions(model.ScopeZone, z.ID) {
|
||||||
if o.Name != "forward" && o.Name != "forwarders" {
|
if o.Name != "forward" && o.Name != "forwarders" {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
// Jarl web UI: router, layout and login.
|
// 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 { 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 totp from "./totp.js";
|
||||||
import * as dashboard from "./pages/dashboard.js";
|
import * as dashboard from "./pages/dashboard.js";
|
||||||
import * as zones from "./pages/zones.js";
|
import * as zones from "./pages/zones.js";
|
||||||
@@ -207,7 +207,28 @@ function renderBar() {
|
|||||||
mount(barEl, n > 0 ? h("div", { class: "pending-bar" },
|
mount(barEl, n > 0 ? h("div", { class: "pending-bar" },
|
||||||
h("strong", null, plural(n, "pending change")),
|
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("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() {
|
async function logout() {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// Blocking: your block/allow rules and blocklists (docs/blocking.md).
|
// 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 { get, change, describeResult, draftQuery, can, api } from "../api.js";
|
||||||
import { rerender } from "../app.js";
|
import { rerender } from "../app.js";
|
||||||
import { pendingBadge } from "./zones.js";
|
import { pendingBadge } from "./zones.js";
|
||||||
@@ -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;
|
||||||
@@ -35,14 +35,20 @@ export async function render(el, params, query) {
|
|||||||
if (!name) return;
|
if (!name) return;
|
||||||
try {
|
try {
|
||||||
const res = await get("/blocking/check", { name });
|
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)); }
|
} 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,27 +57,71 @@ 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 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.");
|
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,
|
return h("div", null,
|
||||||
h("ul", { class: "plain" }, res.map((m) => h("li", 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.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.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.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))),
|
: 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);
|
||||||
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);
|
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) {
|
async function addRule(name, action, exact, views, comment) {
|
||||||
try {
|
try {
|
||||||
@@ -82,17 +132,74 @@ 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).
|
||||||
if (!views.length) return { el: null, value: () => [] };
|
function viewPicker(views, autoViews, selected = []) {
|
||||||
const boxes = views.map((v) => checkbox(v.name, (selected || []).includes(v.name)));
|
if (!views.length) return { el: null, picker: null, value: () => [] };
|
||||||
const hint = autoViews && autoViews.length ? "none ticked: " + autoViews.join(", ") + " (every view that offers recursion)" : "none ticked: every view that offers recursion";
|
const boxes = views.map((v) => checkbox(v.name, selected.includes(v.name)));
|
||||||
return {
|
const value = () => views.filter((v, i) => boxes[i].input.checked).map((v) => v.name);
|
||||||
el: h("div", { class: "f" }, h("label", null, "Views"), h("div", { class: "actions" }, boxes, h("span", { class: "muted small" }, hint))),
|
const summary = h("summary");
|
||||||
value: () => views.filter((v, i) => boxes[i].input.checked).map((v) => v.name),
|
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) {
|
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(", ");
|
||||||
@@ -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")] },
|
{ 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,
|
views.length ? { label: "Views", render: viewsCell } : null,
|
||||||
{ label: "Comment", render: (r) => h("span", { class: "muted" }, r.comment || "") },
|
{ 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 : "" }));
|
].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: "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)) },
|
{ 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,
|
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", 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,
|
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" : "" }),
|
].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";
|
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 + ". ",
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { h, mount, table, field, input, select, textarea, toast, errorBox, badge, confirmDialog, lines } from "../ui.js";
|
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 { rerender } from "../app.js";
|
||||||
import { emailOf } from "./zones.js";
|
import { emailOf } from "./zones.js";
|
||||||
import { amlContext, suggestions } from "../aml.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." };
|
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") {
|
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
|
// Statements such as allow-transfer and listen-on take an address
|
||||||
// match list after optional parameters: suggest names at the cursor.
|
// match list after optional parameters: suggest names at the cursor.
|
||||||
const takesList = stmt && /address_match_element/.test(stmt.grammar || "");
|
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() };
|
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.
|
// optionsEditor renders the options of one scope.
|
||||||
export async function optionsEditor(el, { scope, scope_id, catalogScope, title, intro }) {
|
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 byName = Object.fromEntries(cat.map((s) => [s.name, s]));
|
||||||
const err = h("div");
|
const err = h("div");
|
||||||
const listEl = h("div");
|
const listEl = h("div");
|
||||||
@@ -49,7 +82,7 @@ export async function optionsEditor(el, { scope, scope_id, catalogScope, title,
|
|||||||
const draw = () => {
|
const draw = () => {
|
||||||
mount(listEl, table([
|
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: "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: "Description", render: (o) => h("span", { class: "muted small" }, o.description || ""), tdClass: "small" },
|
||||||
{ label: "", render: (o) => editing === o.id ? "" : h("span", null,
|
{ label: "", render: (o) => editing === o.id ? "" : h("span", null,
|
||||||
h("button", { class: "small", onclick: () => { editing = o.id; draw(); } }, "Edit"), " ",
|
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." }));
|
], opts, { empty: "No options set; BIND's defaults apply." }));
|
||||||
};
|
};
|
||||||
const editRow = (o) => {
|
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");
|
const e2 = h("div");
|
||||||
return h("div", null, w.el, w.hint ? h("div", { class: "hint" }, w.hint) : null,
|
return h("div", null, w.el, w.hint ? h("div", { class: "hint" }, w.hint) : null,
|
||||||
h("div", { class: "actions", style: { marginTop: "6px" } },
|
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));
|
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));
|
||||||
|
|||||||
@@ -36,7 +36,13 @@ export async function render(el, params, query) {
|
|||||||
zone.dnssec_policy ? [h("a", { href: "#/zones/" + id + "/dnssec" }, badge(zone.dnssec_policy === "insecure" ? "DNSSEC: turning off" : "DNSSEC", zone.dnssec_policy === "insecure" ? "warn" : "ok")), " "] : null,
|
zone.dnssec_policy ? [h("a", { href: "#/zones/" + id + "/dnssec" }, badge(zone.dnssec_policy === "insecure" ? "DNSSEC: turning off" : "DNSSEC", zone.dnssec_policy === "insecure" ? "warn" : "ok")), " "] : null,
|
||||||
zone.zone_file ? h("span", { class: "mono small" }, zone.zone_file) : null)),
|
zone.zone_file ? h("span", { class: "mono small" }, zone.zone_file) : null)),
|
||||||
h("div", { class: "actions" },
|
h("div", { class: "actions" },
|
||||||
|
zone.disabled
|
||||||
|
? h("button", { onclick: () => toggleZone(zone) }, "Enable zone")
|
||||||
|
: h("button", { disabled: !!zone.dnssec_policy, title: zone.dnssec_policy ? "A signed zone can't be disabled: turn signing off first" : "Stop serving the zone, keeping it in Jarl", onclick: () => toggleZone(zone) }, "Disable zone"),
|
||||||
|
" ",
|
||||||
h("button", { class: "danger", onclick: () => deleteZone(zone) }, "Delete zone")));
|
h("button", { class: "danger", onclick: () => deleteZone(zone) }, "Delete zone")));
|
||||||
|
const banner = zone.disabled ? h("div", { class: "msg warn" },
|
||||||
|
h("strong", null, "This zone is disabled: "), "named doesn't load it. It is kept here, with its records, and can still be edited; enabling it serves it as it is then.") : null;
|
||||||
const tabBar = h("div", { class: "tabs" }, tabs.map(([k, label]) => h("a", { href: "#/zones/" + id + "/" + k, class: k === tab ? "active" : "" }, label)));
|
const tabBar = h("div", { class: "tabs" }, tabs.map(([k, label]) => h("a", { href: "#/zones/" + id + "/" + k, class: k === tab ? "active" : "" }, label)));
|
||||||
// The header and tabs show at once. Below them, switching tabs keeps the
|
// The header and tabs show at once. Below them, switching tabs keeps the
|
||||||
// previous tab's content (faded) until the new one is loaded, rather
|
// previous tab's content (faded) until the new one is loaded, rather
|
||||||
@@ -47,7 +53,7 @@ export async function render(el, params, query) {
|
|||||||
holder.classList.add("stale");
|
holder.classList.add("stale");
|
||||||
body.className = "zone-body";
|
body.className = "zone-body";
|
||||||
body.dataset.zone = id;
|
body.dataset.zone = id;
|
||||||
mount(el, head, tabBar, holder);
|
mount(el, head, banner, tabBar, holder);
|
||||||
if (tab === "records") await recordsTab(body, zone, query);
|
if (tab === "records") await recordsTab(body, zone, query);
|
||||||
else if (tab === "settings") await settingsTab(body, zone);
|
else if (tab === "settings") await settingsTab(body, zone);
|
||||||
else if (tab === "options") await optionsEditor(body, { scope: "zone", scope_id: zone.id, catalogScope: "zone:" + zone.type, title: "Zone options" });
|
else if (tab === "options") await optionsEditor(body, { scope: "zone", scope_id: zone.id, catalogScope: "zone:" + zone.type, title: "Zone options" });
|
||||||
@@ -453,11 +459,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) &&
|
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")));
|
(!stateFilter.value || !!r.disabled === (stateFilter.value === "off")));
|
||||||
mount(list, table([
|
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: "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: "Type", render: (r) => h("strong", null, r.type) },
|
||||||
{ label: "Data", render: (r) => r.data, tdClass: "data" },
|
{ 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,
|
{ label: "", render: (r) => r.pending === "delete" ? "" : h("span", null,
|
||||||
h("button", { class: "small", title: "Edit", onclick: () => edit(r) }, "Edit"), " ",
|
h("button", { class: "small", title: "Edit", onclick: () => edit(r) }, "Edit"), " ",
|
||||||
r.disabled
|
r.disabled
|
||||||
@@ -652,3 +659,19 @@ function diffViewOf(r) {
|
|||||||
if (!res) return h("div", { class: "muted" }, (r.warnings || []).join("; ") || "Nothing to import.");
|
if (!res) return h("div", { class: "muted" }, (r.warnings || []).join("; ") || "Nothing to import.");
|
||||||
return h("div", null, (r.warnings || []).length ? h("div", { class: "msg warn" }, r.warnings.join("; ")) : null, diffView(res.diff));
|
return h("div", null, (r.warnings || []).length ? h("div", { class: "msg warn" }, r.warnings.join("; ")) : null, diffView(res.diff));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// toggleZone disables or enables a zone (asking first when disabling).
|
||||||
|
async function toggleZone(zone) {
|
||||||
|
if (!zone.disabled) {
|
||||||
|
const where = zone.view ? "view " + zone.view : "this server";
|
||||||
|
const ok = await confirmDialog("Disable zone " + zone.display_name,
|
||||||
|
"named stops serving " + zone.display_name + " in " + where + ". Its names are then answered by recursion where the view offers it (the internet's version of the zone, if any), and REFUSED elsewhere. The zone, its records and options stay in Jarl.", "Disable zone", "danger");
|
||||||
|
if (!ok) return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const r = await change("PATCH", "/zones/" + zone.id, { disabled: !zone.disabled });
|
||||||
|
toast(describeResult(r));
|
||||||
|
(r.warnings || []).forEach((w) => toast(w, "warn", 10000));
|
||||||
|
rerender();
|
||||||
|
} catch (e) { toast(e.message, "err", 8000); }
|
||||||
|
}
|
||||||
@@ -23,13 +23,13 @@ export async function render(el, params, query) {
|
|||||||
lastList.zones = hash;
|
lastList.zones = hash;
|
||||||
const rows = zones.filter((z) => (!q || z.name.includes(q)) && (!viewSel.value || z.view === viewSel.value) && (!typeSel.value || z.type === typeSel.value));
|
const rows = zones.filter((z) => (!q || z.name.includes(q)) && (!viewSel.value || z.view === viewSel.value) && (!typeSel.value || z.type === typeSel.value));
|
||||||
mount(list, table([
|
mount(list, table([
|
||||||
{ label: "Zone", render: (z) => [z.pending === "delete" ? h("span", null, z.display_name) : h("a", { href: "#/zones/" + z.id }, z.display_name), dnssecLock(z), z.pending ? [" ", pendingBadge(z.pending)] : null], tdClass: "name" },
|
{ label: "Zone", render: (z) => [z.pending === "delete" ? h("span", null, z.display_name) : h("a", { href: "#/zones/" + z.id }, z.display_name), dnssecLock(z), z.disabled ? [" ", badge("disabled", "off")] : null, z.pending ? [" ", pendingBadge(z.pending)] : null], tdClass: "name" },
|
||||||
views.length ? { label: "View", render: (z) => viewLink(viewsById.get(z.view_id), z.view, hash) } : null,
|
views.length ? { label: "View", render: (z) => viewLink(viewsById.get(z.view_id), z.view, hash) } : null,
|
||||||
{ label: "Type", render: (z) => badge(z.type, z.type === "primary" ? "accent" : "") },
|
{ label: "Type", render: (z) => badge(z.type, z.type === "primary" ? "accent" : "") },
|
||||||
{ label: "Records", render: (z) => z.type === "primary" ? [String(z.record_count), z.disabled_count ? h("span", { class: "muted small", title: z.disabled_count + " disabled (left out of the zone)" }, " (" + z.disabled_count + " off)") : null] : "", class: "right", tdClass: "right" },
|
{ label: "Records", render: (z) => z.type === "primary" ? [String(z.record_count), z.disabled_count ? h("span", { class: "muted small", title: z.disabled_count + " disabled (left out of the zone)" }, " (" + z.disabled_count + " off)") : null] : "", class: "right", tdClass: "right" },
|
||||||
{ label: "Serial", render: (z) => z.type === "primary" ? String(z.soa.serial) : "", tdClass: "mono" },
|
{ label: "Serial", render: (z) => z.type === "primary" ? String(z.soa.serial) : "", tdClass: "mono" },
|
||||||
{ label: "Comment", render: (z) => h("span", { class: "muted" }, z.comment || "") },
|
{ label: "Comment", render: (z) => h("span", { class: "muted" }, z.comment || "") },
|
||||||
].filter(Boolean), rows, { empty: zones.length ? "No zones match." : "No zones yet. Create one, or import your existing BIND configuration with 'jarld import'.", rowClass: (z) => z.pending ? "pending-" + z.pending : "" }));
|
].filter(Boolean), rows, { empty: zones.length ? "No zones match." : "No zones yet. Create one, or import your existing BIND configuration with 'jarld import'.", rowClass: (z) => [z.pending ? "pending-" + z.pending : "", z.disabled ? "disabled" : ""].filter(Boolean).join(" ") }));
|
||||||
};
|
};
|
||||||
filter.addEventListener("input", draw);
|
filter.addEventListener("input", draw);
|
||||||
viewSel.addEventListener("change", draw);
|
viewSel.addEventListener("change", draw);
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
--panel: #ffffff;
|
--panel: #ffffff;
|
||||||
--text: #1d2330;
|
--text: #1d2330;
|
||||||
--muted: #5f6b7d;
|
--muted: #5f6b7d;
|
||||||
|
--hatch: rgba(95, 107, 125, 0.13);
|
||||||
--border: #d9dee6;
|
--border: #d9dee6;
|
||||||
--accent: #2f5fb3;
|
--accent: #2f5fb3;
|
||||||
--accent-text: #ffffff;
|
--accent-text: #ffffff;
|
||||||
@@ -34,6 +35,7 @@
|
|||||||
--panel: #1c2027;
|
--panel: #1c2027;
|
||||||
--text: #e3e7ee;
|
--text: #e3e7ee;
|
||||||
--muted: #9aa5b5;
|
--muted: #9aa5b5;
|
||||||
|
--hatch: rgba(154, 165, 181, 0.12);
|
||||||
--border: #2f3540;
|
--border: #2f3540;
|
||||||
--accent: #6d9be8;
|
--accent: #6d9be8;
|
||||||
--accent-text: #0f1520;
|
--accent-text: #0f1520;
|
||||||
@@ -58,7 +60,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
:root[data-theme="dark"] {
|
: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;
|
--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;
|
--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;
|
--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;
|
padding: 8px 16px;
|
||||||
}
|
}
|
||||||
.pending-bar strong { color: var(--warn); }
|
.pending-bar strong { color: var(--warn); }
|
||||||
|
.pending-bar .apply-now { margin-left: auto; }
|
||||||
/* The review/live switch in the header. */
|
/* 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 { 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); }
|
.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-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.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); }
|
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; }
|
button.toggle { min-width: 5.2em; }
|
||||||
.empty { color: var(--muted); padding: 18px; text-align: center; }
|
.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 > label { padding-top: 6px; color: var(--muted); }
|
||||||
.form-grid .help { grid-column: 2; margin-top: -4px; color: var(--muted); font-size: 12px; }
|
.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, .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 { display: flex; gap: 6px; flex-wrap: wrap; align-items: flex-end; }
|
||||||
.inline-form .f { display: flex; flex-direction: column; gap: 2px; }
|
.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; }
|
.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; }
|
.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; }
|
||||||
|
.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; }
|
ul.plain li { padding: 3px 0; }
|
||||||
.query-details { margin: 8px 0; }
|
.query-details { margin: 8px 0; }
|
||||||
@@ -410,6 +410,22 @@ run "wipe the child" 0 $J zone dnssec wipe sub.adbraun.test --view external --ye
|
|||||||
run "delete the child" 0 $J zone delete sub.adbraun.test --view external --yes
|
run "delete the child" 0 $J zone delete sub.adbraun.test --view external --yes
|
||||||
run "remove the delegation" 0 $J record delete adbraun.test sub NS --view external --yes
|
run "remove the delegation" 0 $J record delete adbraun.test sub NS --view external --yes
|
||||||
|
|
||||||
|
# Disabled zones: kept in Jarl, not loaded by named.
|
||||||
|
run "zone for disabling" 0 $J zone create parked.test --view internal --ns ns1.adbraun.test.
|
||||||
|
run "record in it" 0 $J record add parked.test www A 192.0.2.70 --view internal
|
||||||
|
expect_dig "parked www served" "192.0.2.70" www.parked.test A
|
||||||
|
run "zone disable" 0 $J zone disable parked.test --view internal
|
||||||
|
contains "recursion"
|
||||||
|
expect_dig "disabled zone not served" "" www.parked.test A
|
||||||
|
run "named.conf says it's disabled" 0 grep -q 'zone "parked.test" is disabled in Jarl' $E/etc-jarl/named.conf
|
||||||
|
run "zone list marks it" 0 $J zone list
|
||||||
|
contains "parked.test.*primary (disabled)"
|
||||||
|
run "editing a disabled zone works (no reload of a zone named lacks)" 0 $J record add parked.test api A 192.0.2.71 --view internal
|
||||||
|
run "zone enable" 0 $J zone enable parked.test --view internal
|
||||||
|
expect_dig "enabled zone served again" "192.0.2.70" www.parked.test A
|
||||||
|
expect_dig "with the change made while disabled" "192.0.2.71" api.parked.test A
|
||||||
|
run "delete it" 0 $J zone delete parked.test --view internal --yes
|
||||||
|
|
||||||
# Blocking (docs/blocking.md). It only changes answers to recursive
|
# Blocking (docs/blocking.md). It only changes answers to recursive
|
||||||
# queries, so these ask with recursion and look at which policy zone (if
|
# queries, so these ask with recursion and look at which policy zone (if
|
||||||
# any) the answer came from.
|
# any) the answer came from.
|
||||||
@@ -444,6 +460,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