Compare commits
15
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ecffc2d5c1 | ||
|
|
9d9ae37367 | ||
|
|
384f6b3060 | ||
|
|
c696d02513 | ||
|
|
7be0762f48 | ||
|
|
b097e10bef | ||
|
|
73d97ecc0a | ||
|
|
33060f86ce | ||
|
|
89663e366b | ||
|
|
59baccd636 | ||
|
|
02c393a6bb | ||
|
|
d4756444ac | ||
|
|
67f14b030f | ||
|
|
ad8eb3fc3d | ||
|
|
27d1f9e538 |
No files matched your search
+108
-3
@@ -8,6 +8,35 @@ All notable changes to Jarl are documented here. The format follows
|
|||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
|
- View keys: every view gets a TSIG key, `jarl-view-NAME`, at the front of
|
||||||
|
its match-clients, so a query signed with it lands in that view from any
|
||||||
|
address. Jarl signs its delegation check's queries to this server's own
|
||||||
|
addresses with the key of the zone's view: before, they got whichever
|
||||||
|
view matched the server's own address (usually internal), so the check
|
||||||
|
could report the wrong serial for the external view. `jarl view key
|
||||||
|
VIEW` shows a key, for `dig -k` (docs/views.md). The first apply after
|
||||||
|
upgrading adds the keys to named.conf and keys.conf.
|
||||||
|
- Server statistics: named's memory (resident, and its own count) and
|
||||||
|
CPU use, its cache size, sockets, and recursive and TCP clients against
|
||||||
|
their limits; the host's memory and load; free space where zone files,
|
||||||
|
logs and Jarl's data are. Memory and CPU are kept over time like the
|
||||||
|
other statistics. Statistics page → Server, `jarl stats server`, API
|
||||||
|
`/stats/server`. Read from named's statistics channel, `rndc status` and
|
||||||
|
/proc, with no extra privileges.
|
||||||
|
- CLI: the record commands that change records take several views,
|
||||||
|
`--view internal,external`, or `--view all`, and make the change in each,
|
||||||
|
as one changeset (all views or none).
|
||||||
|
- Blocking can be paused until resumed, like Pi-hole's "indefinitely":
|
||||||
|
`jarl block pause indefinitely`, **Until I resume** on the Blocking page,
|
||||||
|
`{"indefinitely": true}` in the API.
|
||||||
|
- 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 +55,89 @@ 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: each view has its own page, like a zone: Settings (match lists,
|
||||||
|
comment, colour), Options and Zones tabs (with **New zone in VIEW**),
|
||||||
|
and Delete. The Views page lists them in order, with the arrows that
|
||||||
|
reorder them; view links everywhere open the view's page.
|
||||||
|
- Web UI: A and AAAA records have a **+ CNAME** button that fills in the
|
||||||
|
add form with a CNAME pointing to the record's name, leaving only the
|
||||||
|
alias's name to type.
|
||||||
|
- Options are kept in one fixed order, in named.conf, the web UI and the
|
||||||
|
CLI: grouped by topic (files, listening, access control, recursion and
|
||||||
|
forwarding, DNSSEC, transfers and notify, then the rest), alphabetical
|
||||||
|
within a group. The same option is now in the same place in every view
|
||||||
|
and zone. The first apply after upgrading reorders named.conf (a diff
|
||||||
|
that only moves lines). Top-level statements keep their order.
|
||||||
|
- Options that end in a list (`allow-transfer`, `forwarders`,
|
||||||
|
`listen-on`, ...) can be given without their braces, in the web UI, CLI
|
||||||
|
and API: `10.0.10.53; key xfer` becomes `{ 10.0.10.53; key xfer; }`.
|
||||||
|
Before, only some server options (those edited one element per line)
|
||||||
|
added them.
|
||||||
|
- The dashboard's configuration checks include named-checkconf's own
|
||||||
|
warnings about the running configuration (such as `both "recursion no;"
|
||||||
|
and "allow-recursion" active`), which before only showed while applying.
|
||||||
|
The CLI now prints them after an apply too.
|
||||||
|
- Statistics page: cards and tiles fill their rows instead of leaving one
|
||||||
|
alone beside an empty space, and the queries and failed answers charts
|
||||||
|
have the same margins, so their time axes line up.
|
||||||
|
- Dashboard: options have their own tile; "0 TSIG keys · 22 options" read
|
||||||
|
as if the options belonged to the keys.
|
||||||
|
- Web UI times use the 24-hour clock.
|
||||||
|
- Diffs of named.conf say which block each change is in, after the `@@`
|
||||||
|
line numbers (`@@ -50,6 +49,8 @@ view "internal" {`), like git does
|
||||||
|
with function names. Moving an option from the server's options into a
|
||||||
|
view no longer looks like two unrelated edits.
|
||||||
|
- 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
|
||||||
|
|
||||||
|
- Under the shipped systemd unit, jarld couldn't list the host's
|
||||||
|
addresses (it needs a netlink socket, which `RestrictAddressFamilies`
|
||||||
|
didn't allow), so the name server checks didn't recognize this server's
|
||||||
|
own addresses. The Name servers tab then queried them like another
|
||||||
|
server's, and the delegation check didn't sign those queries with the
|
||||||
|
view's key: with views, both got the view matching the server's own
|
||||||
|
address (usually internal) and reported a wrong serial. The unit now
|
||||||
|
allows `AF_NETLINK`, and without it jarld reads the addresses from
|
||||||
|
/proc.
|
||||||
|
- 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.3): 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), disabling zones, server statistics and view keys. 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)).
|
||||||
|
|
||||||
|
|||||||
+99
-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,95 @@ names below it (docs/blocking.md).`}
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd.AddCommand(addRule("block"), addRule("allow"), list, del, check)
|
pause := &cobra.Command{
|
||||||
|
Use: "pause [DURATION | indefinitely]",
|
||||||
|
Short: "Let every name through for a while (default 10m, at most 24h) or until resumed",
|
||||||
|
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. "indefinitely" pauses until 'jarl block resume'.
|
||||||
|
|
||||||
|
jarl block pause # 10 minutes
|
||||||
|
jarl block pause 1h
|
||||||
|
jarl block pause indefinitely
|
||||||
|
jarl block resume`,
|
||||||
|
Args: cobra.MaximumNArgs(1),
|
||||||
|
ValidArgs: []string{"indefinitely", "5m", "10m", "30m", "1h", "24h"},
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
if len(args) == 1 && args[0] == "indefinitely" {
|
||||||
|
c, err := newClient()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
resp, err := c.Mutate(ctx(), "POST", "/blocking/pause", nil, map[string]any{"indefinitely": true})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := report(resp); err != nil || flagQuiet || !resp.Applied {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
fmt.Println("Blocking is paused until you resume it ('jarl block resume').")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
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, or indefinitely)", 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"`
|
||||||
|
PausedIndefinitely bool `json:"paused_indefinitely"`
|
||||||
|
}
|
||||||
|
if c.Get(ctx(), "/blocking/status", nil, &st) != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if st.PausedIndefinitely {
|
||||||
|
return "Blocking is paused until you resume it ('jarl block resume').\n"
|
||||||
|
}
|
||||||
|
if st.PausedUntil == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return "Blocking is paused until " + st.PausedUntil.Local().Format("2006-01-02 15:04") + " ('jarl block resume' ends it).\n"
|
||||||
|
}
|
||||||
+39
-1
@@ -201,7 +201,45 @@ func viewCmd() *cobra.Command {
|
|||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
cmd.AddCommand(list, create, update, del, order, color)
|
var keyNamed bool
|
||||||
|
key := &cobra.Command{
|
||||||
|
Use: "key VIEW",
|
||||||
|
Short: "Show the TSIG key that selects a view, to query it from anywhere",
|
||||||
|
Long: `Every view has a TSIG key (jarl-view-NAME) at the front of its
|
||||||
|
match-clients: a query signed with it lands in that view, whatever address
|
||||||
|
it comes from. Jarl signs its own checks of this server with it; use it to
|
||||||
|
see what a view answers:
|
||||||
|
|
||||||
|
jarl view key external # shows a ready dig -y command
|
||||||
|
jarl view key external --named-conf > external.key
|
||||||
|
dig -k external.key @SERVER adbraun.lol SOA
|
||||||
|
|
||||||
|
Showing it needs tsig:write and is recorded in the audit log.`,
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
c, err := newClient()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var k struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Algorithm string `json:"algorithm"`
|
||||||
|
Secret string `json:"secret"`
|
||||||
|
}
|
||||||
|
if err := c.Get(ctx(), "/views/"+url.PathEscape(args[0])+"/key", nil, &k); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if keyNamed {
|
||||||
|
fmt.Printf("key %q {\n\talgorithm %s;\n\tsecret %q;\n};\n", k.Name, k.Algorithm, k.Secret)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return output(k, func(w io.Writer) {
|
||||||
|
fmt.Fprintf(w, "Key:\t%s\nAlgorithm:\t%s\nSecret:\t%s\ndig:\tdig -y %s:%s:%s @SERVER NAME\n", k.Name, k.Algorithm, k.Secret, k.Algorithm, k.Name, k.Secret)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
}
|
||||||
|
key.Flags().BoolVar(&keyNamed, "named-conf", false, "print a key statement (for dig -k FILE)")
|
||||||
|
cmd.AddCommand(list, create, update, del, order, color, key)
|
||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -530,6 +530,18 @@ func report(resp *api.MutationResponse) error {
|
|||||||
json.Unmarshal(resp.Problems, &introduced)
|
json.Unmarshal(resp.Problems, &introduced)
|
||||||
}
|
}
|
||||||
printProblems(os.Stderr, introduced)
|
printProblems(os.Stderr, introduced)
|
||||||
|
// named-checkconf's own warnings about the new configuration.
|
||||||
|
if r != nil && (resp.Applied || resp.DryRun) {
|
||||||
|
for _, m := range r.Check {
|
||||||
|
if !m.Error && !strings.HasPrefix(m.Text, "loaded serial") && m.Text != "OK" {
|
||||||
|
where := ""
|
||||||
|
if m.Object != "" {
|
||||||
|
where = " (" + m.Object + ")"
|
||||||
|
}
|
||||||
|
fmt.Fprintf(os.Stderr, "warning: named-checkconf: %s%s\n", m.Text, where)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
for _, warn := range resp.Warnings {
|
for _, warn := range resp.Warnings {
|
||||||
fmt.Fprintln(os.Stderr, "warning:", warn)
|
fmt.Fprintln(os.Stderr, "warning:", warn)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"jarl/internal/api"
|
||||||
|
"jarl/internal/client"
|
||||||
|
)
|
||||||
|
|
||||||
|
// zoneViews expands a --view value for a record command: "" or one view
|
||||||
|
// as it is, "a,b" to those views, and "all" to every view that has the
|
||||||
|
// zone.
|
||||||
|
func zoneViews(c *client.Client, zone, view string) ([]string, error) {
|
||||||
|
if view != "all" && !strings.Contains(view, ",") {
|
||||||
|
return []string{view}, nil
|
||||||
|
}
|
||||||
|
if view != "all" {
|
||||||
|
var out []string
|
||||||
|
for _, v := range strings.Split(view, ",") {
|
||||||
|
if v = strings.TrimSpace(v); v != "" {
|
||||||
|
out = append(out, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
var zones []api.Zone
|
||||||
|
if err := c.Get(ctx(), "/zones", nil, &zones); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
want := strings.ToLower(strings.TrimSuffix(zone, "."))
|
||||||
|
var out []string
|
||||||
|
for _, z := range zones {
|
||||||
|
if strings.ToLower(strings.TrimSuffix(z.Display, ".")) == want && z.View != "" {
|
||||||
|
out = append(out, z.View)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(out) == 0 {
|
||||||
|
return nil, fmt.Errorf("zone %s is in no view", zone)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// perView runs a record command once per view. With several views, the
|
||||||
|
// changes are staged into one changeset and applied together, so either
|
||||||
|
// every view gets the change or none does (with --changeset, they are
|
||||||
|
// staged there).
|
||||||
|
//
|
||||||
|
// do returns the server's response, or nil when there was nothing to do.
|
||||||
|
func perView(c *client.Client, views []string, description string, do func(c *client.Client, view string) (*api.MutationResponse, error)) error {
|
||||||
|
if len(views) == 1 || c.Changeset > 0 {
|
||||||
|
for _, v := range views {
|
||||||
|
resp, err := do(c, v)
|
||||||
|
if err != nil {
|
||||||
|
if len(views) > 1 {
|
||||||
|
err = fmt.Errorf("view %s: %w", v, err)
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if resp != nil {
|
||||||
|
if err := report(resp); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var cs api.Changeset
|
||||||
|
if err := c.Do(ctx(), "POST", "/changesets", nil, map[string]any{"description": description + " (views " + strings.Join(views, ", ") + ")"}, &cs); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
discard := func() { c.Do(ctx(), "POST", fmt.Sprintf("/changesets/%d/discard", cs.ID), nil, nil, nil) }
|
||||||
|
staging := *c
|
||||||
|
staging.Changeset, staging.DryRun = cs.ID, false
|
||||||
|
staged := 0
|
||||||
|
for _, v := range views {
|
||||||
|
resp, err := do(&staging, v)
|
||||||
|
if err != nil {
|
||||||
|
discard()
|
||||||
|
fmt.Fprintf(os.Stderr, "view %s failed, so nothing was changed in any view\n", v)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if resp != nil {
|
||||||
|
staged++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if staged == 0 {
|
||||||
|
discard()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var resp api.MutationResponse
|
||||||
|
err := c.Do(ctx(), "POST", fmt.Sprintf("/changesets/%d/apply", cs.ID), nil, map[string]any{"force": c.Force, "dry_run": c.DryRun}, &resp)
|
||||||
|
if err != nil || c.DryRun {
|
||||||
|
discard()
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return report(&resp)
|
||||||
|
}
|
||||||
+85
-4
@@ -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"
|
||||||
}
|
}
|
||||||
@@ -321,7 +325,61 @@ export the data.`,
|
|||||||
}
|
}
|
||||||
sinceFlag(breakdown, &breakdownSince, "24h", sinceHelp)
|
sinceFlag(breakdown, &breakdownSince, "24h", sinceHelp)
|
||||||
|
|
||||||
cmd.AddCommand(status, summary, top, timeline, heatmap, breakdown)
|
server := &cobra.Command{
|
||||||
|
Use: "server",
|
||||||
|
Short: "Show named's memory, CPU, cache and clients, the host's memory and load, and free disk space",
|
||||||
|
Long: `Server statistics, sampled once a minute: named's memory and CPU use,
|
||||||
|
cache size, recursive and TCP clients against their limits, the host's
|
||||||
|
memory and load, and the space left where zone files, logs and Jarl's
|
||||||
|
data are. Memory and CPU over time: 'jarl stats timeline --key
|
||||||
|
g:named:rss,g:named:cpu' (averages; CPU in tenths of a percent).`,
|
||||||
|
Args: cobra.NoArgs,
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
c, err := newClient()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var resp struct {
|
||||||
|
Server *stats.Server `json:"server"`
|
||||||
|
Named map[string]string `json:"named,omitempty"`
|
||||||
|
NamedError string `json:"named_error,omitempty"`
|
||||||
|
Disks []stats.Disk `json:"disks"`
|
||||||
|
}
|
||||||
|
if err := c.Get(ctx(), "/stats/server", nil, &resp); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return output(resp, func(w io.Writer) {
|
||||||
|
s := resp.Server
|
||||||
|
if s == nil {
|
||||||
|
fmt.Fprintln(w, "No sample yet:\tthe first is taken a minute after jarl starts")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fmt.Fprintf(w, "named:\t%s, up since %s, reconfigured %s\n", s.Version, isoLocal(s.BootTime), isoLocal(s.ConfigTime))
|
||||||
|
if s.PID > 0 {
|
||||||
|
fmt.Fprintf(w, "Memory:\t%s resident (named counts %s), %d threads, PID %d\n", sizeStr(s.RSS), sizeStr(s.MemInUse), s.Threads, s.PID)
|
||||||
|
fmt.Fprintf(w, "CPU:\t%.1f%% of one CPU over the last minute\n", s.CPUPercent)
|
||||||
|
} else {
|
||||||
|
fmt.Fprintf(w, "Memory:\tnamed counts %s (its process isn't visible to jarld)\n", sizeStr(s.MemInUse))
|
||||||
|
}
|
||||||
|
fmt.Fprintf(w, "Cache:\t%d RRsets, %s\n", s.CacheRRsets, sizeStr(s.CacheMem))
|
||||||
|
if n := resp.Named; n != nil {
|
||||||
|
fmt.Fprintf(w, "Recursive clients:\t%s (now/soft limit/limit), most at once %d\n", n["recursive_clients"], s.RecursionHighwater)
|
||||||
|
fmt.Fprintf(w, "TCP clients:\t%s (now/limit), most at once %d\n", n["tcp_clients"], s.TCPHighwater)
|
||||||
|
} else if resp.NamedError != "" {
|
||||||
|
fmt.Fprintf(w, "Clients:\tunknown: %s\n", resp.NamedError)
|
||||||
|
}
|
||||||
|
fmt.Fprintf(w, "Sockets:\t%d UDP, %d TCP\n", s.SocketsUDP, s.SocketsTCP)
|
||||||
|
if h := s.Host; h != nil && h.MemTotal > 0 {
|
||||||
|
fmt.Fprintf(w, "Host:\t%s of %s memory in use, load %v, %d CPUs, up %s\n", sizeStr(h.MemTotal-h.MemAvailable), sizeStr(h.MemTotal), h.Load, h.CPUs, (time.Duration(h.Uptime) * time.Second).String())
|
||||||
|
}
|
||||||
|
for _, d := range resp.Disks {
|
||||||
|
fmt.Fprintf(w, "Disk:\t%s free of %s for %s (%s)\n", sizeStr(d.Free), sizeStr(d.Total), strings.Join(d.Holds, ", "), d.Path)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd.AddCommand(status, summary, top, timeline, heatmap, breakdown, server)
|
||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -394,3 +452,26 @@ func sparkline(vals []int64) string {
|
|||||||
}
|
}
|
||||||
return sb.String()
|
return sb.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// sizeStr formats a size in binary units.
|
||||||
|
func sizeStr(n int64) string {
|
||||||
|
units := []string{"B", "KiB", "MiB", "GiB", "TiB"}
|
||||||
|
f, i := float64(n), 0
|
||||||
|
for f >= 1024 && i < len(units)-1 {
|
||||||
|
f /= 1024
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
if i == 0 || f >= 100 {
|
||||||
|
return fmt.Sprintf("%.0f %s", f, units[i])
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%.1f %s", f, units[i])
|
||||||
|
}
|
||||||
|
|
||||||
|
// isoLocal shows a time named reports (RFC 3339) in local time.
|
||||||
|
func isoLocal(s string) string {
|
||||||
|
t, err := time.Parse(time.RFC3339Nano, s)
|
||||||
|
if err != nil {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return t.Local().Format("2006-01-02 15:04")
|
||||||
|
}
|
||||||
+97
-37
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -406,6 +449,10 @@ func jsonUnmarshal(b []byte, v any) error { return json.Unmarshal(b, v) }
|
|||||||
|
|
||||||
// ---- records
|
// ---- records
|
||||||
|
|
||||||
|
// viewsFlagHelp describes --view of the record commands that change
|
||||||
|
// records.
|
||||||
|
const viewsFlagHelp = "view of the zone; several (internal,external) or \"all\" make the same change in each, together"
|
||||||
|
|
||||||
func recordCmd() *cobra.Command {
|
func recordCmd() *cobra.Command {
|
||||||
cmd := &cobra.Command{Use: "record", Aliases: []string{"records", "rr"}, Short: "Manage records in primary zones"}
|
cmd := &cobra.Command{Use: "record", Aliases: []string{"records", "rr"}, Short: "Manage records in primary zones"}
|
||||||
var view, name, typ, search, comment, ttlArg string
|
var view, name, typ, search, comment, ttlArg string
|
||||||
@@ -488,15 +535,17 @@ and splitting are automatic).
|
|||||||
if ptr {
|
if ptr {
|
||||||
body["ptr"] = true
|
body["ptr"] = true
|
||||||
}
|
}
|
||||||
path, qv := zonePath(args[0], view)
|
views, err := zoneViews(c, args[0], view)
|
||||||
resp, err := c.Mutate(ctx(), "POST", path+"/records", qv, body)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return report(resp)
|
return perView(c, views, "add "+args[1]+" "+strings.ToUpper(args[2]), func(c *client.Client, view string) (*api.MutationResponse, error) {
|
||||||
|
path, qv := zonePath(args[0], view)
|
||||||
|
return c.Mutate(ctx(), "POST", path+"/records", qv, body)
|
||||||
|
})
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
add.Flags().StringVar(&view, "view", "", "view of the zone")
|
add.Flags().StringVar(&view, "view", "", viewsFlagHelp)
|
||||||
add.Flags().StringVar(&ttlArg, "ttl", "", "TTL in seconds (default: the RRset's or the zone's)")
|
add.Flags().StringVar(&ttlArg, "ttl", "", "TTL in seconds (default: the RRset's or the zone's)")
|
||||||
add.Flags().StringVar(&comment, "comment", "", "comment")
|
add.Flags().StringVar(&comment, "comment", "", "comment")
|
||||||
add.Flags().BoolVar(&ptr, "ptr", false, "also add the PTR record (A/AAAA)")
|
add.Flags().BoolVar(&ptr, "ptr", false, "also add the PTR record (A/AAAA)")
|
||||||
@@ -581,6 +630,11 @@ or one record by ID.
|
|||||||
if len(args) == 3 && !confirm(fmt.Sprintf("Delete all %s records of %s?", strings.ToUpper(args[2]), args[1]), yes) {
|
if len(args) == 3 && !confirm(fmt.Sprintf("Delete all %s records of %s?", strings.ToUpper(args[2]), args[1]), yes) {
|
||||||
return fmt.Errorf("canceled")
|
return fmt.Errorf("canceled")
|
||||||
}
|
}
|
||||||
|
views, err := zoneViews(c, args[0], view)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return perView(c, views, "delete "+args[1]+" "+strings.ToUpper(args[2]), func(c *client.Client, view string) (*api.MutationResponse, error) {
|
||||||
path, qv := zonePath(args[0], view)
|
path, qv := zonePath(args[0], view)
|
||||||
qv.Set("name", args[1])
|
qv.Set("name", args[1])
|
||||||
qv.Set("type", args[2])
|
qv.Set("type", args[2])
|
||||||
@@ -593,14 +647,11 @@ or one record by ID.
|
|||||||
if ptr {
|
if ptr {
|
||||||
qv.Set("ptr", "1")
|
qv.Set("ptr", "1")
|
||||||
}
|
}
|
||||||
resp, err := c.Mutate(ctx(), "DELETE", path+"/records", qv, nil)
|
return c.Mutate(ctx(), "DELETE", path+"/records", qv, nil)
|
||||||
if err != nil {
|
})
|
||||||
return err
|
|
||||||
}
|
|
||||||
return report(resp)
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
del.Flags().StringVar(&view, "view", "", "view of the zone")
|
del.Flags().StringVar(&view, "view", "", viewsFlagHelp)
|
||||||
del.Flags().Int64Var(&id, "id", 0, "delete the record with this ID")
|
del.Flags().Int64Var(&id, "id", 0, "delete the record with this ID")
|
||||||
del.Flags().BoolVar(&missingOK, "missing-ok", false, "succeed when nothing matches")
|
del.Flags().BoolVar(&missingOK, "missing-ok", false, "succeed when nothing matches")
|
||||||
del.Flags().BoolVar(&ptr, "ptr", false, "also delete the matching PTR record (A/AAAA)")
|
del.Flags().BoolVar(&ptr, "ptr", false, "also delete the matching PTR record (A/AAAA)")
|
||||||
@@ -627,15 +678,17 @@ spaces.
|
|||||||
if comment != "" {
|
if comment != "" {
|
||||||
body["comment"] = comment
|
body["comment"] = comment
|
||||||
}
|
}
|
||||||
path, qv := zonePath(args[0], view)
|
views, err := zoneViews(c, args[0], view)
|
||||||
resp, err := c.Mutate(ctx(), "PUT", path+"/rrsets/"+urlPathEscape(args[1])+"/"+urlPathEscape(args[2]), qv, body)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return report(resp)
|
return perView(c, views, "set "+args[1]+" "+strings.ToUpper(args[2]), func(c *client.Client, view string) (*api.MutationResponse, error) {
|
||||||
|
path, qv := zonePath(args[0], view)
|
||||||
|
return c.Mutate(ctx(), "PUT", path+"/rrsets/"+urlPathEscape(args[1])+"/"+urlPathEscape(args[2]), qv, body)
|
||||||
|
})
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
set.Flags().StringVar(&view, "view", "", "view of the zone")
|
set.Flags().StringVar(&view, "view", "", viewsFlagHelp)
|
||||||
set.Flags().StringVar(&ttlArg, "ttl", "", "TTL for the RRset")
|
set.Flags().StringVar(&ttlArg, "ttl", "", "TTL for the RRset")
|
||||||
set.Flags().StringVar(&comment, "comment", "", "comment for new records")
|
set.Flags().StringVar(&comment, "comment", "", "comment for new records")
|
||||||
|
|
||||||
@@ -685,30 +738,37 @@ with DATA), or one record by ID.
|
|||||||
if len(args) < 3 {
|
if len(args) < 3 {
|
||||||
return fmt.Errorf("give ZONE NAME TYPE [DATA], or --id")
|
return fmt.Errorf("give ZONE NAME TYPE [DATA], or --id")
|
||||||
}
|
}
|
||||||
path, zq := zonePath(args[0], tview)
|
views, err := zoneViews(c, args[0], tview)
|
||||||
for k, v := range zq {
|
|
||||||
qv[k] = v
|
|
||||||
}
|
|
||||||
qv.Set("name", args[1])
|
|
||||||
qv.Set("type", args[2])
|
|
||||||
if len(args) > 3 {
|
|
||||||
qv.Set("data", strings.Join(args[3:], " "))
|
|
||||||
}
|
|
||||||
if tmissing {
|
|
||||||
qv.Set("missing_ok", "1")
|
|
||||||
}
|
|
||||||
resp, err := c.Mutate(ctx(), "POST", path+"/records/"+verb, qv, nil)
|
|
||||||
if err != nil && tmissing && strings.HasSuffix(err.Error(), "nothing to do") {
|
|
||||||
fmt.Println("nothing to do")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return report(resp)
|
return perView(c, views, verb+" "+args[1]+" "+strings.ToUpper(args[2]), func(c *client.Client, view string) (*api.MutationResponse, error) {
|
||||||
|
path, zq := zonePath(args[0], view)
|
||||||
|
vq := q()
|
||||||
|
for k, v := range qv {
|
||||||
|
vq[k] = v
|
||||||
|
}
|
||||||
|
for k, v := range zq {
|
||||||
|
vq[k] = v
|
||||||
|
}
|
||||||
|
vq.Set("name", args[1])
|
||||||
|
vq.Set("type", args[2])
|
||||||
|
if len(args) > 3 {
|
||||||
|
vq.Set("data", strings.Join(args[3:], " "))
|
||||||
|
}
|
||||||
|
if tmissing {
|
||||||
|
vq.Set("missing_ok", "1")
|
||||||
|
}
|
||||||
|
resp, err := c.Mutate(ctx(), "POST", path+"/records/"+verb, vq, nil)
|
||||||
|
if err != nil && tmissing && strings.HasSuffix(err.Error(), "nothing to do") {
|
||||||
|
fmt.Println("nothing to do")
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return resp, err
|
||||||
|
})
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
c.Flags().StringVar(&tview, "view", "", "view of the zone")
|
c.Flags().StringVar(&tview, "view", "", viewsFlagHelp)
|
||||||
c.Flags().Int64Var(&tid, "id", 0, verb+" the record with this ID")
|
c.Flags().Int64Var(&tid, "id", 0, verb+" the record with this ID")
|
||||||
c.Flags().BoolVar(&tmissing, "missing-ok", false, "succeed when nothing matches or the records are already "+verb+"d")
|
c.Flags().BoolVar(&tmissing, "missing-ok", false, "succeed when nothing matches or the records are already "+verb+"d")
|
||||||
c.Flags().BoolVar(&tptr, "ptr", false, "also "+verb+" the matching PTR record (A/AAAA)")
|
c.Flags().BoolVar(&tptr, "ptr", false, "also "+verb+" the matching PTR record (A/AAAA)")
|
||||||
|
|||||||
+5
-1
@@ -302,7 +302,11 @@ func renderCmd() *cobra.Command {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
paths := render.Paths{ConfigDir: filepath.Join(out, "config"), ZoneDir: filepath.Join(out, "zones"), CacheDir: filepath.Join(out, "cache")}
|
paths := render.Paths{ConfigDir: filepath.Join(out, "config"), ZoneDir: filepath.Join(out, "zones"), CacheDir: filepath.Join(out, "cache")}
|
||||||
o := render.Render(st, paths, renderExtras(a.cfg))
|
x := renderExtras(a.cfg)
|
||||||
|
if x.ViewKeySeed, err = engine.ViewKeySeed(context.Background(), a.store); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
o := render.Render(st, paths, x)
|
||||||
for _, f := range o.Files {
|
for _, f := range o.Files {
|
||||||
if err := os.MkdirAll(filepath.Dir(f.Path), 0o750); err != nil {
|
if err := os.MkdirAll(filepath.Dir(f.Path), 0o750); err != nil {
|
||||||
return err
|
return err
|
||||||
|
|||||||
+10
-5
@@ -148,7 +148,7 @@ func statsOptions(cfg *config.Config) stats.Options {
|
|||||||
sc := cfg.Stats
|
sc := cfg.Stats
|
||||||
o := stats.Options{QueryLog: sc.QueryLog, LogFile: sc.LogFile, StatisticsChannel: sc.StatisticsChannel, TopN: sc.TopN,
|
o := stats.Options{QueryLog: sc.QueryLog, LogFile: sc.LogFile, StatisticsChannel: sc.StatisticsChannel, TopN: sc.TopN,
|
||||||
RetainMinute: time.Duration(sc.Retention.Minute), RetainHour: time.Duration(sc.Retention.Hour), RetainDay: time.Duration(sc.Retention.Day),
|
RetainMinute: time.Duration(sc.Retention.Minute), RetainHour: time.Duration(sc.Retention.Hour), RetainDay: time.Duration(sc.Retention.Day),
|
||||||
Anonymize: sc.AnonymizeClients}
|
Anonymize: sc.AnonymizeClients, MainConfig: cfg.BIND.MainConfig}
|
||||||
for _, p := range sc.ExcludeClients {
|
for _, p := range sc.ExcludeClients {
|
||||||
if pf, err := config.ParsePrefix(p); err == nil {
|
if pf, err := config.ParsePrefix(p); err == nil {
|
||||||
o.Exclude = append(o.Exclude, pf.Masked())
|
o.Exclude = append(o.Exclude, pf.Masked())
|
||||||
@@ -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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -55,7 +55,9 @@ RestrictRealtime=yes
|
|||||||
RestrictSUIDSGID=yes
|
RestrictSUIDSGID=yes
|
||||||
LockPersonality=yes
|
LockPersonality=yes
|
||||||
MemoryDenyWriteExecute=yes
|
MemoryDenyWriteExecute=yes
|
||||||
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
|
# AF_NETLINK lets jarld list this host's addresses, so its name server
|
||||||
|
# checks know which addresses are its own.
|
||||||
|
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 AF_NETLINK
|
||||||
SystemCallArchitectures=native
|
SystemCallArchitectures=native
|
||||||
# @system-service includes @chown, which jarld needs to give files to group bind.
|
# @system-service includes @chown, which jarld needs to give files to group bind.
|
||||||
SystemCallFilter=@system-service
|
SystemCallFilter=@system-service
|
||||||
|
|||||||
+13
-5
@@ -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) |
|
||||||
@@ -386,6 +386,7 @@ Disabling a record that is already disabled is a 409; with `missing_ok=1`
|
|||||||
| GET / PATCH / DELETE | `/views/{id}` | DELETE `?cascade=1` also deletes the view's zones |
|
| GET / PATCH / DELETE | `/views/{id}` | DELETE `?cascade=1` also deletes the view's zones |
|
||||||
| POST | `/views/order` | `{"ids": [3, 1, 2]}` |
|
| POST | `/views/order` | `{"ids": [3, 1, 2]}` |
|
||||||
| PUT | `/views/{id}/color` | `{"color": "green"}`; `""` for the default |
|
| PUT | `/views/{id}/color` | `{"color": "green"}`; `""` for the default |
|
||||||
|
| GET | `/views/{id}/key` | `{name, algorithm, secret}`: the TSIG key that selects the view (docs/views.md); needs `tsig:write`, audited |
|
||||||
|
|
||||||
`adopt_zones` moves zones that are not in a view into the new view. named
|
`adopt_zones` moves zones that are not in a view into the new view. named
|
||||||
requires every zone to be in a view as soon as one view exists.
|
requires every zone to be in a view as soon as one view exists.
|
||||||
@@ -426,7 +427,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, paused_indefinitely}` (null and false when not paused) |
|
||||||
|
| POST | `/blocking/pause` | `{"minutes": 15}` (1 to 1440): let every name through until then; jarld resumes by itself. `{"indefinitely": true}`: until resumed |
|
||||||
|
| 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`.
|
||||||
@@ -441,7 +445,9 @@ listing with `?changeset=` marks them `pending`.
|
|||||||
| GET / PATCH | `/settings` | `{"nameservers": [...], "rname": "...", "default_ttl": 3600, "refresh": ..., "retry": ..., "expire": ..., "negative_ttl": ..., "serial_policy": "date"}` |
|
| GET / PATCH | `/settings` | `{"nameservers": [...], "rname": "...", "default_ttl": 3600, "refresh": ..., "retry": ..., "expire": ..., "negative_ttl": ..., "serial_policy": "date"}` |
|
||||||
|
|
||||||
Option values are named.conf syntax without the final semicolon. They are
|
Option values are named.conf syntax without the final semicolon. They are
|
||||||
checked against the BIND 9.20 grammar for that scope.
|
checked against the BIND 9.20 grammar for that scope. `position` only
|
||||||
|
orders top-level statements; the options of a block are always listed and
|
||||||
|
written in Jarl's fixed order (docs/concepts.md).
|
||||||
|
|
||||||
### Catalogs and search
|
### Catalogs and search
|
||||||
|
|
||||||
@@ -484,6 +490,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"}`, `{"indefinitely": true}`, or `{}` 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 +500,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` or `blocking_paused_indefinitely` (with `blocking:read`, while paused). `problems` include named-checkconf's warnings about the running configuration (`named_warning`) |
|
||||||
| 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,8 +515,9 @@ 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/server` | | `{server: {rss, mem_in_use, cpu_percent, threads, pid, cache_rrsets, cache_mem, sockets_udp, sockets_tcp, recursion_highwater, tcp_highwater, version, boot_time, config_time, host: {mem_total, mem_available, load, cpus, uptime}}, named: {recursive_clients, tcp_clients, zones, cpus, worker_threads}, disks: [{path, holds, total, free}]}`; gauges over time: `/stats/timeline?key=g:named:rss` (averages, -1 without a sample) |
|
||||||
| 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}]` |
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
|
|||||||
+26
-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,34 @@ 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. A pause can
|
||||||
|
also last until someone resumes blocking (like Pi-hole's "indefinitely").
|
||||||
|
|
||||||
|
```sh
|
||||||
|
jarl block pause # 10 minutes; or: jarl block pause 1h
|
||||||
|
jarl block pause indefinitely
|
||||||
|
jarl block resume # end it now
|
||||||
|
```
|
||||||
|
|
||||||
|
In the web UI, **Pause blocking** is at the top of the Blocking page (with
|
||||||
|
**Until I resume** among the lengths), 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.
|
||||||
|
|||||||
+37
-2
@@ -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
|
||||||
@@ -264,8 +273,20 @@ jarl record disable --id 1234 # by ID; same for enab
|
|||||||
|
|
||||||
# reverse zones: name records by address; the host name needs no final dot
|
# reverse zones: name records by address; the host name needs no final dot
|
||||||
jarl record add 0.10.10.in-addr.arpa 10.10.0.20 PTR nas.adbraun.lol
|
jarl record add 0.10.10.in-addr.arpa 10.10.0.20 PTR nas.adbraun.lol
|
||||||
|
|
||||||
|
# a zone in several views (split horizon): the same change in each
|
||||||
|
jarl record add adbraun.lol mx2 A 203.0.113.5 --view internal,external
|
||||||
|
jarl record delete adbraun.lol old A --view all -y
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**Several views.** When a zone exists in more than one view, the record
|
||||||
|
commands that change records (`add`, `set`, `delete`, `disable`,
|
||||||
|
`enable`) take `--view a,b` or `--view all` (every view the zone is in).
|
||||||
|
The changes are applied together as one changeset: if one view refuses
|
||||||
|
(the zone isn't in it, a CNAME conflict, ...), no view is changed. With
|
||||||
|
`--changeset` they are staged there instead. Keep split-horizon data that
|
||||||
|
differs per view (an internal address, say) to one view at a time.
|
||||||
|
|
||||||
Names are relative to the zone (`@` is the apex) unless they end with a
|
Names are relative to the zone (`@` is the apex) unless they end with a
|
||||||
dot. Data uses zone file syntax, except that TXT/SPF take plain text: Jarl
|
dot. Data uses zone file syntax, except that TXT/SPF take plain text: Jarl
|
||||||
adds the quotes and splits long values into 255-byte strings. In reverse zones the name can be the address
|
adds the quotes and splits long values into 255-byte strings. In reverse zones the name can be the address
|
||||||
@@ -347,6 +368,7 @@ jarl view order internal external
|
|||||||
jarl view update external --match-clients "!homelab,any"
|
jarl view update external --match-clients "!homelab,any"
|
||||||
jarl view delete old [--cascade]
|
jarl view delete old [--cascade]
|
||||||
jarl view color internal green # web UI colour; "default" resets it
|
jarl view color internal green # web UI colour; "default" resets it
|
||||||
|
jarl view key external [--named-conf] # the key that selects the view (dig -k), docs/views.md
|
||||||
|
|
||||||
jarl acl create homelab 192.168.1.0/24 10.10.0.0/16 localhost
|
jarl acl create homelab 192.168.1.0/24 10.10.0.0/16 localhost
|
||||||
jarl acl update homelab --add 10.20.0.0/16 --remove 10.10.0.0/16
|
jarl acl update homelab --add 10.20.0.0/16 --remove 10.10.0.0/16
|
||||||
@@ -362,12 +384,21 @@ jarl option list [--view V | --zone Z | --toplevel | --all]
|
|||||||
jarl option set recursion yes
|
jarl option set recursion yes
|
||||||
jarl option set allow-recursion '{ homelab; localhost; }'
|
jarl option set allow-recursion '{ homelab; localhost; }'
|
||||||
jarl option set forwarders '{ 1.1.1.1; 9.9.9.9; }'
|
jarl option set forwarders '{ 1.1.1.1; 9.9.9.9; }'
|
||||||
jarl option set allow-transfer '{ key xfer; }' --zone adbraun.lol
|
jarl option set forwarders 1.1.1.1 9.9.9.9 # the same: braces are added to a list
|
||||||
|
jarl option set allow-transfer 'key xfer; 10.0.10.53' --zone adbraun.lol
|
||||||
jarl option set response-policy '{ zone "rpz.local"; }' --view internal
|
jarl option set response-policy '{ zone "rpz.local"; }' --view internal
|
||||||
jarl option set logging '{ channel q { file "/var/log/named/query.log" versions 3 size 20m; }; category queries { q; }; }' --toplevel
|
jarl option set logging '{ channel q { file "/var/log/named/query.log" versions 3 size 20m; }; category queries { q; }; }' --toplevel
|
||||||
jarl option unset forwarders
|
jarl option unset forwarders
|
||||||
jarl option describe # every option allowed at this scope
|
jarl option describe # every option allowed at this scope
|
||||||
jarl option describe allow-query-cache # grammar and ARM description
|
jarl option describe allow-query-cache # grammar and ARM description
|
||||||
|
```
|
||||||
|
|
||||||
|
Options that end in a list (`allow-*`, `forwarders`, `listen-on`, ...) may
|
||||||
|
be given without the braces: elements are separated by `;`, `,` or spaces,
|
||||||
|
and `key NAME` and `port N` stay with their element. Options are always
|
||||||
|
listed in one fixed order (see [concepts.md](concepts.md)).
|
||||||
|
|
||||||
|
```sh
|
||||||
|
|
||||||
jarl settings show
|
jarl settings show
|
||||||
jarl settings set --ns ns1.adbraun.lol.,ns2.adbraun.lol. --contact hostmaster@adbraun.lol --ttl 3600
|
jarl settings set --ns ns1.adbraun.lol.,ns2.adbraun.lol. --contact hostmaster@adbraun.lol --ttl 3600
|
||||||
@@ -419,10 +450,11 @@ 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
|
||||||
|
jarl stats server # named's memory, CPU, cache, clients; host; disk space
|
||||||
```
|
```
|
||||||
|
|
||||||
Statistics must be enabled in `jarl.yaml`, and `-o json` exports the data.
|
Statistics must be enabled in `jarl.yaml`, and `-o json` exports the data.
|
||||||
@@ -442,6 +474,9 @@ 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 pause indefinitely # ... until 'jarl block resume'
|
||||||
|
jarl block resume
|
||||||
```
|
```
|
||||||
|
|
||||||
See [blocking.md](blocking.md).
|
See [blocking.md](blocking.md).
|
||||||
|
|||||||
+9
-1
@@ -68,7 +68,9 @@ both, using a single mechanism:
|
|||||||
* `POST /api/v1/apply` (CLI: `jarl apply -f changes.yaml`) applies a list of
|
* `POST /api/v1/apply` (CLI: `jarl apply -f changes.yaml`) applies a list of
|
||||||
operations as one changeset in a single request.
|
operations as one changeset in a single request.
|
||||||
* `?dry_run=1` (CLI: `--dry-run`) runs the whole pipeline except installing
|
* `?dry_run=1` (CLI: `--dry-run`) runs the whole pipeline except installing
|
||||||
files and reloading named, and shows the problems and the diff.
|
files and reloading named, and shows the problems and the diff. Each
|
||||||
|
part of a named.conf diff says which block it is in, after the `@@`
|
||||||
|
line numbers: `@@ -50,6 +49,8 @@ view "internal" {`.
|
||||||
|
|
||||||
**Why bundling matters.** Some changes only make sense together. Turning
|
**Why bundling matters.** Some changes only make sense together. Turning
|
||||||
`www` from an A record into a CNAME means deleting the A record and adding
|
`www` from an A record into a CNAME means deleting the A record and adding
|
||||||
@@ -159,6 +161,12 @@ RFC 1982 arithmetic.
|
|||||||
Jarl checks them before named does. Statements that Jarl manages itself
|
Jarl checks them before named does. Statements that Jarl manages itself
|
||||||
(`zone`, `view`, `acl`, `key`, a zone's `type`/`file`, a view's
|
(`zone`, `view`, `acl`, `key`, a zone's `type`/`file`, a view's
|
||||||
`match-clients`, ...) can't be set as raw options.
|
`match-clients`, ...) can't be set as raw options.
|
||||||
|
Options of a block are kept in one fixed order, the same in named.conf,
|
||||||
|
the web UI and the CLI: grouped (files, listening, access control,
|
||||||
|
recursion and forwarding, DNSSEC, transfers and notify, then the rest),
|
||||||
|
alphabetical within a group. So the same option is in the same place in
|
||||||
|
every view and zone. Top-level statements stay in the order they were
|
||||||
|
added.
|
||||||
* **Settings** are Jarl's defaults for new zones: name servers, SOA contact
|
* **Settings** are Jarl's defaults for new zones: name servers, SOA contact
|
||||||
and timers, default TTL, serial policy.
|
and timers, default TTL, serial policy.
|
||||||
|
|
||||||
|
|||||||
+26
-2
@@ -104,10 +104,30 @@ lists, views, zones). named's own counters can't be filtered.
|
|||||||
| `responses` | response log | lines read (a health check) |
|
| `responses` | response log | lines read (a health check) |
|
||||||
| `blocked`, `blocklist:NAME` | response log | queries [blocking](blocking.md) answered, and by which list (`blocklist:rules` for your own rules) |
|
| `blocked`, `blocklist:NAME` | response log | queries [blocking](blocking.md) answered, and by which list (`blocklist:rules` for your own rules) |
|
||||||
|
|
||||||
|
**Server** (sampled every minute; stored as gauges, `g:KEY` the sum of
|
||||||
|
the samples and `gn:KEY` their number, so a chart shows the average):
|
||||||
|
|
||||||
|
| Gauge | Source | Meaning |
|
||||||
|
|-------|--------|---------|
|
||||||
|
| `g:named:rss` | /proc | named's resident memory (what the system sees), bytes |
|
||||||
|
| `g:named:mem` | named | the memory named counts itself as using, bytes |
|
||||||
|
| `g:named:cpu` | /proc | named's CPU use over the minute, in tenths of a percent of one CPU |
|
||||||
|
| `g:cache:mem`, `g:cache:rrsets` | named | the cache's memory and RRsets, all views |
|
||||||
|
| `g:host:mem-used`, `g:host:load` | /proc | the host's memory in use, and its 1-minute load average times 100 |
|
||||||
|
|
||||||
|
Jarl finds named's process by name (the one whose command line mentions
|
||||||
|
named's main configuration file); reading its memory and CPU time needs no
|
||||||
|
privileges. With `/proc` mounted `hidepid`, it isn't visible and those
|
||||||
|
figures are left out. The current values also include named's sockets, its
|
||||||
|
recursive and TCP clients against their limits (`rndc status`), the most
|
||||||
|
at once since it started, and the space left on the file systems holding
|
||||||
|
the zone files, Jarl's cache, named.conf, the response log and this
|
||||||
|
database.
|
||||||
|
|
||||||
**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.
|
||||||
|
|
||||||
@@ -131,7 +151,9 @@ prefixes and get no name.
|
|||||||
page follows it. Charts have a hover readout (or focus them and use the
|
page follows it. Charts have a hover readout (or focus them and use the
|
||||||
arrow keys) and a **Table** button showing the same data as a table. The
|
arrow keys) and a **Table** button showing the same data as a table. The
|
||||||
heatmap shows the average queries per hour for each weekday and hour over at
|
heatmap shows the average queries per hour for each weekday and hour over at
|
||||||
least the last 7 days. The page refreshes every minute.
|
least the last 7 days. The page refreshes every minute. **Server**, at the
|
||||||
|
bottom, shows named's memory, CPU, cache and clients, the host and the
|
||||||
|
disks now, and named's memory and CPU over the range.
|
||||||
|
|
||||||
**CLI**:
|
**CLI**:
|
||||||
|
|
||||||
@@ -146,6 +168,8 @@ jarl stats timeline --since 6h # sparkline; --table for every bucket
|
|||||||
jarl stats timeline --since 7d --key rcode:NXDOMAIN --key rcode:SERVFAIL
|
jarl stats timeline --since 7d --key rcode:NXDOMAIN --key rcode:SERVFAIL
|
||||||
jarl stats heatmap --since 28d
|
jarl stats heatmap --since 28d
|
||||||
jarl stats breakdown qtype | rcode | view | zone | answer | rtt | dnssec | cache | transport | ratelimit
|
jarl stats breakdown qtype | rcode | view | zone | answer | rtt | dnssec | cache | transport | ratelimit
|
||||||
|
jarl stats server # memory, CPU, cache, clients, host, disks now
|
||||||
|
jarl stats timeline --since 7d --key g:named:rss --table # named's memory over a week
|
||||||
jarl stats summary --since 30d -o json # export (also -o yaml)
|
jarl stats summary --since 30d -o json # export (also -o yaml)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
+10
-1
@@ -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 |
|
||||||
@@ -69,7 +71,7 @@ showing them.
|
|||||||
| `acl_ref_missing`, `key_ref_missing` | error | Every ACL or TSIG key that is referenced must exist |
|
| `acl_ref_missing`, `key_ref_missing` | error | Every ACL or TSIG key that is referenced must exist |
|
||||||
| `acl_cycle` | error | ACLs that include each other |
|
| `acl_cycle` | error | ACLs that include each other |
|
||||||
| `acl_duplicate`, `acl_name_invalid`, `acl_empty`, `acl_shadowed` | error / warning | Unique names, built-in names reserved, elements that can never match |
|
| `acl_duplicate`, `acl_name_invalid`, `acl_empty`, `acl_shadowed` | error / warning | Unique names, built-in names reserved, elements that can never match |
|
||||||
| `key_*` | error / warning | Valid key names, a supported algorithm, a base64 secret of a sensible length. hmac-md5/sha1 are flagged as weak |
|
| `key_*` | error / warning | Valid key names, a supported algorithm, a base64 secret of a sensible length. hmac-md5/sha1 are flagged as weak. Names starting with `jarl-view-` are reserved for the view keys (`key_name_reserved`) |
|
||||||
| `view_duplicate`, `view_name_invalid`, `view_acl_invalid` | error | Valid, unique views and match lists |
|
| `view_duplicate`, `view_name_invalid`, `view_acl_invalid` | error | Valid, unique views and match lists |
|
||||||
| `view_unreachable` | warning | A view that comes after one matching every client is never used |
|
| `view_unreachable` | warning | A view that comes after one matching every client is never used |
|
||||||
| `option_unknown`, `option_scope_invalid` | error | The option must exist in BIND 9.20 and be allowed where it is set (server, view, zone of this type, top level) |
|
| `option_unknown`, `option_scope_invalid` | error | The option must exist in BIND 9.20 and be allowed where it is set (server, view, zone of this type, top level) |
|
||||||
@@ -130,3 +132,10 @@ values Jarl accepts as raw named.conf syntax, such as `logging` channels
|
|||||||
and `rrset-order`, are the main example. Their errors come back as
|
and `rrset-order`, are the main example. Their errors come back as
|
||||||
`validation_failed` (HTTP 422) with the checker's messages in `check`, each
|
`validation_failed` (HTTP 422) with the checker's messages in `check`, each
|
||||||
tied to the object it belongs to.
|
tied to the object it belongs to.
|
||||||
|
|
||||||
|
named-checkconf's warnings don't block a change. They are shown with the
|
||||||
|
change, and the dashboard's configuration checks list them for as long as
|
||||||
|
they apply (`named_warning`; `named_error` if the running configuration no
|
||||||
|
longer passes, after a hand edit say). Jarl runs named-checkconf on the
|
||||||
|
running configuration for this, without loading zones, when the dashboard
|
||||||
|
asks and the configuration changed, or at most every 10 minutes.
|
||||||
+41
-2
@@ -116,7 +116,44 @@ dig @<public-ip> example.com # from outside: status R
|
|||||||
jarl check
|
jarl check
|
||||||
```
|
```
|
||||||
|
|
||||||
On the server, `dig -b <source address>` picks which view you test.
|
On the server, `dig -b <source address>` picks which view you test, or,
|
||||||
|
from anywhere, sign the query with the view's key (below):
|
||||||
|
|
||||||
|
```sh
|
||||||
|
jarl view key external --named-conf > external.key
|
||||||
|
dig -k external.key @ns1.adbraun.lol nas.adbraun.lol +short # 203.0.113.20, even from the LAN
|
||||||
|
```
|
||||||
|
|
||||||
|
## View keys
|
||||||
|
|
||||||
|
named picks a view by where a query comes from, so a query this server
|
||||||
|
sends to its own public address usually lands in the internal view: its
|
||||||
|
source is one of your own networks. That makes checks of the external view
|
||||||
|
from the server itself (such as Jarl's delegation check) see the wrong
|
||||||
|
view.
|
||||||
|
|
||||||
|
So Jarl gives every view a TSIG key, `jarl-view-NAME`, and puts it at the
|
||||||
|
front of the view's `match-clients`:
|
||||||
|
|
||||||
|
```
|
||||||
|
view "internal" {
|
||||||
|
match-clients { !key jarl-view-external; key jarl-view-internal; homelab; };
|
||||||
|
...
|
||||||
|
view "external" {
|
||||||
|
match-clients { key jarl-view-external; any; };
|
||||||
|
```
|
||||||
|
|
||||||
|
A query signed with a view's key lands in that view, whatever its source;
|
||||||
|
the `!key` stops an earlier view from taking a later view's queries.
|
||||||
|
Clients don't send these keys, so for them nothing changes. Jarl signs its
|
||||||
|
own queries to this server's addresses with the key of the zone's view.
|
||||||
|
The keys live in `keys.conf` with your other keys, never in diffs or in
|
||||||
|
`jarl tsig list`; their secrets are derived from a random value in Jarl's
|
||||||
|
database (and in its backups), so they stay the same from one apply to
|
||||||
|
the next. `jarl view key VIEW`
|
||||||
|
shows one (it needs `tsig:write` and is audited). Key names starting with
|
||||||
|
`jarl-view-` are reserved, and the view keys can be used in your own
|
||||||
|
options (`allow-transfer { key jarl-view-external; }`).
|
||||||
|
|
||||||
## Living with views
|
## Living with views
|
||||||
|
|
||||||
@@ -130,7 +167,9 @@ On the server, `dig -b <source address>` picks which view you test.
|
|||||||
`jarl view list` shows it.
|
`jarl view list` shows it.
|
||||||
* Deleting a view is refused while it still has zones, unless you pass
|
* Deleting a view is refused while it still has zones, unless you pass
|
||||||
`--cascade`.
|
`--cascade`.
|
||||||
* Give each view a colour (Views → Edit → Colour, or
|
* Each view has its own page (Views → the view's name): its settings,
|
||||||
|
options and zones.
|
||||||
|
* Give each view a colour (the view's page → Settings → Colour, or
|
||||||
`jarl view color internal green`) to tell the zones of each view apart
|
`jarl view color internal green`) to tell the zones of each view apart
|
||||||
in the zone list. The colour only affects the web UI, so it applies at
|
in the zone list. The colour only affects the web UI, so it applies at
|
||||||
once, with no changeset or deploy. `jarl view color internal default`
|
once, with no changeset or deploy. `jarl view color internal default`
|
||||||
|
|||||||
+10
-2
@@ -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,10 @@ 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 for a time,
|
||||||
|
// BlockingPausedIndefinitely while it is paused until resumed.
|
||||||
|
BlockingPausedUntil *time.Time `json:"blocking_paused_until,omitempty"`
|
||||||
|
BlockingPausedIndefinitely bool `json:"blocking_paused_indefinitely,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// BlocklistError is a blocklist whose last update failed. UpdatedAt is
|
// BlocklistError is a blocklist whose last update failed. UpdatedAt is
|
||||||
|
|||||||
@@ -340,6 +340,13 @@ type ServerStatus struct {
|
|||||||
ConfigFile string `json:"config_file"`
|
ConfigFile string `json:"config_file"`
|
||||||
Zones string `json:"zones"`
|
Zones string `json:"zones"`
|
||||||
Running bool `json:"running"`
|
Running bool `json:"running"`
|
||||||
|
// RecursiveClients is "now/soft limit/hard limit" (the soft limit is
|
||||||
|
// where named starts dropping the oldest queries), TCPClients
|
||||||
|
// "now/limit".
|
||||||
|
RecursiveClients string `json:"recursive_clients,omitempty"`
|
||||||
|
TCPClients string `json:"tcp_clients,omitempty"`
|
||||||
|
CPUs string `json:"cpus,omitempty"`
|
||||||
|
WorkerThreads string `json:"worker_threads,omitempty"`
|
||||||
Raw string `json:"raw"`
|
Raw string `json:"raw"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -363,6 +370,14 @@ func (t *Tools) Status(ctx context.Context) (*ServerStatus, error) {
|
|||||||
st.ConfigFile = v
|
st.ConfigFile = v
|
||||||
case ok && k == "number of zones":
|
case ok && k == "number of zones":
|
||||||
st.Zones = v
|
st.Zones = v
|
||||||
|
case ok && k == "recursive clients":
|
||||||
|
st.RecursiveClients = v
|
||||||
|
case ok && k == "tcp clients":
|
||||||
|
st.TCPClients = v
|
||||||
|
case ok && k == "CPUs found":
|
||||||
|
st.CPUs = v
|
||||||
|
case ok && k == "worker threads":
|
||||||
|
st.WorkerThreads = v
|
||||||
case strings.Contains(line, "server is up and running"):
|
case strings.Contains(line, "server is up and running"):
|
||||||
st.Running = true
|
st.Running = true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -427,3 +427,80 @@ func classify(name, grammar string) (string, []string) {
|
|||||||
}
|
}
|
||||||
return KindRaw, nil
|
return KindRaw, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// listParam matches an optional parameter before a statement's list, such
|
||||||
|
// as "[ port <integer> ]" in allow-transfer's grammar.
|
||||||
|
var listParam = regexp.MustCompile(`\[ ([a-z0-9-]+) `)
|
||||||
|
|
||||||
|
// WrapList adds the braces to a list value given without them, for
|
||||||
|
// statements that end in a list ("allow-transfer [ port <integer> ] {
|
||||||
|
// <address_match_element>; ... }"): "10.0.0.53" becomes "{ 10.0.0.53; }",
|
||||||
|
// "port 5353 10.0.0.53; key xfer" becomes "port 5353 { 10.0.0.53; key
|
||||||
|
// xfer; }". Elements are separated by ";", "," or new lines, or by spaces
|
||||||
|
// when they are all addresses. Values with a brace, and values of other
|
||||||
|
// statements, are returned as they are.
|
||||||
|
func (st *Statement) WrapList(v string) string {
|
||||||
|
g, _, _ := strings.Cut(st.Grammar, "\n")
|
||||||
|
open := strings.Index(g, " {")
|
||||||
|
if strings.Contains(v, "{") || open < 0 || !strings.HasSuffix(g, "; ... };") {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
params := map[string]bool{}
|
||||||
|
for _, m := range listParam.FindAllStringSubmatch(g[:open], -1) {
|
||||||
|
params[m[1]] = true
|
||||||
|
}
|
||||||
|
fields := strings.Fields(v)
|
||||||
|
i := 0
|
||||||
|
for i+1 < len(fields) && params[strings.ToLower(fields[i])] {
|
||||||
|
i += 2
|
||||||
|
}
|
||||||
|
head := strings.Join(fields[:i], " ")
|
||||||
|
rest := strings.Join(fields[i:], " ")
|
||||||
|
var elems []string
|
||||||
|
if !strings.ContainsAny(rest, ";,\n") {
|
||||||
|
elems = spaceElements(fields[i:])
|
||||||
|
} else {
|
||||||
|
for _, e := range strings.FieldsFunc(rest, func(r rune) bool { return r == ';' || r == ',' || r == '\n' }) {
|
||||||
|
if e = strings.TrimSpace(e); e != "" {
|
||||||
|
elems = append(elems, e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(elems) == 0 {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
list := "{ " + strings.Join(elems, "; ") + "; }"
|
||||||
|
if head != "" {
|
||||||
|
return head + " " + list
|
||||||
|
}
|
||||||
|
return list
|
||||||
|
}
|
||||||
|
|
||||||
|
// spaceElements splits list elements separated by spaces: "key NAME"
|
||||||
|
// is one element, and "port N" and "tls NAME" belong to the address
|
||||||
|
// before them (forwarders { 192.0.2.1 port 53; }).
|
||||||
|
func spaceElements(fields []string) []string {
|
||||||
|
var out []string
|
||||||
|
for i := 0; i < len(fields); i++ {
|
||||||
|
f := fields[i]
|
||||||
|
switch strings.ToLower(f) {
|
||||||
|
case "key", "!key", "!":
|
||||||
|
if i+1 < len(fields) {
|
||||||
|
f += " " + fields[i+1]
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
if f[0] == '!' && strings.HasPrefix(strings.ToLower(f), "! key") && i+1 < len(fields) {
|
||||||
|
f += " " + fields[i+1]
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
case "port", "tls":
|
||||||
|
if len(out) > 0 && i+1 < len(fields) {
|
||||||
|
out[len(out)-1] += " " + f + " " + fields[i+1]
|
||||||
|
i++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out = append(out, f)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
@@ -77,3 +77,28 @@ func TestCatalog(t *testing.T) {
|
|||||||
t.Errorf("zone types = %s", types)
|
t.Errorf("zone types = %s", types)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestWrapList(t *testing.T) {
|
||||||
|
c := Default()
|
||||||
|
for _, tc := range []struct{ scope, name, in, want string }{
|
||||||
|
{ScopeOptions, "allow-transfer", "10.0.10.53", "{ 10.0.10.53; }"},
|
||||||
|
{ScopeOptions, "allow-transfer", "localhost 10.0.10.53 key xfer ! key old !10.0.0.1", "{ localhost; 10.0.10.53; key xfer; ! key old; !10.0.0.1; }"},
|
||||||
|
{ScopeOptions, "forwarders", "1.1.1.1 port 853 tls cf 9.9.9.9", "{ 1.1.1.1 port 853 tls cf; 9.9.9.9; }"},
|
||||||
|
{ScopeOptions, "allow-transfer", "localhost; 10.0.10.53", "{ localhost; 10.0.10.53; }"},
|
||||||
|
{ScopeOptions, "allow-transfer", "port 5353 key xfer, !10.0.0.1", "port 5353 { key xfer; !10.0.0.1; }"},
|
||||||
|
{ScopeOptions, "allow-transfer", "{ any; }", "{ any; }"},
|
||||||
|
{ScopeOptions, "allow-query", "10.0.0.0/8 192.168.1.0/24", "{ 10.0.0.0/8; 192.168.1.0/24; }"},
|
||||||
|
{ScopeOptions, "forwarders", "1.1.1.1 9.9.9.9", "{ 1.1.1.1; 9.9.9.9; }"},
|
||||||
|
{ScopeOptions, "listen-on", "port 53 10.0.10.5", "port 53 { 10.0.10.5; }"},
|
||||||
|
{ScopeOptions, "recursion", "yes", "yes"},
|
||||||
|
{ScopeOptions, "directory", `"/var/cache/bind"`, `"/var/cache/bind"`},
|
||||||
|
} {
|
||||||
|
st, ok := c.Lookup(tc.scope, tc.name)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("%s not found", tc.name)
|
||||||
|
}
|
||||||
|
if got := st.WrapList(tc.in); got != tc.want {
|
||||||
|
t.Errorf("%s %q = %q, want %q", tc.name, tc.in, got, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,70 @@ 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)
|
||||||
|
}
|
||||||
|
// Until resumed: jarld never ends it, a resume does.
|
||||||
|
res = env.submit(env.admin, NewRequest(OpBlockingPause, 0, BlockingPauseInput{Indefinitely: true}))
|
||||||
|
if res.Ops[0].Summary != "pause blocking until resumed" || !env.state().Settings.BlockingPaused() {
|
||||||
|
t.Errorf("indefinite pause: %q", res.Ops[0].Summary)
|
||||||
|
}
|
||||||
|
if f, _ := os.ReadFile(rulesFile); !strings.Contains(string(f), "*\tCNAME\trpz-passthru.\n") {
|
||||||
|
t.Errorf("rules zone while paused indefinitely:\n%s", f)
|
||||||
|
}
|
||||||
|
env.e.Now = func() time.Time { return until.Add(1000 * time.Hour) }
|
||||||
|
if got := env.e.ResumeBlocking(context.Background()); !got.IsZero() || !env.state().Settings.BlockingPausedIndefinitely {
|
||||||
|
t.Errorf("an indefinite pause ended by itself: %v", got)
|
||||||
|
}
|
||||||
|
env.submit(env.admin, NewRequest(OpBlockingPause, 0, BlockingPauseInput{}))
|
||||||
|
if env.state().Settings.BlockingPaused() {
|
||||||
|
t.Error("resume didn't end an indefinite pause")
|
||||||
|
}
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -78,7 +78,7 @@ func (e *Engine) RefreshDue(ctx context.Context) {
|
|||||||
e.log.Warn("blocklists: loading their status failed", "err", err)
|
e.log.Warn("blocklists: loading their status failed", "err", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
out := render.Render(st, e.cfg.Paths, e.cfg.Extras)
|
out := render.Render(st, e.cfg.Paths, e.extras(ctx))
|
||||||
if err := e.ensureBlocklistFiles(out.Blocklists); err != nil {
|
if err := e.ensureBlocklistFiles(out.Blocklists); err != nil {
|
||||||
e.log.Warn("blocklists: creating empty zone files failed", "err", err)
|
e.log.Warn("blocklists: creating empty zone files failed", "err", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,41 @@ 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, or until it is resumed", MaxBlockingPause)
|
||||||
|
}
|
||||||
|
if in.Indefinitely && in.Until != nil {
|
||||||
|
return errf(KindBadRequest, "a pause is either until a time or until resumed, not both")
|
||||||
|
}
|
||||||
|
st.Settings.BlockingPausedUntil = in.Until
|
||||||
|
st.Settings.BlockingPausedIndefinitely = in.Indefinitely
|
||||||
|
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{}
|
||||||
|
}
|
||||||
@@ -335,5 +335,8 @@ func fileDiff(path string, old []byte, existed bool, new []byte, removed, secret
|
|||||||
if d != "" && !strings.HasSuffix(d, "\n") {
|
if d != "" && !strings.HasSuffix(d, "\n") {
|
||||||
d += "\n"
|
d += "\n"
|
||||||
}
|
}
|
||||||
|
if strings.HasSuffix(path, ".conf") {
|
||||||
|
d = labelHunks(d, string(old), string(new))
|
||||||
|
}
|
||||||
return d
|
return d
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
package engine
|
||||||
|
|
||||||
|
import (
|
||||||
|
"regexp"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// hunkHeader matches a unified diff hunk header; the line counts are left
|
||||||
|
// out when they are 1.
|
||||||
|
var hunkHeader = regexp.MustCompile(`^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@$`)
|
||||||
|
|
||||||
|
// labelHunks adds the blocks a hunk's first change is in to its header,
|
||||||
|
// like git does with function names, so a change to named.conf says where
|
||||||
|
// it is:
|
||||||
|
//
|
||||||
|
// @@ -50,6 +49,8 @@ view "internal" {
|
||||||
|
//
|
||||||
|
// Removed lines are looked up in the old file, added ones in the new.
|
||||||
|
func labelHunks(diff, old, new string) string {
|
||||||
|
if !strings.Contains(diff, "\n@@ ") {
|
||||||
|
return diff
|
||||||
|
}
|
||||||
|
oldCtx, newCtx := blockContext(old), blockContext(new)
|
||||||
|
lines := strings.SplitAfter(diff, "\n")
|
||||||
|
for i, line := range lines {
|
||||||
|
m := hunkHeader.FindStringSubmatch(strings.TrimSuffix(line, "\n"))
|
||||||
|
if m == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
o, _ := strconv.Atoi(m[1])
|
||||||
|
n, _ := strconv.Atoi(m[2])
|
||||||
|
label := ""
|
||||||
|
hunk:
|
||||||
|
for _, l := range lines[i+1:] {
|
||||||
|
switch {
|
||||||
|
case strings.HasPrefix(l, "-"):
|
||||||
|
label = at(oldCtx, o)
|
||||||
|
break hunk
|
||||||
|
case strings.HasPrefix(l, "+"):
|
||||||
|
label = at(newCtx, n)
|
||||||
|
break hunk
|
||||||
|
case strings.HasPrefix(l, " "):
|
||||||
|
o, n = o+1, n+1
|
||||||
|
case strings.HasPrefix(l, `\`):
|
||||||
|
default:
|
||||||
|
break hunk
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if label != "" {
|
||||||
|
lines[i] = strings.TrimSuffix(line, "\n") + " " + label + "\n"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return strings.Join(lines, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
// at returns the context of line n (1-based), or "".
|
||||||
|
func at(ctx []string, n int) string {
|
||||||
|
if n < 1 || n > len(ctx) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return ctx[n-1]
|
||||||
|
}
|
||||||
|
|
||||||
|
// blockContext returns, for each line of a named.conf file, the blocks
|
||||||
|
// open at its start: `view "internal" { zone "example.com" {`, or "" at
|
||||||
|
// the top level. It knows named.conf's strings and comments.
|
||||||
|
func blockContext(text string) []string {
|
||||||
|
var (
|
||||||
|
out []string
|
||||||
|
stack []string
|
||||||
|
stmt strings.Builder // the current statement, up to a { or ;
|
||||||
|
inStr bool
|
||||||
|
inBlock bool // in a /* */ comment
|
||||||
|
)
|
||||||
|
label := func() string {
|
||||||
|
if len(stack) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return strings.Join(stack, " { ") + " {"
|
||||||
|
}
|
||||||
|
for _, line := range strings.SplitAfter(text, "\n") {
|
||||||
|
if line == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, label())
|
||||||
|
for i := 0; i < len(line); i++ {
|
||||||
|
c := line[i]
|
||||||
|
switch {
|
||||||
|
case inBlock:
|
||||||
|
if c == '*' && i+1 < len(line) && line[i+1] == '/' {
|
||||||
|
inBlock = false
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
case inStr:
|
||||||
|
stmt.WriteByte(c)
|
||||||
|
if c == '\\' && i+1 < len(line) {
|
||||||
|
stmt.WriteByte(line[i+1])
|
||||||
|
i++
|
||||||
|
} else if c == '"' {
|
||||||
|
inStr = false
|
||||||
|
}
|
||||||
|
case c == '"':
|
||||||
|
inStr = true
|
||||||
|
stmt.WriteByte(c)
|
||||||
|
case c == '#' || c == '/' && i+1 < len(line) && line[i+1] == '/':
|
||||||
|
i = len(line) // a comment to the end of the line
|
||||||
|
case c == '/' && i+1 < len(line) && line[i+1] == '*':
|
||||||
|
inBlock = true
|
||||||
|
i++
|
||||||
|
case c == '{':
|
||||||
|
stack = append(stack, shortLabel(stmt.String()))
|
||||||
|
stmt.Reset()
|
||||||
|
case c == '}':
|
||||||
|
if len(stack) > 0 {
|
||||||
|
stack = stack[:len(stack)-1]
|
||||||
|
}
|
||||||
|
stmt.Reset()
|
||||||
|
case c == ';':
|
||||||
|
stmt.Reset()
|
||||||
|
default:
|
||||||
|
stmt.WriteByte(c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// shortLabel tidies the text before a { for a hunk header.
|
||||||
|
func shortLabel(s string) string {
|
||||||
|
s = strings.Join(strings.Fields(s), " ")
|
||||||
|
if len(s) > 60 {
|
||||||
|
s = s[:57] + "..."
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
package engine
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestLabelHunks(t *testing.T) {
|
||||||
|
old := `// header
|
||||||
|
options {
|
||||||
|
directory "/var/cache/bind";
|
||||||
|
forwarders { 1.1.1.1; 9.9.9.9; };
|
||||||
|
listen-on { any; };
|
||||||
|
recursion no;
|
||||||
|
version "a { b"; # a { in a string and comments: /* {
|
||||||
|
dnssec-validation auto;
|
||||||
|
};
|
||||||
|
|
||||||
|
view "internal" {
|
||||||
|
match-clients { 10.0.0.0/8; };
|
||||||
|
recursion yes;
|
||||||
|
allow-transfer { localhost; };
|
||||||
|
/* a { comment
|
||||||
|
over lines */
|
||||||
|
zone "example.com" {
|
||||||
|
type primary;
|
||||||
|
file "example.com.db";
|
||||||
|
notify yes;
|
||||||
|
};
|
||||||
|
zone "other.example" { type primary; file "o.db"; };
|
||||||
|
};
|
||||||
|
`
|
||||||
|
new := strings.Replace(old, "\tforwarders { 1.1.1.1; 9.9.9.9; };\n", "", 1)
|
||||||
|
new = strings.Replace(new, "\tallow-transfer { localhost; };\n", "\tallow-transfer { localhost; };\n\tforward first;\n\tforwarders { 1.1.1.1; 9.9.9.9; };\n", 1)
|
||||||
|
new = strings.Replace(new, "\t\tnotify yes;\n", "\t\tnotify no;\n", 1)
|
||||||
|
d := fileDiff("/etc/bind/named.conf", []byte(old), true, []byte(new), false, false)
|
||||||
|
var headers []string
|
||||||
|
for _, l := range strings.Split(d, "\n") {
|
||||||
|
if strings.HasPrefix(l, "@@") {
|
||||||
|
headers = append(headers, l)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
want := []string{
|
||||||
|
`@@ -1,7 +1,6 @@ options {`,
|
||||||
|
`@@ -12,12 +11,14 @@ view "internal" {`,
|
||||||
|
}
|
||||||
|
if strings.Join(headers, "\n") != strings.Join(want, "\n") {
|
||||||
|
t.Errorf("headers:\n%s\n\ndiff:\n%s", strings.Join(headers, "\n"), d)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The first change of a hunk decides, and a change inside a zone
|
||||||
|
// names both blocks.
|
||||||
|
new2 := strings.Replace(old, "\t\tnotify yes;\n", "\t\tnotify no;\n", 1)
|
||||||
|
d = fileDiff("/etc/bind/named.conf", []byte(old), true, []byte(new2), false, false)
|
||||||
|
if !strings.Contains(d, ` @@ view "internal" { zone "example.com" {`+"\n") {
|
||||||
|
t.Errorf("zone change:\n%s", d)
|
||||||
|
}
|
||||||
|
// Zone files aren't named.conf syntax.
|
||||||
|
d = fileDiff("/var/lib/bind/example.com.db", []byte(old), true, []byte(new2), false, false)
|
||||||
|
if strings.Contains(d, "@@ view") {
|
||||||
|
t.Errorf("zone file labelled:\n%s", d)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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) || from.Settings.BlockingPausedIndefinitely != to.Settings.BlockingPausedIndefinitely {
|
||||||
|
add(OpBlockingPause, 0, 0, BlockingPauseInput{Until: to.Settings.BlockingPausedUntil, Indefinitely: to.Settings.BlockingPausedIndefinitely}, map[bool]string{true: "pause blocking", false: "resume blocking"}[to.Settings.BlockingPaused()])
|
||||||
|
}
|
||||||
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, s.BlockingPausedIndefinitely = nil, false // their own operation
|
||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -73,6 +73,11 @@ type Engine struct {
|
|||||||
blKick chan struct{} // a list was added or changed
|
blKick chan struct{} // a list was added or changed
|
||||||
HTTP *http.Client // for downloads; nil: a default client
|
HTTP *http.Client // for downloads; nil: a default client
|
||||||
Limits BlocklistLimits
|
Limits BlocklistLimits
|
||||||
|
|
||||||
|
namedCheck namedCheck // named-checkconf on the running configuration (namedwarn.go)
|
||||||
|
|
||||||
|
seedMu sync.Mutex
|
||||||
|
seed []byte // the view keys' seed (viewkeys.go)
|
||||||
}
|
}
|
||||||
|
|
||||||
// New creates an engine.
|
// New creates an engine.
|
||||||
@@ -435,7 +440,7 @@ func (e *Engine) makePlan(ctx context.Context, base, work *model.State, user str
|
|||||||
pl.introduced = append(pl.introduced, problems...)
|
pl.introduced = append(pl.introduced, problems...)
|
||||||
return pl, nil
|
return pl, nil
|
||||||
}
|
}
|
||||||
pl.out = render.Render(work, e.cfg.Paths, e.cfg.Extras)
|
pl.out = render.Render(work, e.cfg.Paths, e.extras(ctx))
|
||||||
deployed, err := e.store.DeployedFiles(ctx)
|
deployed, err := e.store.DeployedFiles(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -471,18 +476,18 @@ func (e *Engine) makePlan(ctx context.Context, base, work *model.State, user str
|
|||||||
pl.diff = diff.String()
|
pl.diff = diff.String()
|
||||||
|
|
||||||
// Which zones named must reload, and which serials to verify.
|
// Which zones named must reload, and which serials to verify.
|
||||||
baseOut := render.Render(base, e.cfg.Paths, e.cfg.Extras)
|
baseOut := render.Render(base, e.cfg.Paths, e.extras(ctx))
|
||||||
changedPaths := map[string]bool{}
|
changedPaths := map[string]bool{}
|
||||||
for _, f := range pl.changed {
|
for _, f := range pl.changed {
|
||||||
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
|
||||||
@@ -527,7 +532,7 @@ func (e *Engine) stagingCheck(ctx context.Context, pl *plan) error {
|
|||||||
}
|
}
|
||||||
defer os.RemoveAll(dir)
|
defer os.RemoveAll(dir)
|
||||||
sp := render.Paths{ConfigDir: filepath.Join(dir, "config"), ZoneDir: filepath.Join(dir, "zones"), CacheDir: filepath.Join(dir, "cache")}
|
sp := render.Paths{ConfigDir: filepath.Join(dir, "config"), ZoneDir: filepath.Join(dir, "zones"), CacheDir: filepath.Join(dir, "cache")}
|
||||||
sout := render.Render(pl.work, sp, e.cfg.Extras)
|
sout := render.Render(pl.work, sp, e.extras(ctx))
|
||||||
for _, d := range sout.Dirs {
|
for _, d := range sout.Dirs {
|
||||||
if err := os.MkdirAll(d, 0o750); err != nil {
|
if err := os.MkdirAll(d, 0o750); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -829,7 +834,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 +1059,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
|
||||||
}
|
}
|
||||||
@@ -1175,6 +1180,14 @@ type Status struct {
|
|||||||
MainConfig string `json:"main_config"`
|
MainConfig string `json:"main_config"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// NamedStatus asks named for its status (rndc status).
|
||||||
|
func (e *Engine) NamedStatus(ctx context.Context) (*bind.ServerStatus, error) {
|
||||||
|
if e.cfg.NoReload || e.tools == nil {
|
||||||
|
return nil, errors.New("named is not managed by this jarld (no_reload)")
|
||||||
|
}
|
||||||
|
return e.tools.Status(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
// Status returns the current status.
|
// Status returns the current status.
|
||||||
func (e *Engine) Status(ctx context.Context) (*Status, error) {
|
func (e *Engine) Status(ctx context.Context) (*Status, error) {
|
||||||
st, err := e.store.LoadState(ctx)
|
st, err := e.store.LoadState(ctx)
|
||||||
@@ -1218,7 +1231,7 @@ func (e *Engine) Status(ctx context.Context) (*Status, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
s.Problems = nonNilProblems(e.validate(st))
|
s.Problems = nonNilProblems(append(e.validate(st), e.namedProblems(ctx, st, deployed)...))
|
||||||
s.Counts = map[string]int{"views": len(st.Views), "zones": len(st.Zones), "records": len(st.Records), "acls": len(st.ACLs), "tsig_keys": len(st.Keys), "options": len(st.Options)}
|
s.Counts = map[string]int{"views": len(st.Views), "zones": len(st.Zones), "records": len(st.Records), "acls": len(st.ACLs), "tsig_keys": len(st.Keys), "options": len(st.Options)}
|
||||||
return s, nil
|
return s, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,18 @@ 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")
|
||||||
|
} else if in.Indefinitely {
|
||||||
|
summary = "pause blocking until resumed"
|
||||||
|
}
|
||||||
|
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 +1037,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,14 @@ 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"`
|
||||||
|
// Indefinitely pauses until someone resumes blocking (Until is nil).
|
||||||
|
Indefinitely bool `json:"indefinitely,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// MaxBlockingPause is the longest pause: blocking shouldn't stay off by
|
||||||
|
// mistake (disable lists to stop using them).
|
||||||
|
const MaxBlockingPause = 24 * time.Hour
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
package engine
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"jarl/internal/model"
|
||||||
|
"jarl/internal/render"
|
||||||
|
"jarl/internal/validate"
|
||||||
|
)
|
||||||
|
|
||||||
|
// namedCheckTTL is how long the result of checking named's running
|
||||||
|
// configuration is kept when nothing changed.
|
||||||
|
const namedCheckTTL = 10 * time.Minute
|
||||||
|
|
||||||
|
// namedCheck caches the warnings of named-checkconf on the configuration
|
||||||
|
// named runs, keyed by what it depends on.
|
||||||
|
type namedCheck struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
key string
|
||||||
|
at time.Time
|
||||||
|
problems []validate.Problem
|
||||||
|
}
|
||||||
|
|
||||||
|
// namedProblems runs named-checkconf on the configuration named runs
|
||||||
|
// (without loading zones, which is quick) and returns what it reports as
|
||||||
|
// problems. An apply shows these too, but they are named's own checks,
|
||||||
|
// which Jarl doesn't repeat ("both recursion no and allow-recursion
|
||||||
|
// active"): the dashboard lists them with Jarl's so they don't go unseen
|
||||||
|
// after the apply. The result is reused while the deployed files and
|
||||||
|
// named's main file are unchanged.
|
||||||
|
func (e *Engine) namedProblems(ctx context.Context, st *model.State, deployed map[string]*model.DeployedFile) []validate.Problem {
|
||||||
|
if e.tools == nil || e.cfg.SkipCheck || e.cfg.MainConfig == "" || len(deployed) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
key := ""
|
||||||
|
if fi, err := os.Stat(e.cfg.MainConfig); err == nil {
|
||||||
|
key = fi.ModTime().String()
|
||||||
|
}
|
||||||
|
if f := deployed[e.cfg.Paths.MainConfig()]; f != nil {
|
||||||
|
key += "|" + f.SHA256
|
||||||
|
}
|
||||||
|
c := &e.namedCheck
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
if c.key == key && e.Now().Sub(c.at) < namedCheckTTL {
|
||||||
|
return c.problems
|
||||||
|
}
|
||||||
|
res, err := e.tools.CheckConfig(ctx, e.cfg.MainConfig, false)
|
||||||
|
if err != nil {
|
||||||
|
return nil // named-checkconf couldn't run; an apply reports that
|
||||||
|
}
|
||||||
|
out := render.Render(st, e.cfg.Paths, e.extras(ctx))
|
||||||
|
var ps []validate.Problem
|
||||||
|
for _, m := range res.Messages {
|
||||||
|
p := validate.Problem{Severity: validate.Warning, Code: "named_warning", View: m.View, Zone: m.Zone}
|
||||||
|
if m.Error {
|
||||||
|
p.Severity, p.Code = validate.Error, "named_error"
|
||||||
|
}
|
||||||
|
where := ""
|
||||||
|
if m.File != "" {
|
||||||
|
where = fmt.Sprintf(" (%s:%d)", m.File, m.Line)
|
||||||
|
}
|
||||||
|
if span, ok := out.Lookup(m.File, m.Line); ok {
|
||||||
|
p.ObjectType, p.ObjectID = span.ObjectType, span.ObjectID
|
||||||
|
where = " (" + span.Label + ", " + where[2:]
|
||||||
|
}
|
||||||
|
p.Message = "named-checkconf: " + strings.TrimSpace(m.Text) + where
|
||||||
|
ps = append(ps, p)
|
||||||
|
}
|
||||||
|
c.key, c.at, c.problems = key, e.Now(), ps
|
||||||
|
return ps
|
||||||
|
}
|
||||||
@@ -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) {
|
||||||
@@ -1122,6 +1127,8 @@ func NormalizeOption(st *model.State, scope string, scopeID int64, name, value s
|
|||||||
if (stmt.Kind == catalog.KindString) && !strings.HasPrefix(v, `"`) && !strings.ContainsAny(v, " \t{};") {
|
if (stmt.Kind == catalog.KindString) && !strings.HasPrefix(v, `"`) && !strings.ContainsAny(v, " \t{};") {
|
||||||
v = namedconf.QuoteString(v)
|
v = namedconf.QuoteString(v)
|
||||||
}
|
}
|
||||||
|
// Lists may be given without their braces: "10.0.0.53; key xfer".
|
||||||
|
v = stmt.WrapList(v)
|
||||||
parsed, err := namedconf.ParseOptionValue(n, v)
|
parsed, err := namedconf.ParseOptionValue(n, v)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", "", errf(KindBadRequest, "option %s: %v", n, err)
|
return "", "", errf(KindBadRequest, "option %s: %v", n, err)
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
package engine
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"jarl/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestNormalizeOptionWrapsLists(t *testing.T) {
|
||||||
|
st := model.NewState()
|
||||||
|
for in, want := range map[string]string{
|
||||||
|
"10.0.10.53": "{ 10.0.10.53; }",
|
||||||
|
"localhost; key xfer;": "{ localhost; key xfer; }",
|
||||||
|
"port 53 { any; }": "port 53 { any; }",
|
||||||
|
"localhost 10.0.10.53": "{ localhost; 10.0.10.53; }",
|
||||||
|
} {
|
||||||
|
_, v, err := NormalizeOption(st, model.ScopeOptions, 0, "allow-transfer", in)
|
||||||
|
if err != nil || v != want {
|
||||||
|
t.Errorf("%q: %q, %v; want %q", in, v, err, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,68 @@
|
|||||||
|
package engine
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/base64"
|
||||||
|
"errors"
|
||||||
|
|
||||||
|
"jarl/internal/model"
|
||||||
|
"jarl/internal/nscheck"
|
||||||
|
"jarl/internal/render"
|
||||||
|
"jarl/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// viewKeySeedMeta is where the seed of the view keys is kept.
|
||||||
|
const viewKeySeedMeta = "view_key_seed"
|
||||||
|
|
||||||
|
// ViewKeySeed returns the seed the view keys' secrets are derived from
|
||||||
|
// (render/viewkeys.go), creating it the first time.
|
||||||
|
func ViewKeySeed(ctx context.Context, st *store.Store) ([]byte, error) {
|
||||||
|
v, err := st.GetMeta(ctx, viewKeySeedMeta)
|
||||||
|
if err == nil {
|
||||||
|
return base64.StdEncoding.DecodeString(v)
|
||||||
|
}
|
||||||
|
if !errors.Is(err, store.ErrNotFound) {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
seed := make([]byte, 32)
|
||||||
|
if _, err := rand.Read(seed); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := st.SetMeta(ctx, viewKeySeedMeta, base64.StdEncoding.EncodeToString(seed)); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return seed, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// extras returns what jarld's configuration adds to the rendered
|
||||||
|
// configuration, with the view keys' seed.
|
||||||
|
func (e *Engine) extras(ctx context.Context) render.Extras {
|
||||||
|
x := e.cfg.Extras
|
||||||
|
e.seedMu.Lock()
|
||||||
|
defer e.seedMu.Unlock()
|
||||||
|
if e.seed == nil {
|
||||||
|
seed, err := ViewKeySeed(ctx, e.store)
|
||||||
|
if err != nil {
|
||||||
|
e.log.Warn("view keys: reading their seed failed", "err", err)
|
||||||
|
return x
|
||||||
|
}
|
||||||
|
e.seed = seed
|
||||||
|
}
|
||||||
|
x.ViewKeySeed = e.seed
|
||||||
|
return x
|
||||||
|
}
|
||||||
|
|
||||||
|
// ViewKey returns the TSIG key that selects a zone's view on this server
|
||||||
|
// (nil without views).
|
||||||
|
func (e *Engine) ViewKey(ctx context.Context, st *model.State, z *model.Zone) *nscheck.Key {
|
||||||
|
v := st.Views[z.ViewID]
|
||||||
|
if v == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
x := e.extras(ctx)
|
||||||
|
if len(x.ViewKeySeed) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &nscheck.Key{Name: render.ViewKeyName(v.Name), Algorithm: render.ViewKeyAlgorithm, Secret: render.ViewKeySecret(x.ViewKeySeed, v.ID)}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -393,6 +393,10 @@ func (im *importer) key(s *namedconf.Stmt) error {
|
|||||||
return fmt.Errorf("%s: key without a block", s.Pos)
|
return fmt.Errorf("%s: key without a block", s.Pos)
|
||||||
}
|
}
|
||||||
name := model.NormalizeKeyName(s.Arg(0))
|
name := model.NormalizeKeyName(s.Arg(0))
|
||||||
|
if strings.HasPrefix(name, model.ViewKeyPrefix) {
|
||||||
|
// A view key from a configuration Jarl wrote: Jarl makes its own.
|
||||||
|
return nil
|
||||||
|
}
|
||||||
alg, secret := "", ""
|
alg, secret := "", ""
|
||||||
if a := namedconf.FindOne(b.Stmts, "algorithm"); a != nil {
|
if a := namedconf.FindOne(b.Stmts, "algorithm"); a != nil {
|
||||||
alg = strings.ToLower(a.Arg(0))
|
alg = strings.ToLower(a.Arg(0))
|
||||||
@@ -433,7 +437,7 @@ func (im *importer) view(s *namedconf.Stmt) error {
|
|||||||
switch kw := st.Keyword(); kw {
|
switch kw := st.Keyword(); kw {
|
||||||
case "match-clients":
|
case "match-clients":
|
||||||
if mb := st.FirstBlock(); mb != nil {
|
if mb := st.FirstBlock(); mb != nil {
|
||||||
v.MatchClients = nonNil(elementStrings(mb))
|
v.MatchClients = withoutViewKeys(nonNil(elementStrings(mb)))
|
||||||
}
|
}
|
||||||
case "match-destinations":
|
case "match-destinations":
|
||||||
if mb := st.FirstBlock(); mb != nil {
|
if mb := st.FirstBlock(); mb != nil {
|
||||||
@@ -807,3 +811,23 @@ func allowsNone(o *namedconf.Stmt) bool {
|
|||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// withoutViewKeys drops the view keys Jarl puts in front of match-clients
|
||||||
|
// (render/viewkeys.go) from a list read from a configuration Jarl wrote,
|
||||||
|
// and the "any" it adds after them to a view that had no list.
|
||||||
|
func withoutViewKeys(elems []string) []string {
|
||||||
|
out := []string{}
|
||||||
|
dropped := false
|
||||||
|
for _, e := range elems {
|
||||||
|
f := strings.Fields(strings.TrimPrefix(strings.TrimSpace(e), "!"))
|
||||||
|
if len(f) == 2 && f[0] == "key" && strings.HasPrefix(strings.Trim(f[1], `"`), model.ViewKeyPrefix) {
|
||||||
|
dropped = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, e)
|
||||||
|
}
|
||||||
|
if dropped && len(out) == 1 && out[0] == "any" {
|
||||||
|
return []string{}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
@@ -205,3 +205,15 @@ view "internal" {
|
|||||||
t.Errorf("imported state has errors: %v", ps)
|
t.Errorf("imported state has errors: %v", ps)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestWithoutViewKeys(t *testing.T) {
|
||||||
|
for _, tc := range []struct{ in, want []string }{
|
||||||
|
{[]string{"!key jarl-view-external", `key "jarl-view-internal"`, "homelab"}, []string{"homelab"}},
|
||||||
|
{[]string{"key jarl-view-external", "any"}, []string{}},
|
||||||
|
{[]string{"key xfer", "any"}, []string{"key xfer", "any"}},
|
||||||
|
} {
|
||||||
|
if got := withoutViewKeys(tc.in); strings.Join(got, ";") != strings.Join(tc.want, ";") || got == nil {
|
||||||
|
t.Errorf("%v: %v, want %v", tc.in, got, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+28
-1
@@ -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,9 +211,20 @@ 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"`
|
||||||
|
// BlockingPausedIndefinitely pauses blocking until someone resumes it.
|
||||||
|
BlockingPausedIndefinitely bool `json:"blocking_paused_indefinitely,omitempty"`
|
||||||
Version int `json:"version"`
|
Version int `json:"version"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// BlockingPaused reports whether blocking is paused, for a time or until
|
||||||
|
// resumed.
|
||||||
|
func (s *Settings) BlockingPaused() bool {
|
||||||
|
return s.BlockingPausedUntil != nil || s.BlockingPausedIndefinitely
|
||||||
|
}
|
||||||
|
|
||||||
// DefaultSettings returns the settings of a fresh installation.
|
// DefaultSettings returns the settings of a fresh installation.
|
||||||
func DefaultSettings() Settings {
|
func DefaultSettings() Settings {
|
||||||
return Settings{
|
return Settings{
|
||||||
@@ -370,6 +384,13 @@ func (s *State) KeyByName(name string) *TSIGKey {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ViewKeyPrefix starts the names of the TSIG keys Jarl gives views
|
||||||
|
// (render/viewkeys.go); other keys can't use it.
|
||||||
|
const ViewKeyPrefix = "jarl-view-"
|
||||||
|
|
||||||
|
// ViewKeyName returns the name of a view's key.
|
||||||
|
func ViewKeyName(view string) string { return ViewKeyPrefix + view }
|
||||||
|
|
||||||
// NormalizeKeyName returns the canonical form of a key name: lowercase,
|
// NormalizeKeyName returns the canonical form of a key name: lowercase,
|
||||||
// without a trailing dot.
|
// without a trailing dot.
|
||||||
func NormalizeKeyName(name string) string {
|
func NormalizeKeyName(name string) string {
|
||||||
@@ -500,7 +521,9 @@ func (s *State) ServedRecordsByZone() map[int64][]*Record {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
// ScopedOptions returns the options of a scope, in position order.
|
// ScopedOptions returns the options of a scope: in a fixed order by name
|
||||||
|
// for a block (see optionLess), in the order they were added for
|
||||||
|
// top-level statements, which are written as entered.
|
||||||
func (s *State) ScopedOptions(scope string, scopeID int64) []*Option {
|
func (s *State) ScopedOptions(scope string, scopeID int64) []*Option {
|
||||||
var out []*Option
|
var out []*Option
|
||||||
for _, o := range s.Options {
|
for _, o := range s.Options {
|
||||||
@@ -508,6 +531,10 @@ func (s *State) ScopedOptions(scope string, scopeID int64) []*Option {
|
|||||||
out = append(out, o)
|
out = append(out, o)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if scope != ScopeTopLevel {
|
||||||
|
sort.Slice(out, func(i, j int) bool { return optionLess(out[i], out[j]) })
|
||||||
|
return out
|
||||||
|
}
|
||||||
sort.Slice(out, func(i, j int) bool {
|
sort.Slice(out, func(i, j int) bool {
|
||||||
if out[i].Position != out[j].Position {
|
if out[i].Position != out[j].Position {
|
||||||
return out[i].Position < out[j].Position
|
return out[i].Position < out[j].Position
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
// optionGroups orders the options of a block (options, a view, a zone) the
|
||||||
|
// same way everywhere: in named.conf, the web UI and the CLI. Related
|
||||||
|
// options are grouped; within a group they are alphabetical, and options
|
||||||
|
// not listed here come last, alphabetically. named doesn't care about the
|
||||||
|
// order of options in a block.
|
||||||
|
var optionGroups = [][]string{
|
||||||
|
// Files and identity.
|
||||||
|
{"bindkeys-file", "directory", "dump-file", "hostname", "managed-keys-directory", "memstatistics-file", "pid-file", "recursing-file", "secroots-file", "server-id", "session-keyfile", "statistics-file", "version"},
|
||||||
|
// Listening and source addresses.
|
||||||
|
{"listen-on", "listen-on-v6", "notify-source", "notify-source-v6", "query-source", "query-source-v6", "transfer-source", "transfer-source-v6"},
|
||||||
|
// Access control.
|
||||||
|
{"allow-query", "allow-query-cache", "allow-query-cache-on", "allow-query-on", "allow-recursion", "allow-recursion-on", "allow-update", "allow-update-forwarding", "blackhole", "deny-answer-addresses", "deny-answer-aliases", "update-policy"},
|
||||||
|
// Recursion, forwarding and the cache.
|
||||||
|
{"forward", "forwarders", "max-cache-size", "max-cache-ttl", "max-ncache-ttl", "minimal-responses", "prefetch", "qname-minimization", "rate-limit", "recursion", "response-policy", "serve-stale", "stale-answer-enable", "stale-answer-ttl", "stale-cache-enable"},
|
||||||
|
// DNSSEC.
|
||||||
|
{"dnssec-accept-expired", "dnssec-must-be-secure", "dnssec-policy", "dnssec-validation", "inline-signing", "key-directory", "trust-anchor-telemetry", "validate-except"},
|
||||||
|
// Transfers and notify.
|
||||||
|
{"allow-notify", "allow-transfer", "also-notify", "catalog-zones", "ixfr-from-differences", "masters", "max-transfer-idle-in", "max-transfer-idle-out", "max-transfer-time-in", "max-transfer-time-out", "notify", "notify-to-soa", "primaries", "provide-ixfr", "request-ixfr", "serial-query-rate", "transfer-format", "transfers-in", "transfers-out", "transfers-per-ns"},
|
||||||
|
}
|
||||||
|
|
||||||
|
var optionGroup = func() map[string]int {
|
||||||
|
m := map[string]int{}
|
||||||
|
for i, g := range optionGroups {
|
||||||
|
for _, n := range g {
|
||||||
|
m[n] = i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
}()
|
||||||
|
|
||||||
|
// optionLess orders two options of the same block: by group, then name,
|
||||||
|
// then (for an option given more than once, like listen-on) the order they
|
||||||
|
// were added in.
|
||||||
|
func optionLess(a, b *Option) bool {
|
||||||
|
ga, ok := optionGroup[a.Name]
|
||||||
|
if !ok {
|
||||||
|
ga = len(optionGroups)
|
||||||
|
}
|
||||||
|
gb, ok := optionGroup[b.Name]
|
||||||
|
if !ok {
|
||||||
|
gb = len(optionGroups)
|
||||||
|
}
|
||||||
|
if ga != gb {
|
||||||
|
return ga < gb
|
||||||
|
}
|
||||||
|
if a.Name != b.Name {
|
||||||
|
return a.Name < b.Name
|
||||||
|
}
|
||||||
|
if a.Position != b.Position {
|
||||||
|
return a.Position < b.Position
|
||||||
|
}
|
||||||
|
return a.ID < b.ID
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestScopedOptionsOrder(t *testing.T) {
|
||||||
|
s := NewState()
|
||||||
|
add := func(scope string, names ...string) {
|
||||||
|
for i, n := range names {
|
||||||
|
id := int64(len(s.Options) + 1)
|
||||||
|
s.Options[id] = &Option{ID: id, Scope: scope, Name: n, Position: i}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Added in an arbitrary order; listen-on twice.
|
||||||
|
add(ScopeView, "zzz-unknown", "forwarders", "listen-on", "recursion", "dnssec-validation", "aaa-unknown", "allow-query", "forward", "directory", "listen-on", "allow-transfer")
|
||||||
|
var got []string
|
||||||
|
for _, o := range s.ScopedOptions(ScopeView, 0) {
|
||||||
|
got = append(got, o.Name)
|
||||||
|
}
|
||||||
|
want := "directory listen-on listen-on allow-query forward forwarders recursion dnssec-validation allow-transfer aaa-unknown zzz-unknown"
|
||||||
|
if strings.Join(got, " ") != want {
|
||||||
|
t.Errorf("order:\n got %s\nwant %s", strings.Join(got, " "), want)
|
||||||
|
}
|
||||||
|
// An option given twice keeps the order it was added in.
|
||||||
|
l := s.ScopedOptions(ScopeView, 0)
|
||||||
|
if l[1].Position > l[2].Position {
|
||||||
|
t.Error("repeated listen-on swapped")
|
||||||
|
}
|
||||||
|
// Top-level statements stay as entered.
|
||||||
|
add(ScopeTopLevel, "logging", "acl", "key")
|
||||||
|
got = nil
|
||||||
|
for _, o := range s.ScopedOptions(ScopeTopLevel, 0) {
|
||||||
|
got = append(got, o.Name)
|
||||||
|
}
|
||||||
|
if strings.Join(got, " ") != "logging acl key" {
|
||||||
|
t.Errorf("top level: %v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
package nscheck
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/netip"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"slices"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Without netlink (a sandboxed jarld), the host's addresses come from /proc.
|
||||||
|
func TestProcAddrs(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
os.WriteFile(filepath.Join(dir, "if_inet6"), []byte(
|
||||||
|
"00000000000000000000000000000001 01 80 10 80 lo\n"+
|
||||||
|
"2a0280137f3d00100000000000000005 02 40 00 80 eth0\n"), 0o644)
|
||||||
|
os.WriteFile(filepath.Join(dir, "fib_trie"), []byte(`Main:
|
||||||
|
+-- 0.0.0.0/0 3 0 5
|
||||||
|
|-- 0.0.0.0
|
||||||
|
/0 universe UNICAST
|
||||||
|
+-- 10.0.10.0/24 2 0 2
|
||||||
|
|-- 10.0.10.0
|
||||||
|
/24 link UNICAST
|
||||||
|
|-- 10.0.10.5
|
||||||
|
/32 host LOCAL
|
||||||
|
|-- 10.0.10.255
|
||||||
|
/32 link BROADCAST
|
||||||
|
Local:
|
||||||
|
+-- 0.0.0.0/0 3 0 5
|
||||||
|
|-- 10.0.10.5
|
||||||
|
/32 host LOCAL
|
||||||
|
`), 0o644)
|
||||||
|
got := procAddrs(dir)
|
||||||
|
for _, want := range []string{"::1", "2a02:8013:7f3d:10::5", "10.0.10.5"} {
|
||||||
|
if !slices.Contains(got, netip.MustParseAddr(want)) {
|
||||||
|
t.Errorf("%s missing from %v", want, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(got) != 3 {
|
||||||
|
t.Errorf("got %v (10.0.10.5 once, no broadcast or network addresses)", got)
|
||||||
|
}
|
||||||
|
// And the real ones on this machine: at least loopback.
|
||||||
|
if len(procAddrs("/proc/net")) == 0 {
|
||||||
|
t.Log("no addresses in /proc/net here")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
package nscheck
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net"
|
||||||
|
"net/netip"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/miekg/dns"
|
||||||
|
)
|
||||||
|
|
||||||
|
// A query to this host's own address is signed with LocalKey, so a server
|
||||||
|
// with views answers from the view that key selects.
|
||||||
|
func TestLocalKey(t *testing.T) {
|
||||||
|
zone := "example.com."
|
||||||
|
key := &Key{Name: "jarl-view-external.", Algorithm: "hmac-sha256", Secret: "c2VjcmV0LXNlY3JldC1zZWNyZXQtc2VjcmV0LXNlY3I="}
|
||||||
|
pc, err := net.ListenPacket("udp", "127.0.0.1:0")
|
||||||
|
if err != nil {
|
||||||
|
t.Skip(err)
|
||||||
|
}
|
||||||
|
_, port, _ := net.SplitHostPort(pc.LocalAddr().String())
|
||||||
|
srv := &dns.Server{PacketConn: pc, TsigSecret: map[string]string{key.Name: key.Secret},
|
||||||
|
Handler: dns.HandlerFunc(func(w dns.ResponseWriter, req *dns.Msg) {
|
||||||
|
// Signed with the view key: the "external" view's serial.
|
||||||
|
serial := "1"
|
||||||
|
signed := req.IsTsig() != nil && w.TsigStatus() == nil
|
||||||
|
if signed {
|
||||||
|
serial = "2"
|
||||||
|
}
|
||||||
|
m := new(dns.Msg)
|
||||||
|
m.SetReply(req)
|
||||||
|
m.Authoritative = true
|
||||||
|
m.Answer = []dns.RR{rr(zone + " 3600 IN SOA ns1." + zone + " admin." + zone + " " + serial + " 3600 900 1209600 300")}
|
||||||
|
if signed {
|
||||||
|
m.SetTsig(key.Name, dns.HmacSHA256, 300, time.Now().Unix())
|
||||||
|
}
|
||||||
|
w.WriteMsg(m)
|
||||||
|
})}
|
||||||
|
go srv.ActivateAndServe()
|
||||||
|
t.Cleanup(func() { srv.Shutdown() })
|
||||||
|
|
||||||
|
ref := uint32(2)
|
||||||
|
c := &Checker{Timeout: time.Second, Port: port, IsLocal: func(netip.Addr) bool { return false }}
|
||||||
|
if res := c.Serials(context.Background(), zone, &ref, []Target{{Addresses: []string{"127.0.0.1"}}}); res[0].Status != "behind" {
|
||||||
|
t.Errorf("unsigned: %+v (want the other view's serial)", res[0])
|
||||||
|
}
|
||||||
|
c.LocalKey = key
|
||||||
|
if res := c.Serials(context.Background(), zone, &ref, []Target{{Addresses: []string{"127.0.0.1"}}}); res[0].Status != "in_sync" {
|
||||||
|
t.Errorf("signed with the view key: %+v", res[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
+69
-10
@@ -15,10 +15,13 @@ package nscheck
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/hex"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net"
|
"net"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"sort"
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -95,6 +98,11 @@ type Checker struct {
|
|||||||
// IsLocal reports whether an address belongs to this host (default:
|
// IsLocal reports whether an address belongs to this host (default:
|
||||||
// its interface addresses and loopback).
|
// its interface addresses and loopback).
|
||||||
IsLocal func(netip.Addr) bool
|
IsLocal func(netip.Addr) bool
|
||||||
|
// LocalKey, when set, signs the queries sent to this host's own
|
||||||
|
// addresses (when they are asked): with views, the key that selects
|
||||||
|
// the zone's view, so the answer comes from that view and not from
|
||||||
|
// whichever view matches this host's address.
|
||||||
|
LocalKey *Key
|
||||||
// Watch, if set, is told about each DNS query the checks send and
|
// Watch, if set, is told about each DNS query the checks send and
|
||||||
// its outcome as it happens (the web UI shows them while it waits).
|
// its outcome as it happens (the web UI shows them while it waits).
|
||||||
// It is called from several goroutines at once.
|
// It is called from several goroutines at once.
|
||||||
@@ -252,20 +260,67 @@ func IsLocalAddr(a netip.Addr) bool {
|
|||||||
if a.IsLoopback() {
|
if a.IsLoopback() {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
addrs, err := net.InterfaceAddrs()
|
for _, b := range LocalAddrs() {
|
||||||
if err != nil {
|
if b == a.Unmap() {
|
||||||
return false
|
|
||||||
}
|
|
||||||
for _, ia := range addrs {
|
|
||||||
if n, ok := ia.(*net.IPNet); ok {
|
|
||||||
if b, ok := netip.AddrFromSlice(n.IP); ok && b.Unmap() == a.Unmap() {
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// LocalAddrs returns this host's interface addresses. Listing them needs a
|
||||||
|
// netlink socket; where that is not allowed (a systemd unit without
|
||||||
|
// AF_NETLINK in RestrictAddressFamilies), they are read from /proc.
|
||||||
|
func LocalAddrs() []netip.Addr {
|
||||||
|
var out []netip.Addr
|
||||||
|
if addrs, err := net.InterfaceAddrs(); err == nil {
|
||||||
|
for _, ia := range addrs {
|
||||||
|
if n, ok := ia.(*net.IPNet); ok {
|
||||||
|
if b, ok := netip.AddrFromSlice(n.IP); ok {
|
||||||
|
out = append(out, b.Unmap())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
return procAddrs("/proc/net")
|
||||||
|
}
|
||||||
|
|
||||||
|
// procAddrs reads the host's addresses from /proc/net: IPv6 from
|
||||||
|
// if_inet6, IPv4 from the local (/32 host LOCAL) entries of fib_trie.
|
||||||
|
func procAddrs(dir string) []netip.Addr {
|
||||||
|
var out []netip.Addr
|
||||||
|
if b, err := os.ReadFile(filepath.Join(dir, "if_inet6")); err == nil {
|
||||||
|
for _, line := range strings.Split(string(b), "\n") {
|
||||||
|
f := strings.Fields(line)
|
||||||
|
if len(f) == 0 || len(f[0]) != 32 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
raw, err := hex.DecodeString(f[0])
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if a, ok := netip.AddrFromSlice(raw); ok {
|
||||||
|
out = append(out, a)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if b, err := os.ReadFile(filepath.Join(dir, "fib_trie")); err == nil {
|
||||||
|
seen := map[netip.Addr]bool{}
|
||||||
|
var last netip.Addr
|
||||||
|
for _, line := range strings.Split(string(b), "\n") {
|
||||||
|
t := strings.TrimSpace(line)
|
||||||
|
if strings.HasPrefix(t, "|-- ") {
|
||||||
|
last, _ = netip.ParseAddr(strings.TrimPrefix(t, "|-- "))
|
||||||
|
} else if strings.HasPrefix(t, "/32 host LOCAL") && last.IsValid() && !seen[last] {
|
||||||
|
seen[last] = true
|
||||||
|
out = append(out, last)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
// SystemResolver returns the first nameserver in /etc/resolv.conf as
|
// SystemResolver returns the first nameserver in /etc/resolv.conf as
|
||||||
// host:port.
|
// host:port.
|
||||||
func SystemResolver() (string, error) {
|
func SystemResolver() (string, error) {
|
||||||
@@ -465,13 +520,17 @@ func (c *Checker) Serials(ctx context.Context, zone string, ref *uint32, targets
|
|||||||
}
|
}
|
||||||
// Reported here rather than by exchange: the end carries the
|
// Reported here rather than by exchange: the end carries the
|
||||||
// server's status.
|
// server's status.
|
||||||
id := c.startEvent(digLine(r.Address, port, zone, "SOA", true, t.Key))
|
key := t.Key
|
||||||
|
if key == nil && c.LocalKey != nil && IsLocalAddr(ip) {
|
||||||
|
key = c.LocalKey
|
||||||
|
}
|
||||||
|
id := c.startEvent(digLine(r.Address, port, zone, "SOA", true, key))
|
||||||
go func(r Result, key *Key) {
|
go func(r Result, key *Key) {
|
||||||
defer wg.Done()
|
defer wg.Done()
|
||||||
c.soa(ctx, zone, key, ref, &r)
|
c.soa(ctx, zone, key, ref, &r)
|
||||||
c.endEvent(Event{ID: id, Result: &r, RTTMs: r.RTTMs})
|
c.endEvent(Event{ID: id, Result: &r, RTTMs: r.RTTMs})
|
||||||
add(r)
|
add(r)
|
||||||
}(r, t.Key)
|
}(r, key)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
wg.Wait()
|
wg.Wait()
|
||||||
|
|||||||
@@ -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.BlockingPaused() && 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.BlockingPaused() {
|
||||||
|
content = blocking.PausedZoneFile(title)
|
||||||
|
}
|
||||||
|
out.Files = append(out.Files, &File{Path: z.File, Content: []byte(content), Mode: 0o640, Kind: KindPolicy})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -149,7 +149,7 @@ const headerText = "Generated by Jarl. DO NOT EDIT: changes made here are overwr
|
|||||||
// from jarld's configuration (e.g. for query statistics).
|
// from jarld's configuration (e.g. for query statistics).
|
||||||
func Render(s *model.State, p Paths, x Extras) *Output {
|
func Render(s *model.State, p Paths, x Extras) *Output {
|
||||||
out := &Output{Spans: map[string][]Span{}, ZoneFiles: map[int64]string{}}
|
out := &Output{Spans: map[string][]Span{}, ZoneFiles: map[int64]string{}}
|
||||||
keys := renderKeys(s)
|
keys := renderKeys(s) + renderViewKeys(s, x)
|
||||||
out.Files = append(out.Files, &File{Path: p.KeysConfig(), Content: []byte(keys), Mode: 0o640, Kind: KindKeys, Secret: true})
|
out.Files = append(out.Files, &File{Path: p.KeysConfig(), Content: []byte(keys), Mode: 0o640, Kind: KindKeys, Secret: true})
|
||||||
conf, spans := renderConfig(s, p, x, out)
|
conf, spans := renderConfig(s, p, x, out)
|
||||||
out.Files = append(out.Files, &File{Path: p.MainConfig(), Content: []byte(conf), Mode: 0o640, Kind: KindConfig})
|
out.Files = append(out.Files, &File{Path: p.MainConfig(), Content: []byte(conf), Mode: 0o640, Kind: KindConfig})
|
||||||
@@ -350,8 +350,8 @@ func renderConfig(s *model.State, p Paths, x Extras, out *Output) (string, []Spa
|
|||||||
w.write(commentBlock("//", v.Comment))
|
w.write(commentBlock("//", v.Comment))
|
||||||
}
|
}
|
||||||
w.write(fmt.Sprintf("view %s {\n", namedconf.QuoteString(v.Name)))
|
w.write(fmt.Sprintf("view %s {\n", namedconf.QuoteString(v.Name)))
|
||||||
if len(v.MatchClients) > 0 {
|
if mc := viewMatchClients(s, x, v); len(mc) > 0 {
|
||||||
writeAML(w, "match-clients", v.MatchClients)
|
writeAML(w, "match-clients", mc)
|
||||||
}
|
}
|
||||||
if len(v.MatchDestinations) > 0 {
|
if len(v.MatchDestinations) > 0 {
|
||||||
writeAML(w, "match-destinations", v.MatchDestinations)
|
writeAML(w, "match-destinations", v.MatchDestinations)
|
||||||
@@ -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))
|
||||||
|
|||||||
@@ -15,6 +15,9 @@ type Extras struct {
|
|||||||
// Stats, when set, makes named publish what Jarl's statistics
|
// Stats, when set, makes named publish what Jarl's statistics
|
||||||
// collector reads.
|
// collector reads.
|
||||||
Stats *StatsHooks
|
Stats *StatsHooks
|
||||||
|
// ViewKeySeed, when set, gives every view a TSIG key that selects it
|
||||||
|
// (viewkeys.go).
|
||||||
|
ViewKeySeed []byte
|
||||||
}
|
}
|
||||||
|
|
||||||
// StatsHooks describe the statistics channel and response log Jarl needs.
|
// StatsHooks describe the statistics channel and response log Jarl needs.
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
package render
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/hmac"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/base64"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"jarl/internal/model"
|
||||||
|
"jarl/internal/namedconf"
|
||||||
|
)
|
||||||
|
|
||||||
|
// View keys let Jarl choose a view regardless of where a query comes from:
|
||||||
|
// each view gets a TSIG key, and its match-clients starts with that key
|
||||||
|
// (after excluding the keys of the views listed after it, so no earlier
|
||||||
|
// view takes their queries). Queries signed with a view's key always land
|
||||||
|
// in that view. Jarl signs its own checks of this server's addresses with
|
||||||
|
// them (a query from this host would otherwise land in whichever view
|
||||||
|
// matches this host's address), and you can too: dig -k.
|
||||||
|
//
|
||||||
|
// The secrets are derived from a random seed kept in Jarl's database
|
||||||
|
// (Extras.ViewKeySeed), so they stay the same from one apply to the next
|
||||||
|
// and need no storage of their own. They are in keys.conf with the other
|
||||||
|
// keys.
|
||||||
|
|
||||||
|
// ViewKeyAlgorithm is the view keys' algorithm.
|
||||||
|
const ViewKeyAlgorithm = "hmac-sha256"
|
||||||
|
|
||||||
|
// ViewKeyName returns the name of a view's key.
|
||||||
|
func ViewKeyName(view string) string { return model.ViewKeyName(view) }
|
||||||
|
|
||||||
|
// IsViewKey reports whether a key name is a view key's.
|
||||||
|
func IsViewKey(name string) bool {
|
||||||
|
return strings.HasPrefix(strings.ToLower(strings.TrimSuffix(name, ".")), model.ViewKeyPrefix)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ViewKeySecret returns the secret of the key of the view with this ID.
|
||||||
|
func ViewKeySecret(seed []byte, viewID int64) string {
|
||||||
|
m := hmac.New(sha256.New, seed)
|
||||||
|
m.Write([]byte("jarl view key " + strconv.FormatInt(viewID, 10)))
|
||||||
|
return base64.StdEncoding.EncodeToString(m.Sum(nil))
|
||||||
|
}
|
||||||
|
|
||||||
|
// viewKeysOn reports whether view keys are rendered.
|
||||||
|
func viewKeysOn(s *model.State, x Extras) bool { return len(x.ViewKeySeed) > 0 && len(s.Views) > 0 }
|
||||||
|
|
||||||
|
// renderViewKeys returns the key statements of the views' keys.
|
||||||
|
func renderViewKeys(s *model.State, x Extras) string {
|
||||||
|
if !viewKeysOn(s, x) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
var sb strings.Builder
|
||||||
|
sb.WriteString("\n" + commentBlock("//", "View keys: a query signed with one of these always lands in its view.\nJarl signs its own checks of this server with them; dig -k works too."))
|
||||||
|
for _, v := range s.OrderedViews() {
|
||||||
|
sb.WriteString("key " + namedconf.QuoteString(ViewKeyName(v.Name)) + " {\n\talgorithm " + ViewKeyAlgorithm + ";\n\tsecret " + namedconf.QuoteString(ViewKeySecret(x.ViewKeySeed, v.ID)) + ";\n};\n")
|
||||||
|
}
|
||||||
|
return sb.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// viewMatchClients returns a view's match-clients with the view keys in
|
||||||
|
// front: "!key" for the keys of the views after it, then its own key, then
|
||||||
|
// the view's own list ("any" when it has none, which is what an absent
|
||||||
|
// match-clients means).
|
||||||
|
func viewMatchClients(s *model.State, x Extras, v *model.View) []string {
|
||||||
|
if !viewKeysOn(s, x) {
|
||||||
|
return v.MatchClients
|
||||||
|
}
|
||||||
|
var out []string
|
||||||
|
after := false
|
||||||
|
for _, o := range s.OrderedViews() {
|
||||||
|
if after {
|
||||||
|
out = append(out, "!key "+namedconf.QuoteString(ViewKeyName(o.Name)))
|
||||||
|
}
|
||||||
|
if o.ID == v.ID {
|
||||||
|
after = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out = append(out, "key "+namedconf.QuoteString(ViewKeyName(v.Name)))
|
||||||
|
if len(v.MatchClients) == 0 {
|
||||||
|
return append(out, "any")
|
||||||
|
}
|
||||||
|
return append(out, v.MatchClients...)
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
package render
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"jarl/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestViewKeys(t *testing.T) {
|
||||||
|
s := model.NewState()
|
||||||
|
s.Views[1] = &model.View{ID: 1, Name: "internal", Position: 0, MatchClients: []string{"homelab", "localhost"}}
|
||||||
|
s.Views[2] = &model.View{ID: 2, Name: "external", Position: 1}
|
||||||
|
p := Paths{ConfigDir: "/etc/bind/jarl", ZoneDir: "/var/lib/bind/jarl", CacheDir: "/var/cache/bind/jarl"}
|
||||||
|
seed := []byte("0123456789abcdef0123456789abcdef")
|
||||||
|
files := func(x Extras) (conf, keys string) {
|
||||||
|
for _, f := range Render(s, p, x).Files {
|
||||||
|
switch f.Kind {
|
||||||
|
case KindConfig:
|
||||||
|
conf = string(f.Content)
|
||||||
|
case KindKeys:
|
||||||
|
keys = string(f.Content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
conf, keys := files(Extras{ViewKeySeed: seed})
|
||||||
|
for _, want := range []string{
|
||||||
|
// An earlier view mustn't take a later view's key.
|
||||||
|
"match-clients {\n\t\t!key jarl-view-external;\n\t\tkey jarl-view-internal;\n\t\thomelab;\n\t\tlocalhost;\n\t};",
|
||||||
|
// A view without a list matched everyone: it still does.
|
||||||
|
"match-clients { key jarl-view-external; any; };",
|
||||||
|
} {
|
||||||
|
if !strings.Contains(conf, want) {
|
||||||
|
t.Errorf("named.conf lacks\n%s\n---\n%s", want, conf)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sec := ViewKeySecret(seed, 2)
|
||||||
|
if !strings.Contains(keys, "key \"jarl-view-external\" {\n\talgorithm hmac-sha256;\n\tsecret \""+sec+"\";\n};") || !strings.Contains(keys, "jarl-view-internal") {
|
||||||
|
t.Errorf("keys.conf:\n%s", keys)
|
||||||
|
}
|
||||||
|
// The same seed gives the same secret; another seed doesn't.
|
||||||
|
if ViewKeySecret(seed, 2) != sec || ViewKeySecret([]byte("other"), 2) == sec || ViewKeySecret(seed, 1) == sec {
|
||||||
|
t.Error("secrets aren't derived from the seed and the view")
|
||||||
|
}
|
||||||
|
// Without a seed (or without views), nothing changes.
|
||||||
|
conf, keys = files(Extras{})
|
||||||
|
if strings.Contains(conf, "jarl-view-") || strings.Contains(keys, "jarl-view-") {
|
||||||
|
t.Errorf("view keys without a seed:\n%s", conf)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,44 @@ 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, "paused_indefinitely": st.Settings.BlockingPausedIndefinitely})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// pauseBlocking: POST /blocking/pause {"minutes": 10} lets every name
|
||||||
|
// through until then, {"indefinitely": true} until resumed; 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"`
|
||||||
|
Indefinitely bool `json:"indefinitely"`
|
||||||
|
}
|
||||||
|
if err := decodeJSON(r, &body); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if body.Indefinitely {
|
||||||
|
in.Indefinitely = true
|
||||||
|
return s.mutate(w, r, p, "", []engine.Request{engine.NewRequest(engine.OpBlockingPause, 0, in)}, nil)
|
||||||
|
}
|
||||||
|
if body.Minutes < 1 || time.Duration(body.Minutes)*time.Minute > engine.MaxBlockingPause {
|
||||||
|
return badRequest("pause for 1 to %d minutes, or indefinitely", 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,10 @@ 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
|
||||||
|
resp.BlockingPausedIndefinitely = st.Settings.BlockingPausedIndefinitely
|
||||||
|
}
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, resp)
|
writeJSON(w, http.StatusOK, resp)
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import (
|
|||||||
"jarl/internal/dnsname"
|
"jarl/internal/dnsname"
|
||||||
"jarl/internal/engine"
|
"jarl/internal/engine"
|
||||||
"jarl/internal/model"
|
"jarl/internal/model"
|
||||||
|
"jarl/internal/render"
|
||||||
"jarl/internal/rr"
|
"jarl/internal/rr"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -92,6 +93,31 @@ func (s *Server) getView(w http.ResponseWriter, r *http.Request, p *auth.Princip
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// viewKey: GET /views/{id}/key is the TSIG key that selects the view
|
||||||
|
// (render/viewkeys.go), with its secret: like revealing a TSIG key's, it
|
||||||
|
// needs tsig:write and is audited.
|
||||||
|
func (s *Server) viewKey(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||||
|
st, err := s.state(r)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
v, err := s.viewByPath(r, st, p)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := require(p, auth.Action{Resource: auth.ResTSIG, Write: true}); err != nil {
|
||||||
|
return forbidden("showing a view's key needs tsig:write")
|
||||||
|
}
|
||||||
|
seed, err := engine.ViewKeySeed(r.Context(), s.store)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
name := render.ViewKeyName(v.Name)
|
||||||
|
s.eng.Audit(r.Context(), p, "tsig.reveal", "view", v.ID, v.Name, "success", "view key "+name+" shown")
|
||||||
|
writeJSON(w, http.StatusOK, map[string]string{"name": name, "algorithm": render.ViewKeyAlgorithm, "secret": render.ViewKeySecret(seed, v.ID)})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Server) viewObject(r *http.Request) func(st *model.State, id int64) any {
|
func (s *Server) viewObject(r *http.Request) func(st *model.State, id int64) any {
|
||||||
return func(st *model.State, id int64) any {
|
return func(st *model.State, id int64) any {
|
||||||
if v := st.Views[id]; v != nil {
|
if v := st.Views[id]; v != nil {
|
||||||
|
|||||||
@@ -160,6 +160,9 @@ func (s *Server) zoneDelegation(w http.ResponseWriter, r *http.Request, p *auth.
|
|||||||
return badRequest("the delegation check compares the parent's delegation with the zone's NS records, so it needs a primary zone (this is a %s zone; check it where its primary is)", z.Type)
|
return badRequest("the delegation check compares the parent's delegation with the zone's NS records, so it needs a primary zone (this is a %s zone; check it where its primary is)", z.Type)
|
||||||
}
|
}
|
||||||
c := s.checker()
|
c := s.checker()
|
||||||
|
// This server's own addresses are asked too: sign those queries with
|
||||||
|
// the key of the zone's view, so they get that view's answer.
|
||||||
|
c.LocalKey = s.eng.ViewKey(r.Context(), st, z)
|
||||||
if v := r.URL.Query().Get("resolver"); v != "" {
|
if v := r.URL.Query().Get("resolver"); v != "" {
|
||||||
// An address only, always port 53: this is a DNS resolver.
|
// An address only, always port 53: this is a DNS resolver.
|
||||||
a, err := netip.ParseAddr(v)
|
a, err := netip.ParseAddr(v)
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package server
|
|||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
|
"path/filepath"
|
||||||
"slices"
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -82,7 +83,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
|
||||||
}
|
}
|
||||||
@@ -240,3 +241,34 @@ func clientNames(st *model.State, want map[netip.Addr]bool, readable func(*model
|
|||||||
}
|
}
|
||||||
return ptr
|
return ptr
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// statsServer: GET /stats/server is the latest server statistics (named's
|
||||||
|
// memory, CPU, cache and sockets, the host), named's client counts and
|
||||||
|
// limits (rndc status), and the space left on the file systems Jarl and
|
||||||
|
// named write to.
|
||||||
|
func (s *Server) statsServer(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||||
|
if _, err := s.statsQuerier(p); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
out := map[string]any{"server": s.cfg.Stats.Server()}
|
||||||
|
if ns, err := s.eng.NamedStatus(r.Context()); err == nil {
|
||||||
|
out["named"] = map[string]string{"recursive_clients": ns.RecursiveClients, "tcp_clients": ns.TCPClients, "zones": ns.Zones,
|
||||||
|
"cpus": ns.CPUs, "worker_threads": ns.WorkerThreads, "last_configured": ns.LastConfigured}
|
||||||
|
} else {
|
||||||
|
out["named_error"] = err.Error()
|
||||||
|
}
|
||||||
|
paths := s.eng.Config().Paths
|
||||||
|
dirs := map[string]string{"zone files": paths.ZoneDir, "Jarl's cache and blocklists": paths.CacheDir, "named.conf": paths.ConfigDir}
|
||||||
|
labels := []string{"zone files", "Jarl's cache and blocklists", "named.conf"}
|
||||||
|
if lf := s.cfg.Stats.Options().LogFile; lf != "" {
|
||||||
|
dirs["named's logs"] = filepath.Dir(lf)
|
||||||
|
labels = append(labels, "named's logs")
|
||||||
|
}
|
||||||
|
if db := s.cfg.Stats.Store().Path(); db != "" {
|
||||||
|
dirs["statistics database"] = filepath.Dir(db)
|
||||||
|
labels = append(labels, "statistics database")
|
||||||
|
}
|
||||||
|
out["disks"] = stats.DiskUsage(labels, dirs)
|
||||||
|
writeJSON(w, http.StatusOK, out)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ func (s *Server) routes() {
|
|||||||
m.HandleFunc("POST "+p+"/views", s.authed(s.createView))
|
m.HandleFunc("POST "+p+"/views", s.authed(s.createView))
|
||||||
m.HandleFunc("POST "+p+"/views/order", s.authed(s.reorderViews))
|
m.HandleFunc("POST "+p+"/views/order", s.authed(s.reorderViews))
|
||||||
m.HandleFunc("GET "+p+"/views/{id}", s.authed(s.getView))
|
m.HandleFunc("GET "+p+"/views/{id}", s.authed(s.getView))
|
||||||
|
m.HandleFunc("GET "+p+"/views/{id}/key", s.authed(s.viewKey))
|
||||||
m.HandleFunc("PATCH "+p+"/views/{id}", s.authed(s.updateView))
|
m.HandleFunc("PATCH "+p+"/views/{id}", s.authed(s.updateView))
|
||||||
m.HandleFunc("DELETE "+p+"/views/{id}", s.authed(s.deleteView))
|
m.HandleFunc("DELETE "+p+"/views/{id}", s.authed(s.deleteView))
|
||||||
m.HandleFunc("PUT "+p+"/views/{id}/color", s.authed(s.setViewColor))
|
m.HandleFunc("PUT "+p+"/views/{id}/color", s.authed(s.setViewColor))
|
||||||
@@ -69,6 +70,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))
|
||||||
@@ -153,6 +157,7 @@ func (s *Server) routes() {
|
|||||||
m.HandleFunc("GET "+p+"/stats/timeline", s.authed(s.statsTimeline))
|
m.HandleFunc("GET "+p+"/stats/timeline", s.authed(s.statsTimeline))
|
||||||
m.HandleFunc("GET "+p+"/stats/heatmap", s.authed(s.statsHeatmap))
|
m.HandleFunc("GET "+p+"/stats/heatmap", s.authed(s.statsHeatmap))
|
||||||
m.HandleFunc("GET "+p+"/stats/breakdown", s.authed(s.statsBreakdown))
|
m.HandleFunc("GET "+p+"/stats/breakdown", s.authed(s.statsBreakdown))
|
||||||
|
m.HandleFunc("GET "+p+"/stats/server", s.authed(s.statsServer))
|
||||||
m.HandleFunc(p+"/", func(w http.ResponseWriter, r *http.Request) {
|
m.HandleFunc(p+"/", func(w http.ResponseWriter, r *http.Request) {
|
||||||
writeError(w, http.StatusNotFound, "not_found", "no such API endpoint: "+r.Method+" "+r.URL.Path)
|
writeError(w, http.StatusNotFound, "not_found", "no such API endpoint: "+r.Method+" "+r.URL.Path)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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).
|
||||||
@@ -45,6 +47,9 @@ type Options struct {
|
|||||||
Anonymize bool
|
Anonymize bool
|
||||||
Exclude []netip.Prefix
|
Exclude []netip.Prefix
|
||||||
Location *time.Location // for days and log timestamps (default: local time)
|
Location *time.Location // for days and log timestamps (default: local time)
|
||||||
|
// MainConfig is named's main configuration file; it tells named's
|
||||||
|
// process apart from other named processes (for its memory and CPU).
|
||||||
|
MainConfig string
|
||||||
// EnsureLogging turns named's response logging on (rndc responselog
|
// EnsureLogging turns named's response logging on (rndc responselog
|
||||||
// on). It is called at start and when named answers queries but no
|
// on). It is called at start and when named answers queries but no
|
||||||
// log lines arrive.
|
// log lines arrive.
|
||||||
@@ -105,6 +110,10 @@ type Collector struct {
|
|||||||
// rewritten holds queries named blocked (client#port|name) until their
|
// rewritten holds queries named blocked (client#port|name) until their
|
||||||
// response line arrives.
|
// response line arrives.
|
||||||
rewritten map[string]bool
|
rewritten map[string]bool
|
||||||
|
// proc is the last sample of named's process, server the latest server
|
||||||
|
// statistics (server.go).
|
||||||
|
proc *namedProc
|
||||||
|
server *Server
|
||||||
}
|
}
|
||||||
|
|
||||||
// maxKeys bounds the per-hour maps; rarely seen keys are dropped first.
|
// maxKeys bounds the per-hour maps; rarely seen keys are dropped first.
|
||||||
@@ -233,13 +242,17 @@ func (c *Collector) start(ctx context.Context) {
|
|||||||
}
|
}
|
||||||
c.ensureLogging(ctx)
|
c.ensureLogging(ctx)
|
||||||
// The first poll is the baseline for the counters.
|
// The first poll is the baseline for the counters.
|
||||||
if snap, err := c.poller.Poll(ctx); err == nil {
|
snap, err := c.poller.Poll(ctx)
|
||||||
|
if err == nil {
|
||||||
c.prev = snap
|
c.prev = snap
|
||||||
t := c.now()
|
t := c.now()
|
||||||
c.status.LastPoll = &t
|
c.status.LastPoll = &t
|
||||||
} else {
|
} else {
|
||||||
c.status.PollError = err.Error()
|
c.status.PollError = err.Error()
|
||||||
}
|
}
|
||||||
|
c.mu.Lock()
|
||||||
|
c.sampleServer(-1, c.now(), snap)
|
||||||
|
c.mu.Unlock()
|
||||||
c.prune(ctx)
|
c.prune(ctx)
|
||||||
c.log.Info("statistics collector started", "query_log", c.opts.QueryLog, "statistics_channel", c.opts.StatisticsChannel)
|
c.log.Info("statistics collector started", "query_log", c.opts.QueryLog, "statistics_channel", c.opts.StatisticsChannel)
|
||||||
}
|
}
|
||||||
@@ -380,6 +393,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"
|
||||||
@@ -467,6 +481,10 @@ func (c *Collector) flush(ctx context.Context, final bool) {
|
|||||||
}
|
}
|
||||||
c.prev = snap
|
c.prev = snap
|
||||||
}
|
}
|
||||||
|
if !final {
|
||||||
|
// Gauges are sampled once a minute, for the minute that ended.
|
||||||
|
c.sampleServer(cur-60, now, snap)
|
||||||
|
}
|
||||||
b := &Batch{}
|
b := &Batch{}
|
||||||
first := c.status.FirstData == nil
|
first := c.status.FirstData == nil
|
||||||
for m, vals := range c.minutes {
|
for m, vals := range c.minutes {
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
//go:build !unix
|
||||||
|
|
||||||
|
package stats
|
||||||
|
|
||||||
|
// DiskUsage is not available on this system.
|
||||||
|
func DiskUsage(labels []string, paths map[string]string) []Disk { return nil }
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
//go:build unix
|
||||||
|
|
||||||
|
package stats
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"syscall"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DiskUsage returns the space on the file systems holding paths (label ->
|
||||||
|
// directory), one entry per file system with the labels of the paths on
|
||||||
|
// it, in the order of labels.
|
||||||
|
func DiskUsage(labels []string, paths map[string]string) []Disk {
|
||||||
|
var out []Disk
|
||||||
|
byDev := map[string]int{}
|
||||||
|
for _, l := range labels {
|
||||||
|
p := paths[l]
|
||||||
|
fi, err := os.Stat(p)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
dev := fmt.Sprint(p)
|
||||||
|
if st, ok := fi.Sys().(*syscall.Stat_t); ok {
|
||||||
|
dev = fmt.Sprint(st.Dev)
|
||||||
|
}
|
||||||
|
if i, ok := byDev[dev]; ok {
|
||||||
|
out[i].Holds = append(out[i].Holds, l)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var fs syscall.Statfs_t
|
||||||
|
if err := syscall.Statfs(p, &fs); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
byDev[dev] = len(out)
|
||||||
|
out = append(out, Disk{Path: p, Holds: []string{l}, Total: int64(fs.Blocks) * int64(fs.Bsize), Free: int64(fs.Bavail) * int64(fs.Bsize)})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
+38
-15
@@ -15,11 +15,22 @@ import (
|
|||||||
type Snapshot struct {
|
type Snapshot struct {
|
||||||
BootTime string
|
BootTime string
|
||||||
Values map[string]int64
|
Values map[string]int64
|
||||||
|
// Gauges are named's current values (memory, cache size, sockets),
|
||||||
|
// not counters.
|
||||||
|
Gauges map[string]int64
|
||||||
|
Version string
|
||||||
|
ConfigTime string
|
||||||
}
|
}
|
||||||
|
|
||||||
// serverJSON is the part of /json/v1/server Jarl uses.
|
// serverJSON is the part of /json/v1 Jarl uses.
|
||||||
type serverJSON struct {
|
type serverJSON struct {
|
||||||
BootTime string `json:"boot-time"`
|
BootTime string `json:"boot-time"`
|
||||||
|
ConfigTime string `json:"config-time"`
|
||||||
|
Version string `json:"version"`
|
||||||
|
SockStats map[string]int64 `json:"sockstats"`
|
||||||
|
Memory struct {
|
||||||
|
InUse int64 `json:"InUse"`
|
||||||
|
} `json:"memory"`
|
||||||
Opcodes map[string]int64 `json:"opcodes"`
|
Opcodes map[string]int64 `json:"opcodes"`
|
||||||
RCodes map[string]int64 `json:"rcodes"`
|
RCodes map[string]int64 `json:"rcodes"`
|
||||||
QTypes map[string]int64 `json:"qtypes"`
|
QTypes map[string]int64 `json:"qtypes"`
|
||||||
@@ -28,6 +39,7 @@ type serverJSON struct {
|
|||||||
Resolver struct {
|
Resolver struct {
|
||||||
Stats map[string]int64 `json:"stats"`
|
Stats map[string]int64 `json:"stats"`
|
||||||
CacheStats map[string]int64 `json:"cachestats"`
|
CacheStats map[string]int64 `json:"cachestats"`
|
||||||
|
Cache map[string]int64 `json:"cache"` // RRsets in the cache by type
|
||||||
} `json:"resolver"`
|
} `json:"resolver"`
|
||||||
} `json:"views"`
|
} `json:"views"`
|
||||||
}
|
}
|
||||||
@@ -77,7 +89,21 @@ func Flatten(body []byte) (*Snapshot, error) {
|
|||||||
v[name] = n
|
v[name] = n
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
g := map[string]int64{"named:mem": j.Memory.InUse,
|
||||||
|
"named:recursion-highwater": j.NSStats["RecursHighwater"], "named:tcp-highwater": j.NSStats["TCPConnHighWater"]}
|
||||||
|
for k, n := range j.SockStats {
|
||||||
|
switch {
|
||||||
|
case strings.HasPrefix(k, "UDP") && strings.HasSuffix(k, "Active"):
|
||||||
|
g["sockets:udp"] += n
|
||||||
|
case strings.HasPrefix(k, "TCP") && strings.HasSuffix(k, "Active"):
|
||||||
|
g["sockets:tcp"] += n
|
||||||
|
}
|
||||||
|
}
|
||||||
for _, view := range j.Views {
|
for _, view := range j.Views {
|
||||||
|
g["cache:mem"] += view.Resolver.CacheStats["TreeMemInUse"] + view.Resolver.CacheStats["HeapMemInUse"]
|
||||||
|
for _, n := range view.Resolver.Cache {
|
||||||
|
g["cache:rrsets"] += n
|
||||||
|
}
|
||||||
for k, name := range rttKeys {
|
for k, name := range rttKeys {
|
||||||
v[name] += view.Resolver.Stats[k]
|
v[name] += view.Resolver.Stats[k]
|
||||||
}
|
}
|
||||||
@@ -92,27 +118,24 @@ func Flatten(body []byte) (*Snapshot, error) {
|
|||||||
delete(v, k)
|
delete(v, k)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return &Snapshot{BootTime: j.BootTime, Values: v}, nil
|
return &Snapshot{BootTime: j.BootTime, Values: v, Gauges: g, Version: j.Version, ConfigTime: j.ConfigTime}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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
|
||||||
@@ -123,13 +146,13 @@ func Delta(prev, cur *Snapshot) map[string]int64 {
|
|||||||
|
|
||||||
// Poller reads named's statistics channel.
|
// Poller reads named's statistics channel.
|
||||||
type Poller struct {
|
type Poller struct {
|
||||||
URL string // e.g. http://127.0.0.1:8058/json/v1/server
|
URL string // e.g. http://127.0.0.1:8058/json/v1 (server, memory and sockets)
|
||||||
Client *http.Client
|
Client *http.Client
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewPoller returns a poller for a statistics channel address.
|
// NewPoller returns a poller for a statistics channel address.
|
||||||
func NewPoller(addr string) *Poller {
|
func NewPoller(addr string) *Poller {
|
||||||
return &Poller{URL: "http://" + addr + "/json/v1/server", Client: &http.Client{Timeout: 10 * time.Second}}
|
return &Poller{URL: "http://" + addr + "/json/v1", Client: &http.Client{Timeout: 10 * time.Second}}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Poll fetches a snapshot.
|
// Poll fetches a snapshot.
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
package stats
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Server statistics from /proc: named's process and the host. They need no
|
||||||
|
// privileges: /proc/PID/stat and /proc/PID/status are readable by every
|
||||||
|
// user (unless /proc is mounted with hidepid).
|
||||||
|
|
||||||
|
// clockTicks is USER_HZ, the unit of the CPU times in /proc/PID/stat. It
|
||||||
|
// is 100 on every Linux architecture Jarl runs on.
|
||||||
|
const clockTicks = 100
|
||||||
|
|
||||||
|
// procRoot is where /proc is (tests point it elsewhere).
|
||||||
|
var procRoot = "/proc"
|
||||||
|
|
||||||
|
// namedProc is a sample of named's process.
|
||||||
|
type namedProc struct {
|
||||||
|
PID int
|
||||||
|
RSS int64 // bytes
|
||||||
|
Threads int64
|
||||||
|
CPU int64 // user + system time, in clock ticks
|
||||||
|
At time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// findNamed returns the PID of named: the process named "named" whose
|
||||||
|
// command line mentions mainConfig, or else the oldest one (the lowest
|
||||||
|
// PID). 0 when there is none.
|
||||||
|
func findNamed(mainConfig string) int {
|
||||||
|
entries, err := os.ReadDir(procRoot)
|
||||||
|
if err != nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
best := 0
|
||||||
|
for _, e := range entries {
|
||||||
|
pid, err := strconv.Atoi(e.Name())
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
comm, err := os.ReadFile(filepath.Join(procRoot, e.Name(), "comm"))
|
||||||
|
if err != nil || strings.TrimSpace(string(comm)) != "named" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if mainConfig != "" {
|
||||||
|
if cmd, err := os.ReadFile(filepath.Join(procRoot, e.Name(), "cmdline")); err == nil && bytes.Contains(cmd, []byte(mainConfig)) {
|
||||||
|
return pid
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if best == 0 || pid < best {
|
||||||
|
best = pid
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return best
|
||||||
|
}
|
||||||
|
|
||||||
|
// readNamedProc samples a process.
|
||||||
|
func readNamedProc(pid int, now time.Time) (*namedProc, bool) {
|
||||||
|
dir := filepath.Join(procRoot, strconv.Itoa(pid))
|
||||||
|
stat, err := os.ReadFile(filepath.Join(dir, "stat"))
|
||||||
|
if err != nil {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
// The command name is in parentheses and may contain spaces: the
|
||||||
|
// fields that follow start after the last ')'.
|
||||||
|
i := bytes.LastIndexByte(stat, ')')
|
||||||
|
if i < 0 {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
f := strings.Fields(string(stat[i+1:]))
|
||||||
|
// f[0] is field 3 (state); utime and stime are fields 14 and 15.
|
||||||
|
if len(f) < 13 {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
utime, _ := strconv.ParseInt(f[11], 10, 64)
|
||||||
|
stime, _ := strconv.ParseInt(f[12], 10, 64)
|
||||||
|
p := &namedProc{PID: pid, CPU: utime + stime, At: now}
|
||||||
|
status, err := os.ReadFile(filepath.Join(dir, "status"))
|
||||||
|
if err != nil {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
for _, line := range strings.Split(string(status), "\n") {
|
||||||
|
k, v, ok := strings.Cut(line, ":")
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
switch k {
|
||||||
|
case "VmRSS":
|
||||||
|
p.RSS = kib(v)
|
||||||
|
case "Threads":
|
||||||
|
p.Threads, _ = strconv.ParseInt(strings.TrimSpace(v), 10, 64)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return p, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// kib parses a "1234 kB" value from /proc into bytes.
|
||||||
|
func kib(v string) int64 {
|
||||||
|
n, _ := strconv.ParseInt(strings.TrimSpace(strings.TrimSuffix(strings.TrimSpace(v), "kB")), 10, 64)
|
||||||
|
return n * 1024
|
||||||
|
}
|
||||||
|
|
||||||
|
// Host is the machine's memory, load and uptime.
|
||||||
|
type Host struct {
|
||||||
|
MemTotal int64 `json:"mem_total"`
|
||||||
|
MemAvailable int64 `json:"mem_available"`
|
||||||
|
Load []float64 `json:"load"` // 1, 5 and 15 minutes
|
||||||
|
CPUs int `json:"cpus"`
|
||||||
|
Uptime int64 `json:"uptime"` // seconds
|
||||||
|
}
|
||||||
|
|
||||||
|
func readHost() *Host {
|
||||||
|
h := &Host{CPUs: runtime.NumCPU()}
|
||||||
|
if b, err := os.ReadFile(filepath.Join(procRoot, "meminfo")); err == nil {
|
||||||
|
for _, line := range strings.Split(string(b), "\n") {
|
||||||
|
k, v, _ := strings.Cut(line, ":")
|
||||||
|
switch k {
|
||||||
|
case "MemTotal":
|
||||||
|
h.MemTotal = kib(v)
|
||||||
|
case "MemAvailable":
|
||||||
|
h.MemAvailable = kib(v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if b, err := os.ReadFile(filepath.Join(procRoot, "loadavg")); err == nil {
|
||||||
|
for _, f := range strings.Fields(string(b))[:min(3, len(strings.Fields(string(b))))] {
|
||||||
|
x, _ := strconv.ParseFloat(f, 64)
|
||||||
|
h.Load = append(h.Load, x)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if b, err := os.ReadFile(filepath.Join(procRoot, "uptime")); err == nil {
|
||||||
|
if f := strings.Fields(string(b)); len(f) > 0 {
|
||||||
|
x, _ := strconv.ParseFloat(f[0], 64)
|
||||||
|
h.Uptime = int64(x)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return h
|
||||||
|
}
|
||||||
+26
-3
@@ -265,7 +265,8 @@ type Timeline struct {
|
|||||||
Series []*Series `json:"series"`
|
Series []*Series `json:"series"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Series is one counter over time.
|
// Series is one counter over time. For a gauge (g:KEY) the values are
|
||||||
|
// averages, and -1 where there is no sample.
|
||||||
type Series struct {
|
type Series struct {
|
||||||
Key string `json:"key"`
|
Key string `json:"key"`
|
||||||
Values []int64 `json:"values"`
|
Values []int64 `json:"values"`
|
||||||
@@ -340,8 +341,8 @@ func (q *Querier) Timeline(ctx context.Context, since time.Duration, step string
|
|||||||
expanded = append(expanded, k)
|
expanded = append(expanded, k)
|
||||||
}
|
}
|
||||||
tl := &Timeline{Step: st.name, Times: starts, Series: []*Series{}}
|
tl := &Timeline{Step: st.name, Times: starts, Series: []*Series{}}
|
||||||
for _, k := range expanded {
|
bucketed := func(key string) ([]int64, error) {
|
||||||
raw, err := q.Store.series(ctx, st.res, from, to, k)
|
raw, err := q.Store.series(ctx, st.res, from, to, key)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -356,6 +357,28 @@ func (q *Querier) Timeline(ctx context.Context, since time.Duration, step string
|
|||||||
vals[i] += v
|
vals[i] += v
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return vals, nil
|
||||||
|
}
|
||||||
|
for _, k := range expanded {
|
||||||
|
vals, err := bucketed(k)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
// A gauge (g:KEY) is the average of its samples: its sum divided
|
||||||
|
// by gn:KEY, their number. A bucket without samples is -1.
|
||||||
|
if g, ok := strings.CutPrefix(k, "g:"); ok {
|
||||||
|
n, err := bucketed("gn:" + g)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for i := range vals {
|
||||||
|
if n[i] > 0 {
|
||||||
|
vals[i] /= n[i]
|
||||||
|
} else {
|
||||||
|
vals[i] = -1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
tl.Series = append(tl.Series, &Series{Key: k, Values: vals})
|
tl.Series = append(tl.Series, &Series{Key: k, Values: vals})
|
||||||
}
|
}
|
||||||
return tl, nil
|
return tl, nil
|
||||||
|
|||||||
@@ -0,0 +1,112 @@
|
|||||||
|
package stats
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
// Server is the latest server statistics: named's process, memory, cache
|
||||||
|
// and sockets, and the host's memory and load. It is sampled with the
|
||||||
|
// counters, once a minute.
|
||||||
|
type Server struct {
|
||||||
|
At time.Time `json:"at"`
|
||||||
|
Version string `json:"version,omitempty"`
|
||||||
|
BootTime string `json:"boot_time,omitempty"`
|
||||||
|
ConfigTime string `json:"config_time,omitempty"`
|
||||||
|
// From /proc (0 when named's process isn't visible).
|
||||||
|
PID int `json:"pid,omitempty"`
|
||||||
|
RSS int64 `json:"rss,omitempty"` // resident memory, bytes
|
||||||
|
Threads int64 `json:"threads,omitempty"`
|
||||||
|
CPUPercent float64 `json:"cpu_percent"` // of one CPU, over the last minute
|
||||||
|
// From the statistics channel.
|
||||||
|
MemInUse int64 `json:"mem_in_use"` // named's own count of the memory it uses
|
||||||
|
CacheRRsets int64 `json:"cache_rrsets"`
|
||||||
|
CacheMem int64 `json:"cache_mem"`
|
||||||
|
SocketsUDP int64 `json:"sockets_udp"`
|
||||||
|
SocketsTCP int64 `json:"sockets_tcp"`
|
||||||
|
RecursionHighwater int64 `json:"recursion_highwater"` // most recursive clients at once since named started
|
||||||
|
TCPHighwater int64 `json:"tcp_highwater"`
|
||||||
|
Host *Host `json:"host"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gauge keys: each is stored as two counters, "g:KEY" (the sum of the
|
||||||
|
// minute samples) and "gn:KEY" (their number), so hours and days add up
|
||||||
|
// like counters and a bucket's value is their average (see Timeline).
|
||||||
|
const (
|
||||||
|
GaugeRSS = "named:rss"
|
||||||
|
GaugeMem = "named:mem"
|
||||||
|
GaugeCPU = "named:cpu" // tenths of a percent of one CPU
|
||||||
|
GaugeCacheMem = "cache:mem"
|
||||||
|
GaugeCacheRRs = "cache:rrsets"
|
||||||
|
GaugeHostMem = "host:mem-used"
|
||||||
|
GaugeHostLoad = "host:load" // the 1-minute load average, times 100
|
||||||
|
)
|
||||||
|
|
||||||
|
// gauge records a sample of a gauge for a minute (none when minute < 0).
|
||||||
|
func (c *Collector) gauge(minute int64, key string, v int64) {
|
||||||
|
if minute < 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.count(minute, "g:"+key, v)
|
||||||
|
c.count(minute, "gn:"+key, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// sampleServer records the gauges for a minute and keeps the latest
|
||||||
|
// values (minute -1: only those). snap is nil when named's statistics
|
||||||
|
// channel didn't answer.
|
||||||
|
// Called with c.mu held.
|
||||||
|
func (c *Collector) sampleServer(minute int64, now time.Time, snap *Snapshot) {
|
||||||
|
srv := &Server{At: now, Host: readHost()}
|
||||||
|
if c.proc == nil || c.proc.PID == 0 {
|
||||||
|
c.proc = &namedProc{PID: findNamed(c.opts.MainConfig)}
|
||||||
|
}
|
||||||
|
p, ok := readNamedProc(c.proc.PID, now)
|
||||||
|
if !ok {
|
||||||
|
// named restarted with a new PID: look again next minute.
|
||||||
|
c.proc = nil
|
||||||
|
} else {
|
||||||
|
srv.PID, srv.RSS, srv.Threads = p.PID, p.RSS, p.Threads
|
||||||
|
if prev := c.proc; prev.At.Before(now) && !prev.At.IsZero() && p.CPU >= prev.CPU {
|
||||||
|
// Tenths of a percent of one CPU: ticks / clockTicks is CPU seconds.
|
||||||
|
permille := (p.CPU - prev.CPU) * 1000 / clockTicks / max(int64(now.Sub(prev.At)/time.Second), 1)
|
||||||
|
srv.CPUPercent = float64(permille) / 10
|
||||||
|
c.gauge(minute, GaugeCPU, permille)
|
||||||
|
}
|
||||||
|
c.proc = p
|
||||||
|
c.gauge(minute, GaugeRSS, p.RSS)
|
||||||
|
}
|
||||||
|
if h := srv.Host; h.MemTotal > 0 {
|
||||||
|
c.gauge(minute, GaugeHostMem, h.MemTotal-h.MemAvailable)
|
||||||
|
}
|
||||||
|
if h := srv.Host; len(h.Load) > 0 {
|
||||||
|
c.gauge(minute, GaugeHostLoad, int64(h.Load[0]*100))
|
||||||
|
}
|
||||||
|
if snap != nil && snap.Gauges != nil {
|
||||||
|
g := snap.Gauges
|
||||||
|
srv.Version, srv.BootTime, srv.ConfigTime = snap.Version, snap.BootTime, snap.ConfigTime
|
||||||
|
srv.MemInUse, srv.CacheRRsets, srv.CacheMem = g["named:mem"], g["cache:rrsets"], g["cache:mem"]
|
||||||
|
srv.SocketsUDP, srv.SocketsTCP = g["sockets:udp"], g["sockets:tcp"]
|
||||||
|
srv.RecursionHighwater, srv.TCPHighwater = g["named:recursion-highwater"], g["named:tcp-highwater"]
|
||||||
|
c.gauge(minute, GaugeMem, srv.MemInUse)
|
||||||
|
c.gauge(minute, GaugeCacheMem, srv.CacheMem)
|
||||||
|
c.gauge(minute, GaugeCacheRRs, srv.CacheRRsets)
|
||||||
|
}
|
||||||
|
c.server = srv
|
||||||
|
}
|
||||||
|
|
||||||
|
// Server returns the latest server statistics (nil before the first
|
||||||
|
// minute).
|
||||||
|
func (c *Collector) Server() *Server {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
if c.server == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
s := *c.server
|
||||||
|
return &s
|
||||||
|
}
|
||||||
|
|
||||||
|
// Disk is the space on a file system Jarl and named use.
|
||||||
|
type Disk struct {
|
||||||
|
Path string `json:"path"` // the first of the directories on it
|
||||||
|
Holds []string `json:"holds"` // what is on it: zone files, logs, ...
|
||||||
|
Total int64 `json:"total"`
|
||||||
|
Free int64 `json:"free"` // available to unprivileged users
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
package stats
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestProc(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
old := procRoot
|
||||||
|
procRoot = root
|
||||||
|
defer func() { procRoot = old }()
|
||||||
|
proc := func(pid, comm, cmdline string, utime, stime int) {
|
||||||
|
d := filepath.Join(root, pid)
|
||||||
|
os.MkdirAll(d, 0o755)
|
||||||
|
os.WriteFile(filepath.Join(d, "comm"), []byte(comm+"\n"), 0o644)
|
||||||
|
os.WriteFile(filepath.Join(d, "cmdline"), []byte(cmdline), 0o644)
|
||||||
|
// The command name may hold spaces and parentheses.
|
||||||
|
stat := pid + " (" + comm + " (x)) S 1 1 1 0 -1 4194560 100 0 0 0 " + strconv.Itoa(utime) + " " + strconv.Itoa(stime) + " 0 0 20 0 9 0 100 0 0\n"
|
||||||
|
os.WriteFile(filepath.Join(d, "stat"), []byte(stat), 0o644)
|
||||||
|
os.WriteFile(filepath.Join(d, "status"), []byte("Name:\t"+comm+"\nVmRSS:\t 51200 kB\nThreads:\t9\n"), 0o644)
|
||||||
|
}
|
||||||
|
proc("90", "named", "/usr/sbin/named\x00-c\x00/etc/other.conf", 1, 1)
|
||||||
|
proc("120", "named", "/usr/sbin/named\x00-c\x00/etc/bind/named.conf\x00-u\x00bind", 300, 100)
|
||||||
|
proc("130", "bash", "bash", 0, 0)
|
||||||
|
if pid := findNamed("/etc/bind/named.conf"); pid != 120 {
|
||||||
|
t.Errorf("findNamed = %d, want the one with the config", pid)
|
||||||
|
}
|
||||||
|
if pid := findNamed("/nowhere.conf"); pid != 90 {
|
||||||
|
t.Errorf("findNamed without a match = %d, want the oldest", pid)
|
||||||
|
}
|
||||||
|
now := time.Unix(1_000_000, 0)
|
||||||
|
p, ok := readNamedProc(120, now)
|
||||||
|
if !ok || p.RSS != 51200*1024 || p.Threads != 9 || p.CPU != 400 {
|
||||||
|
t.Fatalf("readNamedProc = %+v, %v", p, ok)
|
||||||
|
}
|
||||||
|
os.WriteFile(filepath.Join(root, "meminfo"), []byte("MemTotal: 4000000 kB\nMemFree: 100 kB\nMemAvailable: 3000000 kB\n"), 0o644)
|
||||||
|
os.WriteFile(filepath.Join(root, "loadavg"), []byte("0.52 0.40 0.31 1/300 4242\n"), 0o644)
|
||||||
|
os.WriteFile(filepath.Join(root, "uptime"), []byte("86400.12 1000.0\n"), 0o644)
|
||||||
|
h := readHost()
|
||||||
|
if h.MemTotal != 4000000*1024 || h.MemAvailable != 3000000*1024 || len(h.Load) != 3 || h.Load[0] != 0.52 || h.Uptime != 86400 {
|
||||||
|
t.Errorf("readHost = %+v", h)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Two samples a minute apart: 30 seconds of CPU time is 50%.
|
||||||
|
c := &Collector{minutes: map[int64]map[string]int64{}, opts: Options{MainConfig: "/etc/bind/named.conf"}}
|
||||||
|
snap := &Snapshot{Gauges: map[string]int64{"named:mem": 1000, "cache:rrsets": 7, "cache:mem": 300, "sockets:tcp": 2}, Version: "9.20"}
|
||||||
|
c.sampleServer(-1, now, snap)
|
||||||
|
if len(c.minutes) != 0 {
|
||||||
|
t.Errorf("minute -1 recorded gauges: %v", c.minutes)
|
||||||
|
}
|
||||||
|
proc("120", "named", "/usr/sbin/named\x00-c\x00/etc/bind/named.conf", 2800, 600) // +3000 ticks = 30 s
|
||||||
|
c.sampleServer(600, now.Add(time.Minute), snap)
|
||||||
|
s := c.Server()
|
||||||
|
if s.PID != 120 || s.CPUPercent != 50 || s.RSS != 51200*1024 || s.CacheRRsets != 7 || s.SocketsTCP != 2 || s.Version != "9.20" {
|
||||||
|
t.Errorf("server = %+v", s)
|
||||||
|
}
|
||||||
|
m := c.minutes[600]
|
||||||
|
if m["g:"+GaugeCPU] != 500 || m["gn:"+GaugeCPU] != 1 || m["g:"+GaugeMem] != 1000 || m["g:"+GaugeHostLoad] != 52 {
|
||||||
|
t.Errorf("gauges = %v", m)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGaugeTimeline(t *testing.T) {
|
||||||
|
store, err := OpenStore(":memory:")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer store.Close()
|
||||||
|
now := time.Unix(1_800_000_000, 0)
|
||||||
|
start := now.Unix() - now.Unix()%300 - 600 // two 5-minute buckets ago
|
||||||
|
b := &Batch{}
|
||||||
|
for i := int64(0); i < 5; i++ {
|
||||||
|
b.AddCounters(ResMinute, start+i*60, map[string]int64{"g:named:rss": 100 + i*10, "gn:named:rss": 1})
|
||||||
|
}
|
||||||
|
// The next bucket has no samples.
|
||||||
|
if err := store.Commit(context.Background(), b); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
q := &Querier{Store: store, Opts: Options{RetainMinute: 7 * 24 * time.Hour, RetainHour: 90 * 24 * time.Hour}, Now: func() time.Time { return now }}
|
||||||
|
tl, err := q.Timeline(context.Background(), 15*time.Minute, "5m", []string{"g:named:rss"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
vals := map[int64]int64{}
|
||||||
|
for i, ts := range tl.Times {
|
||||||
|
vals[ts] = tl.Series[0].Values[i]
|
||||||
|
}
|
||||||
|
if vals[start] != 120 || vals[start+300] != -1 {
|
||||||
|
t.Errorf("gauge timeline = %v (times %v)", tl.Series[0].Values, tl.Times)
|
||||||
|
}
|
||||||
|
}
|
||||||
+10
-1
@@ -24,6 +24,7 @@ const (
|
|||||||
// Store is the statistics database.
|
// Store is the statistics database.
|
||||||
type Store struct {
|
type Store struct {
|
||||||
db *sql.DB
|
db *sql.DB
|
||||||
|
path string
|
||||||
}
|
}
|
||||||
|
|
||||||
var schema = []string{
|
var schema = []string{
|
||||||
@@ -62,7 +63,7 @@ func OpenStore(path string) (*Store, error) {
|
|||||||
if path == ":memory:" {
|
if path == ":memory:" {
|
||||||
db.SetMaxOpenConns(1)
|
db.SetMaxOpenConns(1)
|
||||||
}
|
}
|
||||||
s := &Store{db: db}
|
s := &Store{db: db, path: path}
|
||||||
if err := s.migrate(); err != nil {
|
if err := s.migrate(); err != nil {
|
||||||
db.Close()
|
db.Close()
|
||||||
return nil, fmt.Errorf("statistics database %s: %w", path, err)
|
return nil, fmt.Errorf("statistics database %s: %w", path, err)
|
||||||
@@ -70,6 +71,14 @@ func OpenStore(path string) (*Store, error) {
|
|||||||
return s, nil
|
return s, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Path returns the database file ("" in memory).
|
||||||
|
func (s *Store) Path() string {
|
||||||
|
if s.path == ":memory:" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return s.path
|
||||||
|
}
|
||||||
|
|
||||||
// Close closes the database.
|
// Close closes the database.
|
||||||
func (s *Store) Close() error { return s.db.Close() }
|
func (s *Store) Close() error { return s.db.Close() }
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
`,
|
`,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -57,6 +57,8 @@ func (c *checker) checkKeys() {
|
|||||||
c.errf("key_name_invalid", "key", k.ID, "TSIG key has no name")
|
c.errf("key_name_invalid", "key", k.ID, "TSIG key has no name")
|
||||||
} else if err := dnsname.CheckZoneName(dnsname.Fqdn(k.Name)); err != nil {
|
} else if err := dnsname.CheckZoneName(dnsname.Fqdn(k.Name)); err != nil {
|
||||||
c.errf("key_name_invalid", "key", k.ID, "%s: invalid name: %v", label, err)
|
c.errf("key_name_invalid", "key", k.ID, "%s: invalid name: %v", label, err)
|
||||||
|
} else if strings.HasPrefix(strings.ToLower(k.Name), model.ViewKeyPrefix) {
|
||||||
|
c.errf("key_name_reserved", "key", k.ID, "%s: names starting with %s are kept for the keys Jarl gives views", label, model.ViewKeyPrefix)
|
||||||
}
|
}
|
||||||
minLen, ok := TSIGAlgorithms[k.Algorithm]
|
minLen, ok := TSIGAlgorithms[k.Algorithm]
|
||||||
if !ok {
|
if !ok {
|
||||||
|
|||||||
@@ -126,6 +126,10 @@ func State(s *model.State, opts Options) []Problem {
|
|||||||
for _, k := range s.Keys {
|
for _, k := range s.Keys {
|
||||||
c.keyNames[k.Name] = true
|
c.keyNames[k.Name] = true
|
||||||
}
|
}
|
||||||
|
// The views' own keys (render/viewkeys.go) may be referred to too.
|
||||||
|
for _, v := range s.Views {
|
||||||
|
c.keyNames[model.NormalizeKeyName(model.ViewKeyName(v.Name))] = true
|
||||||
|
}
|
||||||
for _, k := range opts.ExternalKeys {
|
for _, k := range opts.ExternalKeys {
|
||||||
c.keyNames[model.NormalizeKeyName(k)] = true
|
c.keyNames[model.NormalizeKeyName(k)] = true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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";
|
||||||
@@ -24,6 +24,7 @@ const routes = [
|
|||||||
{ pattern: /^zones$/, page: zones, nav: "zones" },
|
{ pattern: /^zones$/, page: zones, nav: "zones" },
|
||||||
{ pattern: /^zones\/(\d+)(?:\/(\w+))?$/, page: zone, nav: "zones", params: ["id", "tab"] },
|
{ pattern: /^zones\/(\d+)(?:\/(\w+))?$/, page: zone, nav: "zones", params: ["id", "tab"] },
|
||||||
{ pattern: /^views$/, page: views, nav: "views" },
|
{ pattern: /^views$/, page: views, nav: "views" },
|
||||||
|
{ pattern: /^views\/(\d+)(?:\/(\w+))?$/, page: views, nav: "views", params: ["id", "tab"] },
|
||||||
{ pattern: /^acls$/, page: acls, nav: "acls" },
|
{ pattern: /^acls$/, page: acls, nav: "acls" },
|
||||||
{ pattern: /^keys$/, page: keys, nav: "keys" },
|
{ pattern: /^keys$/, page: keys, nav: "keys" },
|
||||||
{ pattern: /^options$/, page: options, nav: "options" },
|
{ pattern: /^options$/, page: options, nav: "options" },
|
||||||
@@ -207,7 +208,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() {
|
||||||
|
|||||||
@@ -125,10 +125,19 @@ export function legend(series) {
|
|||||||
h("li", null, h("span", { class: "key line s" + s.slot }), s.label)));
|
h("li", null, h("span", { class: "key line s" + s.slot }), s.label)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// endLabelWidth is the room lineChart needs for the end labels of series.
|
||||||
|
export function endLabelWidth(series) {
|
||||||
|
return series.length > 1 && series.length <= 4 ? Math.max(...series.map((s) => s.label.length)) * 7 + 12 : 8;
|
||||||
|
}
|
||||||
|
|
||||||
// lineChart draws series over time. series: [{label, values, slot}] (slot
|
// lineChart draws series over time. series: [{label, values, slot}] (slot
|
||||||
// = categorical color 1..3); times: bucket starts (Unix seconds); step in
|
// = categorical color 1..3); times: bucket starts (Unix seconds); step in
|
||||||
// seconds. With area, the first series gets a 10% wash under its line.
|
// seconds. With area, the first series gets a 10% wash under its line.
|
||||||
export function lineChart({ series, times, step, area = false, height = 220, endLabels = true }) {
|
// gutter {left, right} sets the least room for the axis labels and the end
|
||||||
|
// labels: charts shown one above the other pass the same gutter, so their
|
||||||
|
// time axes line up. format formats values (axis and readout); a null
|
||||||
|
// value is a gap in the line (no data for that time).
|
||||||
|
export function lineChart({ series, times, step, area = false, height = 220, endLabels = true, gutter = {}, format = compact }) {
|
||||||
const root = h("div", { class: "chart", tabindex: "0", "aria-label": series.map((s) => s.label).join(", ") + " over time" });
|
const root = h("div", { class: "chart", tabindex: "0", "aria-label": series.map((s) => s.label).join(", ") + " over time" });
|
||||||
const tip = tooltip();
|
const tip = tooltip();
|
||||||
root.append(tip);
|
root.append(tip);
|
||||||
@@ -139,12 +148,12 @@ export function lineChart({ series, times, step, area = false, height = 220, end
|
|||||||
if (!w || w === width) return;
|
if (!w || w === width) return;
|
||||||
width = w;
|
width = w;
|
||||||
root.querySelector("svg")?.remove();
|
root.querySelector("svg")?.remove();
|
||||||
const max = Math.max(1, ...series.flatMap((s) => s.values));
|
const max = Math.max(1, ...series.flatMap((s) => s.values).filter((v) => v != null));
|
||||||
const yTicks = niceTicks(max, 4);
|
const yTicks = niceTicks(max, 4);
|
||||||
const yMax = yTicks[yTicks.length - 1];
|
const yMax = yTicks[yTicks.length - 1];
|
||||||
const labelW = Math.max(...yTicks.map((t) => compact(t).length)) * 7 + 10;
|
const labelW = Math.max(...yTicks.map((t) => format(t).length)) * 7 + 10;
|
||||||
const endW = endLabels && series.length > 1 && series.length <= 4 ? Math.max(...series.map((s) => s.label.length)) * 7 + 12 : 8;
|
const endW = endLabels ? endLabelWidth(series) : 8;
|
||||||
const m = { top: 10, right: endW, bottom: 24, left: labelW };
|
const m = { top: 10, right: Math.max(endW, gutter.right || 0), bottom: 24, left: Math.max(labelW, gutter.left || 0) };
|
||||||
const pw = Math.max(40, w - m.left - m.right), ph = height - m.top - m.bottom;
|
const pw = Math.max(40, w - m.left - m.right), ph = height - m.top - m.bottom;
|
||||||
const t0 = times[0], t1 = times[times.length - 1] + step;
|
const t0 = times[0], t1 = times[times.length - 1] + step;
|
||||||
const x = (t) => m.left + ((t - t0) / Math.max(1, t1 - t0)) * pw;
|
const x = (t) => m.left + ((t - t0) / Math.max(1, t1 - t0)) * pw;
|
||||||
@@ -154,7 +163,7 @@ export function lineChart({ series, times, step, area = false, height = 220, end
|
|||||||
// Grid and axes.
|
// Grid and axes.
|
||||||
for (const t of yTicks) {
|
for (const t of yTicks) {
|
||||||
s.append(svg("line", { class: t === 0 ? "axis" : "grid", x1: m.left, x2: m.left + pw, y1: y(t), y2: y(t) }));
|
s.append(svg("line", { class: t === 0 ? "axis" : "grid", x1: m.left, x2: m.left + pw, y1: y(t), y2: y(t) }));
|
||||||
s.append(svg("text", { class: "tick", x: m.left - 6, y: y(t) + 4, "text-anchor": "end" }, compact(t)));
|
s.append(svg("text", { class: "tick", x: m.left - 6, y: y(t) + 4, "text-anchor": "end" }, format(t)));
|
||||||
}
|
}
|
||||||
const { ticks, step: tstep } = timeTicks(t0, t1, Math.max(2, Math.floor(pw / 90)));
|
const { ticks, step: tstep } = timeTicks(t0, t1, Math.max(2, Math.floor(pw / 90)));
|
||||||
for (const t of ticks) {
|
for (const t of ticks) {
|
||||||
@@ -162,17 +171,21 @@ export function lineChart({ series, times, step, area = false, height = 220, end
|
|||||||
}
|
}
|
||||||
// Series.
|
// Series.
|
||||||
series.forEach((sr, k) => {
|
series.forEach((sr, k) => {
|
||||||
const pts = sr.values.map((v, i) => [cx(i), y(v)]);
|
// A gap (null) starts a new piece of line.
|
||||||
const d = pts.map((p, i) => (i ? "L" : "M") + p[0].toFixed(1) + " " + p[1].toFixed(1)).join("");
|
const pts = sr.values.map((v, i) => (v == null ? null : [cx(i), y(v)]));
|
||||||
if (area && k === 0 && pts.length) {
|
const d = pts.map((p, i) => (p ? (i && pts[i - 1] ? "L" : "M") + p[0].toFixed(1) + " " + p[1].toFixed(1) : "")).join("");
|
||||||
|
if (area && k === 0 && pts.length && pts.every(Boolean)) {
|
||||||
s.append(svg("path", { class: "area s" + sr.slot, d: d + `L${pts[pts.length - 1][0].toFixed(1)} ${y(0)}L${pts[0][0].toFixed(1)} ${y(0)}Z` }));
|
s.append(svg("path", { class: "area s" + sr.slot, d: d + `L${pts[pts.length - 1][0].toFixed(1)} ${y(0)}L${pts[0][0].toFixed(1)} ${y(0)}Z` }));
|
||||||
}
|
}
|
||||||
s.append(svg("path", { class: "line s" + sr.slot, d }));
|
s.append(svg("path", { class: "line s" + sr.slot, d }));
|
||||||
if (pts.length === 1) s.append(svg("circle", { class: "dot s" + sr.slot, cx: pts[0][0], cy: pts[0][1], r: 4 }));
|
// A point without neighbours has no line: show it as a dot.
|
||||||
|
pts.forEach((p, i) => {
|
||||||
|
if (p && !pts[i - 1] && !pts[i + 1]) s.append(svg("circle", { class: "dot s" + sr.slot, cx: p[0], cy: p[1], r: 3 }));
|
||||||
|
});
|
||||||
});
|
});
|
||||||
// Direct end labels when there is room and they don't collide.
|
// Direct end labels when there is room and they don't collide.
|
||||||
if (m.right > 8) {
|
if (m.right > 8) {
|
||||||
const ends = series.map((sr) => ({ label: sr.label, y: y(sr.values[sr.values.length - 1] || 0) })).sort((a, b) => a.y - b.y);
|
const ends = series.map((sr) => ({ label: sr.label, y: y(sr.values.findLast((v) => v != null) || 0) })).sort((a, b) => a.y - b.y);
|
||||||
if (ends.every((e, i) => i === 0 || e.y - ends[i - 1].y >= 13)) {
|
if (ends.every((e, i) => i === 0 || e.y - ends[i - 1].y >= 13)) {
|
||||||
for (const e of ends) s.append(svg("text", { class: "end-label", x: m.left + pw + 6, y: e.y + 4 }, e.label));
|
for (const e of ends) s.append(svg("text", { class: "end-label", x: m.left + pw + 6, y: e.y + 4 }, e.label));
|
||||||
}
|
}
|
||||||
@@ -205,13 +218,13 @@ export function lineChart({ series, times, step, area = false, height = 220, end
|
|||||||
geom.cross.setAttribute("visibility", "visible");
|
geom.cross.setAttribute("visibility", "visible");
|
||||||
series.forEach((sr, k) => {
|
series.forEach((sr, k) => {
|
||||||
geom.dots[k].setAttribute("cx", xpos);
|
geom.dots[k].setAttribute("cx", xpos);
|
||||||
geom.dots[k].setAttribute("cy", geom.y(sr.values[i]));
|
geom.dots[k].setAttribute("cy", geom.y(sr.values[i] ?? 0));
|
||||||
geom.dots[k].setAttribute("visibility", "visible");
|
geom.dots[k].setAttribute("visibility", sr.values[i] == null ? "hidden" : "visible");
|
||||||
});
|
});
|
||||||
tip.replaceChildren(
|
tip.replaceChildren(
|
||||||
h("div", { class: "tip-head" }, bucketLabel(times[i], step)),
|
h("div", { class: "tip-head" }, bucketLabel(times[i], step)),
|
||||||
...series.map((sr) => h("div", { class: "tip-row" },
|
...series.map((sr) => h("div", { class: "tip-row" },
|
||||||
h("span", { class: "key line s" + sr.slot }), h("strong", null, sr.values[i].toLocaleString()), " ", h("span", { class: "muted" }, sr.label))));
|
h("span", { class: "key line s" + sr.slot }), h("strong", null, sr.values[i] == null ? "–" : format === compact ? sr.values[i].toLocaleString() : format(sr.values[i])), " ", h("span", { class: "muted" }, sr.label))));
|
||||||
tip.hidden = false;
|
tip.hidden = false;
|
||||||
// Keep the readout inside the chart, on the far side of the pointer.
|
// Keep the readout inside the chart, on the far side of the pointer.
|
||||||
const left = xpos > root.clientWidth / 2 ? xpos - tip.offsetWidth - 12 : xpos + 12;
|
const left = xpos > root.clientWidth / 2 ? xpos - tip.offsetWidth - 12 : xpos + 12;
|
||||||
@@ -242,12 +255,13 @@ export function lineChart({ series, times, step, area = false, height = 220, end
|
|||||||
|
|
||||||
// timeTable is the table view of a line chart (busiest buckets first is
|
// timeTable is the table view of a line chart (busiest buckets first is
|
||||||
// not useful here: keep time order, newest first).
|
// not useful here: keep time order, newest first).
|
||||||
export function timeTable(series, times, step) {
|
export function timeTable(series, times, step, format) {
|
||||||
|
const cell = (v) => (v == null ? "–" : format ? format(v) : v.toLocaleString());
|
||||||
const rows = times.map((t, i) => ({ t, i })).reverse();
|
const rows = times.map((t, i) => ({ t, i })).reverse();
|
||||||
return h("div", { class: "table-wrap scroll" }, h("table", null,
|
return h("div", { class: "table-wrap scroll" }, h("table", null,
|
||||||
h("thead", null, h("tr", null, h("th", null, "Time"), series.map((s) => h("th", { class: "num" }, s.label)))),
|
h("thead", null, h("tr", null, h("th", null, "Time"), series.map((s) => h("th", { class: "num" }, s.label)))),
|
||||||
h("tbody", null, rows.map((r) => h("tr", null, h("td", null, bucketLabel(r.t, step)),
|
h("tbody", null, rows.map((r) => h("tr", null, h("td", null, bucketLabel(r.t, step)),
|
||||||
series.map((s) => h("td", { class: "num" }, s.values[r.i].toLocaleString())))))));
|
series.map((s) => h("td", { class: "num" }, cell(s.values[r.i]))))))));
|
||||||
}
|
}
|
||||||
|
|
||||||
// heatmap draws average activity by weekday (rows, Monday first) and hour
|
// heatmap draws average activity by weekday (rows, Monday first) and hour
|
||||||
|
|||||||
@@ -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 || status.paused_indefinitely;
|
||||||
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, status.paused_indefinitely ? "Blocking is paused: " : "Blocking is paused until " + new Date(paused).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", hourCycle: "h23" }) + ": "),
|
||||||
|
status.paused_indefinitely ? "every name is answered normally until you resume it. " : "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,73 @@ 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 (status.paused_until) wait = Math.max(1000, Math.min(wait || Infinity, new Date(status.paused_until) - 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"], ["-1", "Until I resume"]], "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 (-1: until resumed), or resumes it
|
||||||
|
// (0). It applies at once, also with a draft open: it's for trying
|
||||||
|
// something now.
|
||||||
|
async function pause(minutes) {
|
||||||
|
try {
|
||||||
|
const body = minutes < 0 ? { indefinitely: true } : { minutes };
|
||||||
|
await api("POST", minutes ? "/blocking/pause" : "/blocking/resume", minutes ? { body } : {});
|
||||||
|
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 +134,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 +237,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 +278,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) {
|
||||||
@@ -13,7 +13,8 @@ export async function render(el) {
|
|||||||
card("Jarl", s.adopted ? badge("active", "ok") : badge("not adopted", "warn"),
|
card("Jarl", s.adopted ? badge("active", "ok") : badge("not adopted", "warn"),
|
||||||
s.adopted ? "named loads Jarl's configuration" : "named does not load Jarl's configuration yet — run 'jarld adopt' on the server"),
|
s.adopted ? "named loads Jarl's configuration" : "named does not load Jarl's configuration yet — run 'jarld adopt' on the server"),
|
||||||
card("Zones", (s.counts || {}).zones ?? "–", plural((s.counts || {}).records || 0, "record") + " · " + plural((s.counts || {}).views || 0, "view")),
|
card("Zones", (s.counts || {}).zones ?? "–", plural((s.counts || {}).records || 0, "record") + " · " + plural((s.counts || {}).views || 0, "view")),
|
||||||
card("Access", plural((s.counts || {}).acls || 0, "ACL"), plural((s.counts || {}).tsig_keys || 0, "TSIG key") + " · " + plural((s.counts || {}).options || 0, "option")),
|
card("Access", plural((s.counts || {}).acls || 0, "ACL"), plural((s.counts || {}).tsig_keys || 0, "TSIG key")),
|
||||||
|
card("Options", (s.counts || {}).options ?? "–", "server, view, zone and top-level"),
|
||||||
st.backup ? backupCard(st.backup) : null,
|
st.backup ? backupCard(st.backup) : null,
|
||||||
);
|
);
|
||||||
const last = s.last_applied;
|
const last = s.last_applied;
|
||||||
@@ -40,6 +41,14 @@ 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")));
|
||||||
|
}
|
||||||
|
if (st.blocking_paused_indefinitely) {
|
||||||
|
parts.push(h("div", { class: "msg warn" },
|
||||||
|
h("strong", null, "Blocking is paused until someone resumes it."), " Every name is answered normally. ", 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,20 +28,54 @@ 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 || "");
|
||||||
if (takesList && !value) t.placeholder = "{ homelab; key xfer-key; }";
|
if (takesList && !value) t.placeholder = "homelab; key xfer-key";
|
||||||
return { el: h("div", null, t, takesList && ctx ? suggestions(t, ctx, { mode: "inline" }) : null), get: () => t.value.trim() };
|
return { el: h("div", null, t, takesList && ctx ? suggestions(t, ctx, { mode: "inline" }) : null), get: () => t.value.trim(),
|
||||||
|
hint: takesList ? "The { } around the list are optional: 10.0.0.53; key xfer-key becomes { 10.0.0.53; key xfer-key; }." : null };
|
||||||
}
|
}
|
||||||
const i = input({ value: value || "", class: "mono" });
|
const i = input({ value: value || "", class: "mono" });
|
||||||
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 +83,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 +91,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" } },
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { h, mount, fmtTime, errorBox } from "../ui.js";
|
import { h, mount, fmtTime, relTime, errorBox } from "../ui.js";
|
||||||
import { get } from "../api.js";
|
import { get } from "../api.js";
|
||||||
import { lineChart, chartCard, legend, timeTable, heatmap, heatmapTable, barList, compact, stepSeconds } from "../charts.js";
|
import { lineChart, chartCard, legend, timeTable, heatmap, heatmapTable, barList, compact, stepSeconds, endLabelWidth } from "../charts.js";
|
||||||
|
|
||||||
const ranges = [["1h", "1 hour"], ["6h", "6 hours"], ["24h", "24 hours"], ["7d", "7 days"], ["30d", "30 days"], ["90d", "90 days"]];
|
const ranges = [["1h", "1 hour"], ["6h", "6 hours"], ["24h", "24 hours"], ["7d", "7 days"], ["30d", "30 days"], ["90d", "90 days"]];
|
||||||
const rangeSecs = { "1h": 3600, "6h": 6 * 3600, "24h": 86400, "7d": 7 * 86400, "30d": 30 * 86400, "90d": 90 * 86400 };
|
const rangeSecs = { "1h": 3600, "6h": 6 * 3600, "24h": 86400, "7d": 7 * 86400, "30d": 30 * 86400, "90d": 90 * 86400 };
|
||||||
@@ -60,16 +60,23 @@ 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 });
|
||||||
|
|
||||||
// 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 }));
|
||||||
const anyFailures = fSeries.some((f) => f.values.some((v) => v > 0));
|
const anyFailures = fSeries.some((f) => f.values.some((v) => v > 0));
|
||||||
|
// The two charts are one above the other: the same room for labels on
|
||||||
|
// both sides keeps their time axes in line.
|
||||||
|
const gutter = { left: 52, right: Math.max(endLabelWidth(qSeries), anyFailures ? endLabelWidth(fSeries) : 0) };
|
||||||
|
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, gutter }), timeTable(qSeries, timeline.times, step),
|
||||||
|
{ wide: true, legend: qSeries.length > 1 ? legend(qSeries) : null }));
|
||||||
|
|
||||||
parts.push(chartCard("Failed answers", "NXDOMAIN: the name doesn't exist · SERVFAIL: resolution failed · REFUSED: not allowed",
|
parts.push(chartCard("Failed answers", "NXDOMAIN: the name doesn't exist · SERVFAIL: resolution failed · REFUSED: not allowed",
|
||||||
anyFailures ? lineChart({ series: fSeries, times: errors.times, step: stepSeconds[errors.step] }) : h("div", { class: "empty" }, "No failed answers " + rangeLabel + "."),
|
anyFailures ? lineChart({ series: fSeries, times: errors.times, step: stepSeconds[errors.step], gutter }) : h("div", { class: "empty" }, "No failed answers " + rangeLabel + "."),
|
||||||
anyFailures ? timeTable(fSeries, errors.times, stepSeconds[errors.step]) : null,
|
anyFailures ? timeTable(fSeries, errors.times, stepSeconds[errors.step]) : null,
|
||||||
{ wide: true, legend: anyFailures ? legend(fSeries) : null }));
|
{ wide: true, legend: anyFailures ? legend(fSeries) : null }));
|
||||||
|
|
||||||
@@ -86,6 +93,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."));
|
||||||
@@ -102,6 +110,8 @@ export async function render(el, params, query) {
|
|||||||
b.answer.length ? listCard("Answers", "authoritative (your zones) or recursive (from the cache or the internet)", barList(b.answer)) : null,
|
b.answer.length ? listCard("Answers", "authoritative (your zones) or recursive (from the cache or the internet)", barList(b.answer)) : null,
|
||||||
rtt.length ? listCard("Upstream response time", "how long other servers took to answer named's recursive queries", barList(rtt)) : null,
|
rtt.length ? listCard("Upstream response time", "how long other servers took to answer named's recursive queries", barList(rtt)) : null,
|
||||||
b.blocklist.length ? listCard("Blocked by", "which of your rules and blocklists blocked queries", barList(b.blocklist, { label: (k) => (k === "rules" ? "your rules" : k), href: () => "#/blocking" })) : null));
|
b.blocklist.length ? listCard("Blocked by", "which of your rules and blocklists blocked queries", barList(b.blocklist, { label: (k) => (k === "rules" ? "your rules" : k), href: () => "#/blocking" })) : null));
|
||||||
|
|
||||||
|
parts.push(...serverSection(data.server, complete(data.gauges), rangeLabel));
|
||||||
el.classList.remove("refreshing");
|
el.classList.remove("refreshing");
|
||||||
mount(el, parts);
|
mount(el, parts);
|
||||||
scheduleRefresh(el, params, query, gen);
|
scheduleRefresh(el, params, query, gen);
|
||||||
@@ -110,14 +120,16 @@ 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 }),
|
||||||
|
server: get("/stats/server").catch(() => null),
|
||||||
|
gauges: get("/stats/timeline", { since, key: gaugeKeys.join(",") }),
|
||||||
};
|
};
|
||||||
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));
|
||||||
@@ -157,6 +169,63 @@ function filterRow(since) {
|
|||||||
h("a", { href: "#/stats?since=" + k, class: k === since ? "active" : "", "aria-current": k === since ? "true" : null, title: "last " + label }, k))));
|
h("a", { href: "#/stats?since=" + k, class: k === since ? "active" : "", "aria-current": k === since ? "true" : null, title: "last " + label }, k))));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const gaugeKeys = ["g:named:rss", "g:named:mem", "g:cache:mem", "g:named:cpu"];
|
||||||
|
|
||||||
|
// serverSection shows named's process, cache, clients, the host and the
|
||||||
|
// disks now, and named's memory and CPU over time.
|
||||||
|
function serverSection(sv, gauges, rangeLabel) {
|
||||||
|
const srv = (sv && sv.server) || {};
|
||||||
|
const named = (sv && sv.named) || {};
|
||||||
|
const host = srv.host || {};
|
||||||
|
const out = [h("h2", { class: "section" }, "Server")];
|
||||||
|
const clients = (v, what) => {
|
||||||
|
// "now/soft/hard" or "now/limit".
|
||||||
|
const [now, ...limits] = (v || "").split("/");
|
||||||
|
return v ? [now, "of " + limits[limits.length - 1] + " allowed" + what] : null;
|
||||||
|
};
|
||||||
|
const rc = clients(named.recursive_clients, srv.recursion_highwater ? " · most at once: " + srv.recursion_highwater : "");
|
||||||
|
const tc = clients(named.tcp_clients, srv.tcp_highwater ? " · most at once: " + srv.tcp_highwater : "");
|
||||||
|
out.push(h("div", { class: "cards" },
|
||||||
|
tile("named memory", srv.rss ? bytes(srv.rss) : "–", srv.rss ? "resident · named counts " + bytes(srv.mem_in_use) + " · " + srv.threads + " threads" : "named's process isn't visible to jarld"),
|
||||||
|
tile("named CPU", srv.pid ? srv.cpu_percent.toFixed(1) + "%" : "–", "of one CPU, last minute" + (named.cpus ? " · " + named.cpus + " CPUs" : "")),
|
||||||
|
tile("Cache", compact(srv.cache_rrsets || 0) + " RRsets", bytes(srv.cache_mem || 0) + " of memory, all views"),
|
||||||
|
rc ? tile("Recursive clients", rc[0], rc[1]) : null,
|
||||||
|
tc ? tile("TCP clients", tc[0], tc[1]) : null,
|
||||||
|
tile("Up since", srv.boot_time ? relTime(srv.boot_time).replace(" ago", "") : "–", srv.config_time ? "named started " + fmtTime(srv.boot_time) + " · reconfigured " + relTime(srv.config_time) : ""),
|
||||||
|
host.mem_total ? tile("Host memory", pct((host.mem_total - host.mem_available) / host.mem_total), "of " + bytes(host.mem_total) + " in use · load " + (host.load || []).map((x) => x.toFixed(2)).join(" ")) : null,
|
||||||
|
...((sv && sv.disks) || []).map((d) => tile("Disk space", bytes(d.free) + " free", h("span", { title: d.path }, "of " + bytes(d.total) + ", for " + d.holds.join(", "))))));
|
||||||
|
if (sv && sv.named_error) out.push(h("div", { class: "msg info" }, "Client counts need rndc: " + sv.named_error));
|
||||||
|
// Gauges over time: averages, -1 where there was no sample.
|
||||||
|
const series = (key) => ((gauges.series || []).find((x) => x.key === key) || { values: gauges.times.map(() => -1) }).values.map((v) => (v < 0 ? null : v));
|
||||||
|
const gStep = stepSeconds[gauges.step];
|
||||||
|
const memSeries = [
|
||||||
|
{ label: "Resident", values: series("g:named:rss"), slot: 1 },
|
||||||
|
{ label: "named's count", values: series("g:named:mem"), slot: 2 },
|
||||||
|
{ label: "Cache", values: series("g:cache:mem"), slot: 3 },
|
||||||
|
];
|
||||||
|
const cpuSeries = [{ label: "CPU", values: series("g:named:cpu").map((v) => (v == null ? null : v / 10)), slot: 1 }];
|
||||||
|
const any = (ss) => ss.some((x) => x.values.some((v) => v != null));
|
||||||
|
const gutter = { left: 64, right: endLabelWidth(memSeries) };
|
||||||
|
out.push(chartCard("named memory", "average per " + stepName(gauges.step) + ", " + rangeLabel + " · resident is what the system sees",
|
||||||
|
any(memSeries) ? lineChart({ series: memSeries, times: gauges.times, step: gStep, gutter, format: bytes }) : h("div", { class: "empty" }, "No samples yet: they are taken once a minute."),
|
||||||
|
any(memSeries) ? timeTable(memSeries, gauges.times, gStep, bytes) : null, { wide: true, legend: any(memSeries) ? legend(memSeries) : null }));
|
||||||
|
out.push(chartCard("named CPU", "percent of one CPU, average per " + stepName(gauges.step),
|
||||||
|
any(cpuSeries) ? lineChart({ series: cpuSeries, times: gauges.times, step: gStep, gutter, format: (v) => (Math.round(v * 10) / 10) + "%" }) : h("div", { class: "empty" }, "No samples yet."),
|
||||||
|
any(cpuSeries) ? timeTable(cpuSeries, gauges.times, gStep, (v) => v.toFixed(1) + "%") : null, { wide: true }));
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// bytes formats a size in binary units.
|
||||||
|
function bytes(n) {
|
||||||
|
const units = ["B", "KiB", "MiB", "GiB", "TiB"];
|
||||||
|
let i = 0;
|
||||||
|
while (n >= 1024 && i < units.length - 1) {
|
||||||
|
n /= 1024;
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
return (i === 0 || n >= 100 ? Math.round(n) : n.toFixed(1).replace(/\.0$/, "")) + " " + units[i];
|
||||||
|
}
|
||||||
|
|
||||||
function tile(label, value, sub) {
|
function tile(label, value, sub) {
|
||||||
return h("div", { class: "card stat" }, h("div", { class: "label" }, label), h("div", { class: "value big" }, value), h("div", { class: "sub" }, sub));
|
return h("div", { class: "card stat" }, h("div", { class: "label" }, label), h("div", { class: "value big" }, value), h("div", { class: "sub" }, sub));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,19 @@
|
|||||||
import { h, mount, table, field, input, select, textarea, toast, errorBox, confirmDialog, modal, lines, checkbox, colorPicker, backLink, safeBack, viewHref } from "../ui.js";
|
import { h, mount, table, field, input, select, textarea, toast, errorBox, confirmDialog, modal, lines, checkbox, colorPicker, backLink, safeBack, viewHref, badge } from "../ui.js";
|
||||||
import { amlContext, suggestions } from "../aml.js";
|
import { amlContext, suggestions } from "../aml.js";
|
||||||
import { api, get, change, describeResult } from "../api.js";
|
import { api, get, change, describeResult } from "../api.js";
|
||||||
|
import { newZoneDialog, dnssecLock, pendingBadge } from "./zones.js";
|
||||||
import { rerender } from "../app.js";
|
import { rerender } from "../app.js";
|
||||||
import { optionsEditor } from "./options.js";
|
import { optionsEditor } from "./options.js";
|
||||||
|
|
||||||
export async function render(el, params, query) {
|
export async function render(el, params, query) {
|
||||||
|
// Old links (#/views?view=ID) open the view's page.
|
||||||
|
if (query.get("view")) {
|
||||||
|
history.replaceState(null, "", viewHref(query.get("view"), query.get("back") || ""));
|
||||||
|
return rerender();
|
||||||
|
}
|
||||||
|
if (params.id) return viewPage(el, params, query);
|
||||||
const [views, zones] = await Promise.all([get("/views"), get("/zones")]);
|
const [views, zones] = await Promise.all([get("/views"), get("/zones")]);
|
||||||
const viewless = zones.filter((z) => !z.view_id);
|
const viewless = zones.filter((z) => !z.view_id);
|
||||||
const optsEl = h("div");
|
|
||||||
const selected = query.get("view") ? views.find((v) => String(v.id) === query.get("view")) : null;
|
|
||||||
// Opened from a view link on a zone or the zone list: offer the way back
|
|
||||||
// (kept while picking other views here).
|
|
||||||
const back = safeBack(query.get("back"));
|
|
||||||
const move = async (i, d) => {
|
const move = async (i, d) => {
|
||||||
const ids = views.map((v) => v.id);
|
const ids = views.map((v) => v.id);
|
||||||
[ids[i], ids[i + d]] = [ids[i + d], ids[i]];
|
[ids[i], ids[i + d]] = [ids[i + d], ids[i]];
|
||||||
@@ -23,31 +25,83 @@ export async function render(el, params, query) {
|
|||||||
};
|
};
|
||||||
mount(el,
|
mount(el,
|
||||||
h("div", { class: "page-head" },
|
h("div", { class: "page-head" },
|
||||||
h("div", { class: "grow" }, back ? backLink(backLabel(back, zones), back) : null, h("h1", null, "Views"),
|
h("div", { class: "grow" }, h("h1", null, "Views"),
|
||||||
h("div", { class: "muted" }, "A client sees the first view it matches (by source address, destination address or TSIG key). Order matters.")),
|
h("div", { class: "muted" }, "A client sees the first view it matches (by source address, destination address or TSIG key). Order matters.")),
|
||||||
h("div", { class: "actions" }, h("button", { class: "primary", onclick: () => viewDialog(null, viewless) }, "New view"))),
|
h("div", { class: "actions" }, h("button", { class: "primary", onclick: () => viewDialog(viewless) }, "New view"))),
|
||||||
views.length === 0 ? h("div", { class: "msg info" },
|
views.length === 0 ? h("div", { class: "msg info" },
|
||||||
"No views: every client sees the same zones. Views let you serve different answers to different clients, for example an internal view with private records and recursion for your LAN, and an external view with only public records.") : null,
|
"No views: every client sees the same zones. Views let you serve different answers to different clients, for example an internal view with private records and recursion for your LAN, and an external view with only public records.") : null,
|
||||||
h("div", { class: "panel" }, table([
|
h("div", { class: "panel" }, table([
|
||||||
{ label: "#", render: (v, i) => String(i + 1) },
|
{ label: "#", render: (v, i) => String(i + 1) },
|
||||||
{ label: "View", render: (v) => h("a", { href: viewHref(v.id, back), class: v.color ? "vc-" + v.color : null }, h("strong", null, v.name)) },
|
{ label: "View", render: (v) => h("a", { href: viewHref(v.id), class: v.color ? "vc-" + v.color : null }, h("strong", null, v.name)) },
|
||||||
{ label: "Match clients", render: (v) => (v.match_clients || []).join("; ") || h("span", { class: "muted" }, "any"), tdClass: "data" },
|
{ label: "Match clients", render: (v) => (v.match_clients || []).join("; ") || h("span", { class: "muted" }, "any"), tdClass: "data" },
|
||||||
{ label: "Match destinations", render: (v) => (v.match_destinations || []).join("; ") || h("span", { class: "muted" }, "any"), tdClass: "data" },
|
{ label: "Match destinations", render: (v) => (v.match_destinations || []).join("; ") || h("span", { class: "muted" }, "any"), tdClass: "data" },
|
||||||
{ label: "Zones", render: (v) => h("a", { href: "#/zones?view=" + encodeURIComponent(v.name) }, String(v.zone_count)) },
|
{ label: "Zones", render: (v) => h("a", { href: viewHref(v.id) + "/zones" }, String(v.zone_count)) },
|
||||||
{ label: "Comment", render: (v) => h("span", { class: "muted" }, v.comment || "") },
|
{ label: "Comment", render: (v) => h("span", { class: "muted" }, v.comment || "") },
|
||||||
{ label: "", render: (v, i) => h("span", null,
|
{ label: "Order", render: (v, i) => h("span", null,
|
||||||
h("button", { class: "small", disabled: i === 0, title: "Move up", onclick: () => move(i, -1) }, "↑"), " ",
|
h("button", { class: "small", disabled: i === 0, title: "Move up: clients are matched against it earlier", onclick: () => move(i, -1) }, "↑"), " ",
|
||||||
h("button", { class: "small", disabled: i === views.length - 1, title: "Move down", onclick: () => move(i, 1) }, "↓"), " ",
|
h("button", { class: "small", disabled: i === views.length - 1, title: "Move down", onclick: () => move(i, 1) }, "↓")), tdClass: "actions-cell" },
|
||||||
h("button", { class: "small", onclick: () => viewDialog(v, viewless) }, "Edit"), " ",
|
], views, { empty: "No views." })));
|
||||||
h("button", { class: "small danger", onclick: () => deleteView(v) }, "Delete")), tdClass: "actions-cell" },
|
|
||||||
], views, { empty: "No views.", rowClass: (v) => selected && v.id === selected.id ? "selected" : "" })),
|
|
||||||
selected ? optsEl : (views.length ? h("p", { class: "muted" }, "Select a view to see its options.") : null));
|
|
||||||
if (selected) {
|
|
||||||
await optionsEditor(optsEl, { scope: "view", scope_id: selected.id, catalogScope: "view", title: "Options of view " + selected.name,
|
|
||||||
intro: "View options override the server options for clients in this view (for example recursion and allow-recursion)." });
|
|
||||||
// With a long list, scroll to the selected view's options.
|
|
||||||
if (views.length > 3) optsEl.scrollIntoView({ block: "start" });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// viewPage is one view, laid out like a zone: its settings, its options
|
||||||
|
// and its zones, each on a tab.
|
||||||
|
async function viewPage(el, params, query) {
|
||||||
|
const id = Number(params.id);
|
||||||
|
const [views, zones] = await Promise.all([get("/views"), get("/zones")]);
|
||||||
|
const i = views.findIndex((v) => v.id === id);
|
||||||
|
const v = views[i];
|
||||||
|
const back = safeBack(query.get("back"));
|
||||||
|
if (!v) {
|
||||||
|
mount(el, backLink("Views", "#/views"), h("div", { class: "msg err" }, "View " + id + " doesn't exist (any more)."));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const inView = zones.filter((z) => z.view_id === id);
|
||||||
|
const tab = params.tab || "settings";
|
||||||
|
const base = viewHref(id);
|
||||||
|
const suffix = back ? "?back=" + encodeURIComponent(back) : "";
|
||||||
|
const tabs = [["settings", "Settings"], ["options", "Options"], ["zones", "Zones (" + inView.length + ")"]];
|
||||||
|
const head = h("div", { class: "page-head" },
|
||||||
|
h("div", { class: "grow" },
|
||||||
|
back ? backLink(backLabel(back, zones), back) : backLink("Views", "#/views"),
|
||||||
|
h("h1", { class: v.color ? "vc-" + v.color : null }, v.name),
|
||||||
|
h("div", { class: "muted" },
|
||||||
|
"view " + (i + 1) + " of " + views.length + " · clients: " + ((v.match_clients || []).join("; ") || "any") +
|
||||||
|
((v.match_destinations || []).length ? " · destinations: " + v.match_destinations.join("; ") : "") +
|
||||||
|
(v.comment ? " · " + v.comment : ""))),
|
||||||
|
h("div", { class: "actions" }, h("button", { class: "danger", onclick: () => deleteView(v) }, "Delete view")));
|
||||||
|
const tabBar = h("div", { class: "tabs" }, tabs.map(([k, label]) => h("a", { href: base + "/" + k + suffix, class: k === tab ? "active" : "" }, label)));
|
||||||
|
const body = h("div");
|
||||||
|
mount(el, head, tabBar, body);
|
||||||
|
if (tab === "options") {
|
||||||
|
await optionsEditor(body, { scope: "view", scope_id: id, catalogScope: "view", title: "View options",
|
||||||
|
intro: "View options override the server options for clients in this view (for example recursion and allow-recursion)." });
|
||||||
|
} else if (tab === "zones") {
|
||||||
|
await zonesTab(body, v, views, zones, inView);
|
||||||
|
} else {
|
||||||
|
await settingsTab(body, v);
|
||||||
|
}
|
||||||
|
const first = body.querySelector("[data-autofocus]");
|
||||||
|
if (first) first.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function settingsTab(el, v) {
|
||||||
|
const f = await viewForm(v, []);
|
||||||
|
mount(el, h("div", { class: "panel" }, f.body,
|
||||||
|
h("div", { class: "actions", style: { marginTop: "12px" } }, h("button", { class: "primary", onclick: async () => { if (await f.save()) rerender(); } }, "Save settings"))));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function zonesTab(el, v, views, zones, inView) {
|
||||||
|
mount(el, h("div", { class: "panel" },
|
||||||
|
h("div", { class: "actions", style: { marginBottom: "10px" } },
|
||||||
|
h("button", { class: "primary", onclick: async () => newZoneDialog(views, zones, await get("/settings"), { view: v.name }) }, "New zone in " + v.name),
|
||||||
|
h("a", { href: "#/zones?view=" + encodeURIComponent(v.name) }, "Show on the Zones page")),
|
||||||
|
table([
|
||||||
|
{ label: "Zone", render: (z) => [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" },
|
||||||
|
{ label: "Type", render: (z) => badge(z.type, z.type === "primary" ? "accent" : "") },
|
||||||
|
{ label: "Records", render: (z) => (z.type === "primary" ? String(z.record_count) : ""), class: "right", tdClass: "right" },
|
||||||
|
{ label: "Serial", render: (z) => (z.type === "primary" ? String(z.soa.serial) : ""), tdClass: "mono" },
|
||||||
|
{ label: "Comment", render: (z) => h("span", { class: "muted" }, z.comment || "") },
|
||||||
|
], inView, { empty: "No zones in this view yet.", rowClass: (z) => (z.disabled ? "disabled" : "") })));
|
||||||
}
|
}
|
||||||
|
|
||||||
// backLabel names the page a back address leads to.
|
// backLabel names the page a back address leads to.
|
||||||
@@ -60,10 +114,12 @@ function backLabel(back, zones) {
|
|||||||
return /^#\/zones(\?|$)/.test(back) ? "Zones" : "Back";
|
return /^#\/zones(\?|$)/.test(back) ? "Zones" : "Back";
|
||||||
}
|
}
|
||||||
|
|
||||||
async function viewDialog(v, viewless) {
|
// viewForm is the form for a view's settings (v null: a new view); save
|
||||||
|
// sends it and reports whether it worked.
|
||||||
|
async function viewForm(v, viewless) {
|
||||||
const err = h("div");
|
const err = h("div");
|
||||||
const ctx = await amlContext();
|
const ctx = await amlContext();
|
||||||
const name = input({ value: v ? v.name : "", placeholder: "internal" });
|
const name = input({ value: v ? v.name : "", placeholder: "internal", "data-autofocus": v ? null : true });
|
||||||
const mc = textarea(v ? (v.match_clients || []).join("\n") : "", { class: "mono", rows: 4, placeholder: "192.168.1.0/24\nlocalhost\n(empty = any)" });
|
const mc = textarea(v ? (v.match_clients || []).join("\n") : "", { class: "mono", rows: 4, placeholder: "192.168.1.0/24\nlocalhost\n(empty = any)" });
|
||||||
const md = textarea(v ? (v.match_destinations || []).join("\n") : "", { class: "mono", rows: 2, placeholder: "(empty = any)" });
|
const md = textarea(v ? (v.match_destinations || []).join("\n") : "", { class: "mono", rows: 2, placeholder: "(empty = any)" });
|
||||||
const mro = select([["", "(default: no)"], ["true", "yes"], ["false", "no"]], v && v.match_recursive_only !== undefined && v.match_recursive_only !== null ? String(v.match_recursive_only) : "");
|
const mro = select([["", "(default: no)"], ["true", "yes"], ["false", "no"]], v && v.match_recursive_only !== undefined && v.match_recursive_only !== null ? String(v.match_recursive_only) : "");
|
||||||
@@ -79,9 +135,8 @@ async function viewDialog(v, viewless) {
|
|||||||
field("Colour", color, "How the web UI shows this view, so zones of the same view are easy to spot. Only changes the web UI: it applies at once, with no deploy.")),
|
field("Colour", color, "How the web UI shows this view, so zones of the same view are easy to spot. Only changes the web UI: it applies at once, with no deploy.")),
|
||||||
!v && viewless.length ? h("div", { class: "msg warn" }, "Once views exist, named requires every zone to be inside a view. ", adopt) : null,
|
!v && viewless.length ? h("div", { class: "msg warn" }, "Once views exist, named requires every zone to be inside a view. ", adopt) : null,
|
||||||
err);
|
err);
|
||||||
await modal(v ? "Edit view " + v.name : "New view", body, [
|
const save = async () => {
|
||||||
{ label: "Cancel", value: null },
|
mount(err);
|
||||||
{ label: v ? "Save" : "Create view", kind: "primary", onclick: async () => {
|
|
||||||
const data = { name: name.value.trim(), match_clients: lines(mc.value), match_destinations: lines(md.value), comment: comment.value };
|
const data = { name: name.value.trim(), match_clients: lines(mc.value), match_destinations: lines(md.value), comment: comment.value };
|
||||||
data.match_recursive_only = mro.value === "" ? null : mro.value === "true";
|
data.match_recursive_only = mro.value === "" ? null : mro.value === "true";
|
||||||
if (!v && viewless.length && adopt.input.checked) data.adopt_zones = true;
|
if (!v && viewless.length && adopt.input.checked) data.adopt_zones = true;
|
||||||
@@ -94,11 +149,18 @@ async function viewDialog(v, viewless) {
|
|||||||
// A view created in a draft only exists in that draft so far.
|
// A view created in a draft only exists in that draft so far.
|
||||||
await api("PUT", "/views/" + id + "/color", { body: { color: color.value }, stage: r && r.staged ? r.changeset_id : undefined });
|
await api("PUT", "/views/" + id + "/color", { body: { color: color.value }, stage: r && r.staged ? r.changeset_id : undefined });
|
||||||
}
|
}
|
||||||
toast(r ? describeResult(r) : "Colour saved");
|
toast(r ? describeResult(r) : colorChanged ? "Colour saved" : "Nothing changed");
|
||||||
rerender();
|
|
||||||
return true;
|
return true;
|
||||||
} catch (e) { mount(err, errorBox(e)); return false; }
|
} catch (e) { mount(err, errorBox(e)); return false; }
|
||||||
} },
|
};
|
||||||
|
return { body, save };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function viewDialog(viewless) {
|
||||||
|
const f = await viewForm(null, viewless);
|
||||||
|
await modal("New view", f.body, [
|
||||||
|
{ label: "Cancel", value: null },
|
||||||
|
{ label: "Create view", kind: "primary", onclick: async () => { const ok = await f.save(); if (ok) rerender(); return ok; } },
|
||||||
], { wide: true, sticky: true });
|
], { wide: true, sticky: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -122,6 +184,8 @@ async function deleteView(v) {
|
|||||||
try {
|
try {
|
||||||
const r = await change("DELETE", "/views/" + v.id, undefined, { query: cascade ? { cascade: 1 } : undefined });
|
const r = await change("DELETE", "/views/" + v.id, undefined, { query: cascade ? { cascade: 1 } : undefined });
|
||||||
toast(describeResult(r));
|
toast(describeResult(r));
|
||||||
rerender();
|
// Applied at once, the view is gone; staged, it is still there.
|
||||||
|
if (r.applied) location.hash = "#/views";
|
||||||
|
else rerender();
|
||||||
} catch (e) { toast(e.message, "err", 8000); }
|
} catch (e) { toast(e.message, "err", 8000); }
|
||||||
}
|
}
|
||||||
@@ -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" });
|
||||||
@@ -396,6 +402,20 @@ async function recordsTab(el, zone, query) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
for (const f of [fName, fData, fTTL]) f.addEventListener("input", preview);
|
for (const f of [fName, fData, fTTL]) f.addEventListener("input", preview);
|
||||||
|
// aliasTo fills the add form with a CNAME to a record's name: only the
|
||||||
|
// alias's name is left to type.
|
||||||
|
const aliasTo = (r) => {
|
||||||
|
fType.value = "CNAME";
|
||||||
|
showHint();
|
||||||
|
fData.value = r.relative_name === "@" ? "@" : r.relative_name;
|
||||||
|
fName.value = "";
|
||||||
|
fTTL.value = "";
|
||||||
|
fComment.value = "";
|
||||||
|
preview();
|
||||||
|
form.scrollIntoView({ block: "nearest", behavior: "smooth" });
|
||||||
|
fName.placeholder = "alias for " + r.name.replace(/\.$/, "");
|
||||||
|
fName.focus();
|
||||||
|
};
|
||||||
const add = async (e) => {
|
const add = async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
mount(err);
|
mount(err);
|
||||||
@@ -413,6 +433,7 @@ async function recordsTab(el, zone, query) {
|
|||||||
// Start the next record from scratch (the type stays, as records
|
// Start the next record from scratch (the type stays, as records
|
||||||
// are often added several of a kind at a time).
|
// are often added several of a kind at a time).
|
||||||
fName.value = "";
|
fName.value = "";
|
||||||
|
fName.placeholder = reverse && rinfo ? rinfo.example : "@ or www";
|
||||||
fData.value = "";
|
fData.value = "";
|
||||||
fTTL.value = "";
|
fTTL.value = "";
|
||||||
fComment.value = "";
|
fComment.value = "";
|
||||||
@@ -453,12 +474,15 @@ 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,
|
||||||
|
!reverse && (r.type === "A" || r.type === "AAAA") && !r.disabled
|
||||||
|
? [h("button", { class: "small", title: "Add a CNAME record that points to " + r.name.replace(/\.$/, ""), onclick: () => aliasTo(r) }, "+ CNAME"), " "] : 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
|
||||||
? h("button", { class: "small toggle", title: "Put the record back in the zone", onclick: () => toggle(r) }, "Enable")
|
? h("button", { class: "small toggle", title: "Put the record back in the zone", onclick: () => toggle(r) }, "Enable")
|
||||||
@@ -652,3 +676,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);
|
||||||
@@ -49,7 +49,8 @@ export async function render(el, params, query) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function newZoneDialog(views, zones, settings) {
|
// preset.view picks the view (opened from a view's page).
|
||||||
|
export async function newZoneDialog(views, zones, settings, preset = {}) {
|
||||||
const err = h("div");
|
const err = h("div");
|
||||||
// The zone's name matters most and is easy to get wrong, so the dialog
|
// The zone's name matters most and is easy to get wrong, so the dialog
|
||||||
// asks for a domain or a network and shows the resulting zone name.
|
// asks for a domain or a network and shows the resulting zone name.
|
||||||
@@ -60,7 +61,7 @@ export async function newZoneDialog(views, zones, settings) {
|
|||||||
const preview = h("div", { class: "name-preview", "aria-live": "polite" });
|
const preview = h("div", { class: "name-preview", "aria-live": "polite" });
|
||||||
const kindButtons = h("div", { class: "segmented", role: "group", "aria-label": "Kind of zone" });
|
const kindButtons = h("div", { class: "segmented", role: "group", "aria-label": "Kind of zone" });
|
||||||
const type = select([["primary", "primary (Jarl manages the records)"], ["secondary", "secondary (copy from another server)"], ["forward", "forward (send queries to other resolvers)"], ["stub", "stub"], ["static-stub", "static-stub"], ["mirror", "mirror"], ["hint", "hint (root hints)"], ["in-view", "in-view (share a zone from an earlier view)"]], "primary");
|
const type = select([["primary", "primary (Jarl manages the records)"], ["secondary", "secondary (copy from another server)"], ["forward", "forward (send queries to other resolvers)"], ["stub", "stub"], ["static-stub", "static-stub"], ["mirror", "mirror"], ["hint", "hint (root hints)"], ["in-view", "in-view (share a zone from an earlier view)"]], "primary");
|
||||||
const view = select([["", views.length ? "– choose a view –" : "(no views)"], ...views.map((v) => [v.name, v.name])], views.length === 1 ? views[0].name : "");
|
const view = select([["", views.length ? "– choose a view –" : "(no views)"], ...views.map((v) => [v.name, v.name])], preset.view || (views.length === 1 ? views[0].name : ""));
|
||||||
const ns = textarea((settings.nameservers || []).join("\n"), { class: "mono", rows: 3, placeholder: "ns1.example.com." });
|
const ns = textarea((settings.nameservers || []).join("\n"), { class: "mono", rows: 3, placeholder: "ns1.example.com." });
|
||||||
const nsAddr = textarea("", { class: "mono", rows: 2, placeholder: "ns1 192.0.2.1" });
|
const nsAddr = textarea("", { class: "mono", rows: 2, placeholder: "ns1 192.0.2.1" });
|
||||||
const contact = input({ value: settings.rname ? emailOf(settings.rname) : "", placeholder: "hostmaster@example.com" });
|
const contact = input({ value: settings.rname ? emailOf(settings.rname) : "", placeholder: "hostmaster@example.com" });
|
||||||
@@ -239,7 +240,7 @@ export function emailOf(rname) {
|
|||||||
// dnssecLock marks a signed zone with a padlock linking to its DNSSEC tab:
|
// dnssecLock marks a signed zone with a padlock linking to its DNSSEC tab:
|
||||||
// closed while signed, open while signing is being turned off (BIND's
|
// closed while signed, open while signing is being turned off (BIND's
|
||||||
// insecure policy).
|
// insecure policy).
|
||||||
function dnssecLock(z) {
|
export function dnssecLock(z) {
|
||||||
if (!z.dnssec_policy) return null;
|
if (!z.dnssec_policy) return null;
|
||||||
const off = z.dnssec_policy === "insecure";
|
const off = z.dnssec_policy === "insecure";
|
||||||
const a = h("a", {
|
const a = h("a", {
|
||||||
|
|||||||
@@ -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); }
|
||||||
@@ -127,7 +130,10 @@ main { padding: 16px; max-width: 1400px; margin: 0 auto; }
|
|||||||
/* Panels and cards */
|
/* Panels and cards */
|
||||||
.panel { background: var(--panel); border: 1px solid var(--border); border-radius: var(--radius); padding: 14px 16px; box-shadow: var(--shadow); margin-bottom: 14px; }
|
.panel { background: var(--panel); border: 1px solid var(--border); border-radius: var(--radius); padding: 14px 16px; box-shadow: var(--shadow); margin-bottom: 14px; }
|
||||||
.panel > h2:first-child, .panel > h3:first-child { margin-top: 0; }
|
.panel > h2:first-child, .panel > h3:first-child { margin-top: 0; }
|
||||||
.cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(230px, 1fr)); gap: 12px; margin-bottom: 14px; }
|
/* Tiles share a row and grow to fill it, so a wrapped tile never sits
|
||||||
|
alone beside an empty space. */
|
||||||
|
.cards { display: flex; flex-wrap: wrap; gap: 12px; margin-bottom: 14px; }
|
||||||
|
.cards > * { flex: 1 1 170px; min-width: 0; }
|
||||||
.card { background: var(--panel); border: 1px solid var(--border); border-radius: var(--radius); padding: 12px 14px; box-shadow: var(--shadow); }
|
.card { background: var(--panel); border: 1px solid var(--border); border-radius: var(--radius); padding: 12px 14px; box-shadow: var(--shadow); }
|
||||||
.card .label { color: var(--muted); font-size: 12px; text-transform: uppercase; letter-spacing: 0.4px; }
|
.card .label { color: var(--muted); font-size: 12px; text-transform: uppercase; letter-spacing: 0.4px; }
|
||||||
.card .value { font-size: 18px; font-weight: 600; margin-top: 2px; }
|
.card .value { font-size: 18px; font-weight: 600; margin-top: 2px; }
|
||||||
@@ -150,6 +156,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 +175,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; }
|
||||||
@@ -310,6 +320,7 @@ td.break { word-break: break-all; }
|
|||||||
}
|
}
|
||||||
|
|
||||||
.refreshing { opacity: 0.6; transition: opacity 0.2s; }
|
.refreshing { opacity: 0.6; transition: opacity 0.2s; }
|
||||||
|
h2.section { margin: 24px 0 10px; font-size: 18px; }
|
||||||
.filter-row { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin-bottom: 12px; }
|
.filter-row { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin-bottom: 12px; }
|
||||||
.segmented { display: inline-flex; border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden; background: var(--panel); }
|
.segmented { display: inline-flex; border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden; background: var(--panel); }
|
||||||
.segmented a { padding: 4px 12px; color: var(--text); border-right: 1px solid var(--border); text-decoration: none; font-variant-numeric: tabular-nums; }
|
.segmented a { padding: 4px 12px; color: var(--text); border-right: 1px solid var(--border); text-decoration: none; font-variant-numeric: tabular-nums; }
|
||||||
@@ -322,7 +333,16 @@ td.break { word-break: break-all; }
|
|||||||
.chart-head { display: flex; align-items: flex-start; gap: 12px; flex-wrap: wrap; margin-bottom: 8px; }
|
.chart-head { display: flex; align-items: flex-start; gap: 12px; flex-wrap: wrap; margin-bottom: 8px; }
|
||||||
.chart-head .grow { flex: 1; min-width: 200px; }
|
.chart-head .grow { flex: 1; min-width: 200px; }
|
||||||
.chart-head h2 { margin: 0; font-size: 16px; }
|
.chart-head h2 { margin: 0; font-size: 16px; }
|
||||||
.chart-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 0 14px; }
|
/* Two or three columns; the last cards stretch to fill their row, so no
|
||||||
|
card is left alone beside an empty space. */
|
||||||
|
.chart-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 14px; }
|
||||||
|
.chart-grid > :last-child:nth-child(odd) { grid-column: 1 / -1; }
|
||||||
|
@media (min-width: 1100px) {
|
||||||
|
.chart-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
||||||
|
.chart-grid > :last-child:nth-child(odd) { grid-column: auto; }
|
||||||
|
.chart-grid > :last-child:nth-child(3n+1) { grid-column: 1 / -1; }
|
||||||
|
.chart-grid > :last-child:nth-child(3n+2) { grid-column: span 2; }
|
||||||
|
}
|
||||||
.chart-grid > .panel h2 { margin: 0; font-size: 16px; }
|
.chart-grid > .panel h2 { margin: 0; font-size: 16px; }
|
||||||
.list-sub { margin: 2px 0 8px; }
|
.list-sub { margin: 2px 0 8px; }
|
||||||
|
|
||||||
@@ -443,5 +463,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; }
|
||||||
@@ -44,7 +44,7 @@ export function fmtTime(iso) {
|
|||||||
if (!iso) return "";
|
if (!iso) return "";
|
||||||
const d = new Date(iso);
|
const d = new Date(iso);
|
||||||
if (isNaN(d)) return iso;
|
if (isNaN(d)) return iso;
|
||||||
return d.toLocaleString(undefined, { year: "numeric", month: "short", day: "2-digit", hour: "2-digit", minute: "2-digit" });
|
return d.toLocaleString(undefined, { year: "numeric", month: "short", day: "2-digit", hour: "2-digit", minute: "2-digit", hourCycle: "h23" });
|
||||||
}
|
}
|
||||||
|
|
||||||
export function relTime(iso) {
|
export function relTime(iso) {
|
||||||
@@ -276,9 +276,9 @@ export function viewLink(v, name, back) {
|
|||||||
return h("a", { href: viewHref(v.id, back), class: v.color ? "vc-" + v.color : null, title: "View " + v.name }, v.name);
|
return h("a", { href: viewHref(v.id, back), class: v.color ? "vc-" + v.color : null, title: "View " + v.name }, v.name);
|
||||||
}
|
}
|
||||||
|
|
||||||
// viewHref is the address of a view on the views page.
|
// viewHref is the address of a view's page.
|
||||||
export function viewHref(id, back) {
|
export function viewHref(id, back) {
|
||||||
return "#/views?view=" + id + (back ? "&back=" + encodeURIComponent(back) : "");
|
return "#/views/" + id + (back ? "?back=" + encodeURIComponent(back) : "");
|
||||||
}
|
}
|
||||||
|
|
||||||
// colorPicker picks a view colour; its value is "" for the default.
|
// colorPicker picks a view colour; its value is "" for the default.
|
||||||
|
|||||||
@@ -139,6 +139,7 @@ expect_dig() { # DESC WANT DIG-ARGS...
|
|||||||
if [ "$got" = "$want" ]; then ok "dig $desc = '$want'"; else bad "dig $desc: got '$got' want '$want'"; fi
|
if [ "$got" = "$want" ]; then ok "dig $desc = '$want'"; else bad "dig $desc: got '$got' want '$want'"; fi
|
||||||
}
|
}
|
||||||
contains() { if echo "$OUT" | grep -q -- "$1"; then ok "output has '$1'"; else bad "output lacks '$1'"; echo "$OUT" | sed 's/^/ /'; fi; }
|
contains() { if echo "$OUT" | grep -q -- "$1"; then ok "output has '$1'"; else bad "output lacks '$1'"; echo "$OUT" | sed 's/^/ /'; fi; }
|
||||||
|
not_contains() { if echo "$OUT" | grep -q -- "$1"; then bad "output has '$1'"; echo "$OUT" | sed 's/^/ /'; else ok "output lacks '$1'"; fi; }
|
||||||
|
|
||||||
# With nothing configured, the CLI uses jarld's local socket, where
|
# With nothing configured, the CLI uses jarld's local socket, where
|
||||||
# jarld's own user (this script's) is an administrator without a token.
|
# jarld's own user (this script's) is an administrator without a token.
|
||||||
@@ -296,10 +297,41 @@ contains "token.revoke"
|
|||||||
run "view create (adopts zones)" 0 $J view create internal --match-clients homelab --adopt-zones
|
run "view create (adopts zones)" 0 $J view create internal --match-clients homelab --adopt-zones
|
||||||
run "view create external" 0 $J view create external --match-clients any
|
run "view create external" 0 $J view create external --match-clients any
|
||||||
run "zone create in external" 0 $J zone create adbraun.test --view external --ns ns1.adbraun.test. --record "ns1 A 203.0.113.1"
|
run "zone create in external" 0 $J zone create adbraun.test --view external --ns ns1.adbraun.test. --record "ns1 A 203.0.113.1"
|
||||||
|
# View keys: a query signed with a view's key lands in that view, from
|
||||||
|
# any address (this host is in homelab, so unsigned it gets internal).
|
||||||
|
run "view key" 0 sh -c "$J view key external --named-conf > $E/external.key"
|
||||||
|
expect_dig "unsigned from here: the internal view" "127.0.0.1" ns1.adbraun.test A
|
||||||
|
expect_dig "signed with the external view's key" "203.0.113.1" -k $E/external.key ns1.adbraun.test A
|
||||||
|
run "view key shows a dig command" 0 $J view key external
|
||||||
|
contains "dig -y hmac-sha256:jarl-view-external:"
|
||||||
|
# named-checkconf's own warnings stay on the dashboard after the apply.
|
||||||
|
run "option set recursion no (external)" 0 $J option set recursion no --view external
|
||||||
|
run "allow-recursion without its braces" 0 $J option set allow-recursion 192.0.2.1 --view external
|
||||||
|
contains "named-checkconf"
|
||||||
|
run "status keeps named's warning" 0 $J status
|
||||||
|
contains "named-checkconf: .*recursion"
|
||||||
|
run "option list: braces added" 0 $J option list --view external
|
||||||
|
contains "{ 192.0.2.1; }"
|
||||||
|
run "unset allow-recursion" 0 $J option unset allow-recursion --view external
|
||||||
|
run "unset recursion" 0 $J option unset recursion --view external
|
||||||
|
run "status: the warning is gone" 0 $J status
|
||||||
|
not_contains "named-checkconf"
|
||||||
run "zone list shows both views" 0 $J zone list
|
run "zone list shows both views" 0 $J zone list
|
||||||
contains "external"
|
contains "external"
|
||||||
expect_dig "internal view nas" "192.168.1.20" nas.adbraun.test A
|
expect_dig "internal view nas" "192.168.1.20" nas.adbraun.test A
|
||||||
run "record add needs a view now" 6 $J record add adbraun.test x A 192.168.1.90
|
run "record add needs a view now" 6 $J record add adbraun.test x A 192.168.1.90
|
||||||
|
run "record add --view all" 0 $J record add adbraun.test both A 192.0.2.77 --view all
|
||||||
|
contains "applied"
|
||||||
|
expect_dig "both views: internal has it" "192.0.2.77" both.adbraun.test A
|
||||||
|
run "record list external has it too" 0 $J record list adbraun.test --view external
|
||||||
|
contains "192.0.2.77"
|
||||||
|
run "several views are all-or-nothing (exit 4)" 4 $J record add adbraun.test half A 192.0.2.78 --view internal,nosuch
|
||||||
|
contains "nothing was changed in any view"
|
||||||
|
expect_dig "so internal didn't get it" "" half.adbraun.test A
|
||||||
|
run "record delete --view internal,external" 0 $J record delete adbraun.test both A --view internal,external -y
|
||||||
|
expect_dig "deleted in both" "" both.adbraun.test A
|
||||||
|
run "and gone from external" 0 $J record list adbraun.test --view external
|
||||||
|
not_contains "192.0.2.77"
|
||||||
run "acme set goes into both views" 0 $J acme set adbraun.test both-views
|
run "acme set goes into both views" 0 $J acme set adbraun.test both-views
|
||||||
expect_dig "acme TXT (internal, from 127.0.0.1)" '"both-views"' _acme-challenge.adbraun.test TXT
|
expect_dig "acme TXT (internal, from 127.0.0.1)" '"both-views"' _acme-challenge.adbraun.test TXT
|
||||||
run "acme set --view external only" 0 $J acme unset adbraun.test --view internal
|
run "acme set --view external only" 0 $J acme unset adbraun.test --view internal
|
||||||
@@ -410,6 +442,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 +492,19 @@ 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
|
||||||
|
run "block pause indefinitely" 0 $J block pause indefinitely
|
||||||
|
expect_rpz "paused until resumed: a listed name is answered" x.tracker.adbraun.test none
|
||||||
|
run "block list says until resumed" 0 $J block list
|
||||||
|
contains "paused until you resume it"
|
||||||
|
run "block resume after an indefinite pause" 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