Compare commits
26
Commits
v0.3.0
...
v0.4.1-rc.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cf550bd512 | ||
|
|
cee11e7c86 | ||
|
|
e2e5007f94 | ||
|
|
49b1c872c9 | ||
|
|
c37de31f4c | ||
|
|
7e6e274a89 | ||
|
|
11ed877c3c | ||
|
|
76aaaf3ef5 | ||
|
|
5b8f681e2a | ||
|
|
8f0a0cc233 | ||
|
|
ecffc2d5c1 | ||
|
|
9d9ae37367 | ||
|
|
384f6b3060 | ||
|
|
c696d02513 | ||
|
|
7be0762f48 | ||
|
|
b097e10bef | ||
|
|
73d97ecc0a | ||
|
|
33060f86ce | ||
|
|
89663e366b | ||
|
|
59baccd636 | ||
|
|
02c393a6bb | ||
|
|
d4756444ac | ||
|
|
67f14b030f | ||
|
|
ad8eb3fc3d | ||
|
|
27d1f9e538 | ||
|
|
9fc23310bd |
No files matched your search
+235
@@ -6,6 +6,241 @@ All notable changes to Jarl are documented here. The format follows
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Upgrading from 0.4
|
||||||
|
|
||||||
|
- Once you use address ranges, the database can't go back to 0.4: its
|
||||||
|
parser doesn't know them. Remove the ranges first if you need to
|
||||||
|
downgrade.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- A changeset with a staged record delete could not be applied after that
|
||||||
|
record was deleted elsewhere (e.g. with the CLI), and it was hard to see
|
||||||
|
which operation to remove: the error only said "record 832 does not
|
||||||
|
exist". A staged delete of a record that is already gone now counts as
|
||||||
|
done (with a warning), and the other operations that no longer apply
|
||||||
|
(an edit or disable of a deleted record, a change to an object changed
|
||||||
|
since) are named with their number and summary. The Changes page marks
|
||||||
|
that operation and offers to remove it right in the error.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- `jarl changeset show`, `diff`, `apply`, `discard` and `remove` take the
|
||||||
|
changeset from `-C` or `JARL_CHANGESET` when no ID is given
|
||||||
|
(`jarl changeset apply`). A changeset from `JARL_CHANGESET` is now
|
||||||
|
checked before it is used, for staging too: it must exist, be open and
|
||||||
|
be yours, so a stale export (an applied changeset, or an ID that later
|
||||||
|
turned into someone else's web UI draft) can't silently take your
|
||||||
|
changes; the error says what to do. Commands say on stderr which
|
||||||
|
changeset from `JARL_CHANGESET` they use, and `apply`/`discard` remind
|
||||||
|
you to `unset JARL_CHANGESET`. `-C` and ID arguments are used as given.
|
||||||
|
- Address ranges such as `10.0.10.101-10.0.10.199` or
|
||||||
|
`2001:db8::100-2001:db8::1ff` (also `!` negated, and with spaces around
|
||||||
|
the dash) wherever an address match list is used: ACLs, views'
|
||||||
|
match-clients and match-destinations, options such as allow-query, and
|
||||||
|
blocking's client groups ([docs/concepts.md](docs/concepts.md)). Jarl
|
||||||
|
keeps the range as written and writes the fewest prefixes that cover
|
||||||
|
exactly that range to named.conf, with the range as a comment. The web
|
||||||
|
UI shows what a range becomes while you type it (`GET
|
||||||
|
/catalog/ranges`). Mistakes are refused when entered: an end that isn't
|
||||||
|
a full address, the start after the end, IPv4 mixed with IPv6, a range
|
||||||
|
in an option that takes no address match list (forwarders). A range
|
||||||
|
covered by an earlier element is reported as never matching, a range of
|
||||||
|
every address counts as "any" for the open resolver and open transfer
|
||||||
|
checks, and an IPv6 range in the IPv4-mapped space gets a warning. ACL
|
||||||
|
names can no longer look like a range.
|
||||||
|
- Prometheus metrics at `/metrics` ([docs/metrics.md](docs/metrics.md)),
|
||||||
|
set up in the new `metrics:` section of jarl.yaml (off by default).
|
||||||
|
`metrics.allow` lists the addresses that may scrape (loopback by
|
||||||
|
default), `metrics.token` lets anyone who sends it as a bearer token
|
||||||
|
scrape, and `metrics.listen` adds addresses that serve only `/metrics`
|
||||||
|
(for a Prometheus server elsewhere). Root and jarld's own user can also
|
||||||
|
scrape on the local socket.
|
||||||
|
- Health, always: named up, recursive and TCP clients against their
|
||||||
|
limits, adopted, open changesets, last apply, validation problems by
|
||||||
|
severity, drifted files, object counts, the last backup (and failure,
|
||||||
|
and whether it is stale), blocking paused, each blocklist's entries,
|
||||||
|
last check and change and whether it fails, the DNSSEC phase of every
|
||||||
|
signed zone, and when API tokens expire.
|
||||||
|
- With query statistics on: named's counters (queries, result codes,
|
||||||
|
record types, UDP/TCP, authoritative/recursive, rate limiting, upstream
|
||||||
|
round-trip times, DNSSEC validation, cache hits) and Jarl's (logged
|
||||||
|
responses per view and zone, blocked queries per blocklist), as
|
||||||
|
counters since jarld started, plus named's memory, CPU, threads, cache
|
||||||
|
and sockets, and the collector's health.
|
||||||
|
- Example queries and alert rules, checked with promtool.
|
||||||
|
|
||||||
|
## [0.4.0] - 2026-10-11
|
||||||
|
|
||||||
|
### Upgrading from 0.3
|
||||||
|
|
||||||
|
- Back up first (`sudo jarld backup`).
|
||||||
|
- When jarld first starts, it rewrites named's configuration: options
|
||||||
|
move into their new fixed order, and every view gets its view key in
|
||||||
|
`match-clients` and `keys.conf`. It is checked with named-checkconf
|
||||||
|
before named reloads, like any apply. Files you changed by hand are left
|
||||||
|
alone; the log then says to run `jarl resync`.
|
||||||
|
- The systemd unit changed (it allows `AF_NETLINK`). The package replaces
|
||||||
|
it; an override that adds `AF_NETLINK` (`systemctl edit jarl`) does no
|
||||||
|
harm and can be removed.
|
||||||
|
- The database moves to schema 8 (disabled zones, client groups) as
|
||||||
|
jarld starts.
|
||||||
|
- If statistics showed a spike after views were created or changed, see
|
||||||
|
"Statistics" under Fixed: the bad minutes can be removed from
|
||||||
|
`/var/lib/jarl/stats.db` (minute rows have `res = 'm'`; the hours and
|
||||||
|
days that include them are `'h'` and `'d'`).
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Different blocking for different devices (docs/blocking.md), like
|
||||||
|
Pi-hole's groups, in one view and without loading any list twice.
|
||||||
|
Client groups put devices (addresses, networks or ACLs of them) at a
|
||||||
|
blocking step: `off` (only your rules), `normal` (the default), or steps
|
||||||
|
you add above it, each including the lists of the steps below. Lists
|
||||||
|
start at a step. Jarl puts a small "gate" policy zone in front of each
|
||||||
|
step's lists that lets the devices below that step through. Web UI: a
|
||||||
|
Devices panel on the Blocking page and a step on each list. CLI: `jarl
|
||||||
|
block step`, `jarl block group`, `jarl blocklist update --step`. API:
|
||||||
|
`/blocking/steps`, `/blocking/groups`. Database schema 8.
|
||||||
|
- 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,
|
||||||
|
trackers and malware, using BIND's response policy zones.
|
||||||
|
- Blocklists in Adblock, hosts or plain-domain format, downloaded by jarld
|
||||||
|
and checked daily (`blocking.refresh`). A list is downloaded only when
|
||||||
|
the server says it changed, checked with named-checkzone, and reloaded
|
||||||
|
only when its content differs; a failed update keeps the previous
|
||||||
|
version and shows on the dashboard.
|
||||||
|
- Your own block and allow rules, for a domain and the names below it or
|
||||||
|
the exact name. Rules take precedence over every list, and the most
|
||||||
|
specific rule wins.
|
||||||
|
- Lists and rules apply to chosen views, or to every view that offers
|
||||||
|
recursion. They are configuration: staged, reviewed and reverted like
|
||||||
|
any change.
|
||||||
|
- Checking a name says whether it is blocked and by which rule or list
|
||||||
|
entry, with an Allow button in the web UI.
|
||||||
|
- Web UI: a Blocking page, with well-known lists to pick and named's
|
||||||
|
memory per list. CLI: `jarl blocklist` and `jarl block`. API:
|
||||||
|
`/blocking/lists`, `/blocking/rules`, `/blocking/check`.
|
||||||
|
- Pausing: `jarl block pause [DURATION]` or the Blocking page lets every
|
||||||
|
name through for a while (at most a day); blocking resumes by itself.
|
||||||
|
The lists stay loaded, so it takes effect at once.
|
||||||
|
- Statistics: blocked queries over time (a line on the queries chart)
|
||||||
|
and their share, blocked per list, the most blocked names and the
|
||||||
|
clients with the most blocked queries (`jarl stats top blocked`,
|
||||||
|
`top blocked-clients`). Blocked answers no longer count as names that
|
||||||
|
don't exist.
|
||||||
|
- The view choice in the web UI's forms is a compact dropdown, however
|
||||||
|
many views there are.
|
||||||
|
- A `blocking` permission; database schema 6.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Web UI: 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. It shows when the row is hovered or focused
|
||||||
|
(always on touch screens).
|
||||||
|
- Blocking page: how many queries each blocklist blocked in the last 24
|
||||||
|
hours, and your rules together, with statistics on (linking to the
|
||||||
|
Statistics page).
|
||||||
|
- 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
|
||||||
|
|
||||||
|
- Reverting a changeset added needless "restore" operations for
|
||||||
|
blocklists, rules and views that hadn't changed: copying the state
|
||||||
|
turned their empty view lists into missing ones.
|
||||||
|
- 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.
|
||||||
|
|
||||||
## [0.3.0] - 2026-10-09
|
## [0.3.0] - 2026-10-09
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -71,6 +71,15 @@ error: www.adbraun.lol has a CNAME, so it can't have any other records (found A)
|
|||||||
queries, record types, cache hit rate. Shown in the web UI and the CLI,
|
queries, record types, cache hit rate. Shown in the web UI and the CLI,
|
||||||
and exportable as JSON. Stored as aggregates in a separate database with
|
and exportable as JSON. Stored as aggregates in a separate database with
|
||||||
retention you set.
|
retention you set.
|
||||||
|
* **Prometheus metrics** (optional): health to alert on (named down,
|
||||||
|
configuration problems, drift, failing blocklists, stale backups, DNSSEC
|
||||||
|
steps waiting at the parent, expiring tokens) and query counters per
|
||||||
|
result code, view, zone and blocklist, with example alerts. Set up in
|
||||||
|
jarl.yaml, with an address allow list and/or a bearer token.
|
||||||
|
* **Blocking** like Pi-hole, done by named itself: blocklists (Adblock,
|
||||||
|
hosts or plain domains) downloaded and kept up to date, your own block
|
||||||
|
and allow rules that beat every list, per view, with "why is this
|
||||||
|
blocked?" checks and blocked counts in the statistics.
|
||||||
* **Let's Encrypt DNS-01** built in: `jarl acme` plus certbot, acme.sh and lego
|
* **Let's Encrypt DNS-01** built in: `jarl acme` plus certbot, acme.sh and lego
|
||||||
hooks, an HTTPS-only endpoint for lego's and Traefik's `httpreq` provider,
|
hooks, an HTTPS-only endpoint for lego's and Traefik's `httpreq` provider,
|
||||||
and CNAME delegation to a dedicated ACME zone.
|
and CNAME delegation to a dedicated ACME zone.
|
||||||
@@ -116,6 +125,8 @@ in `/etc/jarl/jarl.yaml` to reach it from your management network.
|
|||||||
| [docs/dnssec.md](docs/dnssec.md) | DNSSEC signing, going public with your own name servers, Porkbun |
|
| [docs/dnssec.md](docs/dnssec.md) | DNSSEC signing, going public with your own name servers, Porkbun |
|
||||||
| [docs/acme.md](docs/acme.md) | Let's Encrypt DNS-01 with certbot, acme.sh, lego and Traefik |
|
| [docs/acme.md](docs/acme.md) | Let's Encrypt DNS-01 with certbot, acme.sh, lego and Traefik |
|
||||||
| [docs/stats.md](docs/stats.md) | Query statistics: enabling, what is collected, retention |
|
| [docs/stats.md](docs/stats.md) | Query statistics: enabling, what is collected, retention |
|
||||||
|
| [docs/metrics.md](docs/metrics.md) | Prometheus metrics: access, what is exported, example alerts |
|
||||||
|
| [docs/blocking.md](docs/blocking.md) | Blocking ads, trackers and malware: blocklists, your own rules, views, memory |
|
||||||
| [docs/validation.md](docs/validation.md) | Every check Jarl makes |
|
| [docs/validation.md](docs/validation.md) | Every check Jarl makes |
|
||||||
| [docs/operations.md](docs/operations.md) | Backups, drift, failures, recovery, monitoring |
|
| [docs/operations.md](docs/operations.md) | Backups, drift, failures, recovery, monitoring |
|
||||||
| [docs/development.md](docs/development.md) | Code layout, tests against real BIND, conventions |
|
| [docs/development.md](docs/development.md) | Code layout, tests against real BIND, conventions |
|
||||||
@@ -166,9 +177,14 @@ review step.
|
|||||||
|
|
||||||
## Status
|
## Status
|
||||||
|
|
||||||
Version 0.3.0 is the current release. It adds disabled records,
|
Version 0.4.0 is the current release, and 0.4.1 is in testing
|
||||||
script-friendly CLI output and client names in the statistics, on top of
|
(0.4.1-rc.1): it adds Prometheus metrics, address ranges in ACLs and
|
||||||
0.2.0's DNSSEC signing, Porkbun support, two-factor login and view colours. Jarl is feature-complete for one server's configuration.
|
other address match lists, and safer changeset handling. 0.4.0 added blocking (blocklists and
|
||||||
|
your own rules, like Pi-hole, done by named, with different blocking for
|
||||||
|
different devices), disabling zones, server statistics and view keys, on
|
||||||
|
top of 0.3.0's disabled records, script-friendly CLI output and client
|
||||||
|
names in the statistics, and 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)).
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,508 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
|
"jarl/internal/api"
|
||||||
|
"jarl/internal/blocking"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ---- blocking (docs/blocking.md)
|
||||||
|
|
||||||
|
// viewsBody adds the --view flags to a request body: names, or "auto" for
|
||||||
|
// every view that offers recursion.
|
||||||
|
func viewsBody(body map[string]any, views []string) {
|
||||||
|
if len(views) == 1 && strings.EqualFold(views[0], "auto") {
|
||||||
|
body["views"] = []string{}
|
||||||
|
} else if len(views) > 0 {
|
||||||
|
body["views"] = views
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func viewsText(views []string, auto bool) string {
|
||||||
|
switch {
|
||||||
|
case auto:
|
||||||
|
return "auto (" + strings.Join(views, ", ") + ")"
|
||||||
|
case len(views) == 0:
|
||||||
|
return "-"
|
||||||
|
}
|
||||||
|
return strings.Join(views, ", ")
|
||||||
|
}
|
||||||
|
|
||||||
|
func blocklistCmd() *cobra.Command {
|
||||||
|
cmd := &cobra.Command{Use: "blocklist", Aliases: []string{"blocklists"}, Short: "Blocklists: domains to block, downloaded and kept up to date",
|
||||||
|
Long: `Blocklists are downloaded by jarld and turned into response policy zones, so
|
||||||
|
named answers "doesn't exist" for the domains on them (docs/blocking.md).
|
||||||
|
Hosts, plain domain and Adblock (||domain^) lists work. Your own block and
|
||||||
|
allow rules ('jarl block') take precedence over every list.`}
|
||||||
|
var views []string
|
||||||
|
var comment, step string
|
||||||
|
|
||||||
|
list := &cobra.Command{
|
||||||
|
Use: "list",
|
||||||
|
Short: "List blocklists and their state",
|
||||||
|
Args: cobra.NoArgs,
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
c, err := newClient()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var lists []api.Blocklist
|
||||||
|
if err := c.Get(ctx(), "/blocking/lists", nil, &lists); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return output(lists, func(w io.Writer) {
|
||||||
|
fmt.Fprintln(w, "NAME\tSTATE\tSTEP\tDOMAINS\tVIEWS\tUPDATED\tMEMORY\tURL")
|
||||||
|
for _, b := range lists {
|
||||||
|
domains, updated, mem := "-", "-", "-"
|
||||||
|
if s := b.Status; s != nil && s.UpdatedAt != nil {
|
||||||
|
domains, updated = fmt.Sprint(s.Entries), s.UpdatedAt.Local().Format("2006-01-02 15:04")
|
||||||
|
}
|
||||||
|
if b.MemoryMB > 0 {
|
||||||
|
mem = fmt.Sprintf("~%d MB", b.MemoryMB)
|
||||||
|
}
|
||||||
|
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n", b.Name, b.State, b.Step, domains, viewsText(b.Views, b.AutoViews), updated, mem, b.URL)
|
||||||
|
}
|
||||||
|
for _, b := range lists {
|
||||||
|
if b.Status != nil && b.Status.Error != "" {
|
||||||
|
fmt.Fprintf(w, "\n%s: %s\n", b.Name, b.Status.Error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
add := &cobra.Command{
|
||||||
|
Use: "add NAME URL",
|
||||||
|
Short: "Add a blocklist (jarld downloads it right away)",
|
||||||
|
Long: `Add a blocklist. NAME is a short label (letters, digits, hyphens). Without
|
||||||
|
--view the list applies to every view that offers recursion.
|
||||||
|
|
||||||
|
jarl blocklist add hagezi-pro https://raw.githubusercontent.com/hagezi/dns-blocklists/main/adblock/pro.txt
|
||||||
|
jarl blocklist add stevenblack https://raw.githubusercontent.com/StevenBlack/hosts/master/hosts --view internal`,
|
||||||
|
Args: cobra.ExactArgs(2),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
c, err := newClient()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
body := map[string]any{"name": args[0], "url": args[1]}
|
||||||
|
viewsBody(body, views)
|
||||||
|
if step != "" {
|
||||||
|
body["step"] = step
|
||||||
|
}
|
||||||
|
if comment != "" {
|
||||||
|
body["comment"] = comment
|
||||||
|
}
|
||||||
|
resp, err := c.Mutate(ctx(), "POST", "/blocking/lists", nil, body)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := report(resp); err != nil || flagQuiet || !resp.Applied {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
fmt.Println("jarld downloads the list in the background; 'jarl blocklist list' shows when it's in use.")
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
add.Flags().StringArrayVar(&views, "view", nil, "a view it applies to (repeat for several; default: every view that offers recursion)")
|
||||||
|
add.Flags().StringVar(&comment, "comment", "", "comment")
|
||||||
|
add.Flags().StringVar(&step, "step", "", "the blocking step it starts at (default: normal; see 'jarl block step')")
|
||||||
|
|
||||||
|
var rename, newURL string
|
||||||
|
update := &cobra.Command{
|
||||||
|
Use: "update LIST",
|
||||||
|
Short: "Change a blocklist's name, URL, views or comment",
|
||||||
|
Example: ` jarl blocklist update hagezi --url https://example.org/new-list.txt
|
||||||
|
jarl blocklist update hagezi --view internal --view guest
|
||||||
|
jarl blocklist update hagezi --view auto # every view that offers recursion`,
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
c, err := newClient()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
body := map[string]any{}
|
||||||
|
f := cmd.Flags()
|
||||||
|
if f.Changed("name") {
|
||||||
|
body["name"] = rename
|
||||||
|
}
|
||||||
|
if f.Changed("url") {
|
||||||
|
body["url"] = newURL
|
||||||
|
}
|
||||||
|
if f.Changed("comment") {
|
||||||
|
body["comment"] = comment
|
||||||
|
}
|
||||||
|
if f.Changed("step") {
|
||||||
|
body["step"] = step
|
||||||
|
}
|
||||||
|
viewsBody(body, views)
|
||||||
|
if len(body) == 0 {
|
||||||
|
return fmt.Errorf("nothing to change (see --help)")
|
||||||
|
}
|
||||||
|
resp, err := c.Mutate(ctx(), "PATCH", "/blocking/lists/"+url.PathEscape(args[0]), nil, body)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return report(resp)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
update.Flags().StringVar(&rename, "name", "", "new name")
|
||||||
|
update.Flags().StringVar(&newURL, "url", "", "new URL")
|
||||||
|
update.Flags().StringArrayVar(&views, "view", nil, `a view it applies to (repeat for several; "auto": every view that offers recursion)`)
|
||||||
|
update.Flags().StringVar(&comment, "comment", "", "new comment")
|
||||||
|
update.Flags().StringVar(&step, "step", "", "the blocking step it starts at (normal, or a step above it)")
|
||||||
|
|
||||||
|
toggle := func(disable bool) *cobra.Command {
|
||||||
|
verb := map[bool]string{true: "disable", false: "enable"}[disable]
|
||||||
|
return &cobra.Command{
|
||||||
|
Use: verb + " LIST",
|
||||||
|
Short: map[bool]string{true: "Stop using a blocklist (it is kept, and re-enabled without downloading)", false: "Use a disabled blocklist again"}[disable],
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
c, err := newClient()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
resp, err := c.Mutate(ctx(), "PATCH", "/blocking/lists/"+url.PathEscape(args[0]), nil, map[string]any{"disabled": disable})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return report(resp)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var yes bool
|
||||||
|
del := &cobra.Command{
|
||||||
|
Use: "delete LIST",
|
||||||
|
Short: "Delete a blocklist",
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
if !confirm("Delete blocklist "+args[0]+"?", yes) {
|
||||||
|
return fmt.Errorf("canceled")
|
||||||
|
}
|
||||||
|
c, err := newClient()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
resp, err := c.Mutate(ctx(), "DELETE", "/blocking/lists/"+url.PathEscape(args[0]), nil, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return report(resp)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
del.Flags().BoolVarP(&yes, "yes", "y", false, "don't ask")
|
||||||
|
|
||||||
|
var force bool
|
||||||
|
refresh := &cobra.Command{
|
||||||
|
Use: "refresh [LIST...]",
|
||||||
|
Short: "Download blocklists now (all enabled ones without LIST)",
|
||||||
|
Long: `Download blocklists now instead of waiting for their next check
|
||||||
|
(blocking.refresh in jarl.yaml, daily by default). A list is only replaced
|
||||||
|
when its content changed; --force downloads it even when the server says
|
||||||
|
it hasn't.`,
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
c, err := newClient()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
names := args
|
||||||
|
if len(names) == 0 {
|
||||||
|
var lists []api.Blocklist
|
||||||
|
if err := c.Get(ctx(), "/blocking/lists", nil, &lists); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, b := range lists {
|
||||||
|
if b.State != "disabled" && b.State != "idle" {
|
||||||
|
names = append(names, b.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if flagDryRun {
|
||||||
|
fmt.Printf("would download: %s\n", strings.Join(names, ", "))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
failed := 0
|
||||||
|
for _, n := range names {
|
||||||
|
var b api.Blocklist
|
||||||
|
start := time.Now()
|
||||||
|
err := c.Do(ctx(), "POST", "/blocking/lists/"+url.PathEscape(n)+"/refresh", q("force", map[bool]string{true: "1"}[force]), nil, &b)
|
||||||
|
switch {
|
||||||
|
case err != nil:
|
||||||
|
failed++
|
||||||
|
fmt.Printf("%s: %v\n", n, err)
|
||||||
|
case b.Status != nil && b.Status.Error != "":
|
||||||
|
failed++
|
||||||
|
fmt.Printf("%s: %s (the previous version stays in use)\n", n, b.Status.Error)
|
||||||
|
case b.Status != nil:
|
||||||
|
fmt.Printf("%s: %d domains (%d lines skipped), checked in %s\n", n, b.Status.Entries, b.Status.Skipped, time.Since(start).Round(100*time.Millisecond))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if failed > 0 {
|
||||||
|
return &checkFailed{fmt.Sprintf("%d of %d lists could not be updated", failed, len(names))}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
refresh.Flags().BoolVar(&force, "force", false, "download even when the server says the list hasn't changed")
|
||||||
|
|
||||||
|
cmd.AddCommand(list, add, update, toggle(true), toggle(false), del, refresh)
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func blockCmd() *cobra.Command {
|
||||||
|
cmd := &cobra.Command{Use: "block", Short: "Your own block and allow rules, and checking what is blocked",
|
||||||
|
Long: `Rules block a domain, or allow it despite the blocklists ('jarl blocklist').
|
||||||
|
A rule covers the domain and every name below it, unless --exact. Rules
|
||||||
|
take precedence over every list, and the most specific rule wins: allow
|
||||||
|
cdn.example.com overrides block example.com for cdn.example.com and the
|
||||||
|
names below it (docs/blocking.md).`}
|
||||||
|
var views []string
|
||||||
|
var comment, view string
|
||||||
|
var exact bool
|
||||||
|
|
||||||
|
addRule := func(action string) *cobra.Command {
|
||||||
|
c := &cobra.Command{
|
||||||
|
Use: map[string]string{"block": "add DOMAIN", "allow": "allow DOMAIN"}[action],
|
||||||
|
Short: map[string]string{"block": "Block a domain (and the names below it)", "allow": "Allow a domain (and the names below it) that a blocklist blocks"}[action],
|
||||||
|
Example: map[string]string{
|
||||||
|
"block": " jarl block add doubleclick.net\n jarl block add telemetry.example.com --exact --view internal",
|
||||||
|
"allow": " jarl block allow cdn.example.com\n jarl block allow login.example.com --exact",
|
||||||
|
}[action],
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
c, err := newClient()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
body := map[string]any{"name": args[0], "action": action}
|
||||||
|
if exact {
|
||||||
|
body["exact"] = true
|
||||||
|
}
|
||||||
|
viewsBody(body, views)
|
||||||
|
if comment != "" {
|
||||||
|
body["comment"] = comment
|
||||||
|
}
|
||||||
|
resp, err := c.Mutate(ctx(), "POST", "/blocking/rules", nil, body)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return report(resp)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
c.Flags().BoolVar(&exact, "exact", false, "only this name, not the names below it")
|
||||||
|
c.Flags().StringArrayVar(&views, "view", nil, "a view it applies to (repeat for several; default: every view that offers recursion)")
|
||||||
|
c.Flags().StringVar(&comment, "comment", "", "comment")
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
list := &cobra.Command{
|
||||||
|
Use: "list",
|
||||||
|
Short: "List your block and allow rules",
|
||||||
|
Args: cobra.NoArgs,
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
c, err := newClient()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var rules []api.BlockRule
|
||||||
|
if err := c.Get(ctx(), "/blocking/rules", nil, &rules); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
note := pausedNote(c)
|
||||||
|
return output(rules, func(w io.Writer) {
|
||||||
|
if note != "" {
|
||||||
|
fmt.Fprintln(w, note)
|
||||||
|
}
|
||||||
|
fmt.Fprintln(w, "ID\tACTION\tDOMAIN\tCOVERS\tVIEWS\tCOMMENT")
|
||||||
|
for _, r := range rules {
|
||||||
|
covers := "and below"
|
||||||
|
if r.Exact {
|
||||||
|
covers = "exact"
|
||||||
|
}
|
||||||
|
fmt.Fprintf(w, "%d\t%s\t%s\t%s\t%s\t%s\n", r.ID, r.Action, r.Name, covers, viewsText(r.Views, r.AutoViews), r.Comment)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var id int64
|
||||||
|
var missingOK bool
|
||||||
|
del := &cobra.Command{
|
||||||
|
Use: "delete DOMAIN | delete --id ID",
|
||||||
|
Short: "Delete the rules for a domain (or one rule by ID)",
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
c, err := newClient()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var resp *api.MutationResponse
|
||||||
|
switch {
|
||||||
|
case id > 0:
|
||||||
|
resp, err = c.Mutate(ctx(), "DELETE", fmt.Sprintf("/blocking/rules/%d", id), nil, nil)
|
||||||
|
case len(args) == 1:
|
||||||
|
qv := q("name", args[0], "view", view)
|
||||||
|
if missingOK {
|
||||||
|
qv.Set("missing_ok", "1")
|
||||||
|
}
|
||||||
|
resp, err = c.Mutate(ctx(), "DELETE", "/blocking/rules", qv, nil)
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("give DOMAIN, or --id")
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !resp.Applied && !resp.Staged && !resp.DryRun {
|
||||||
|
fmt.Println("nothing to do")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return report(resp)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
del.Flags().Int64Var(&id, "id", 0, "delete the rule with this ID")
|
||||||
|
del.Flags().StringVar(&view, "view", "", "only the rules that apply in this view")
|
||||||
|
del.Flags().BoolVar(&missingOK, "missing-ok", false, "succeed when there is no such rule")
|
||||||
|
|
||||||
|
check := &cobra.Command{
|
||||||
|
Use: "check DOMAIN",
|
||||||
|
Short: "Show whether a name is blocked, and by which rule or list",
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
c, err := newClient()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var matches []blocking.Match
|
||||||
|
if err := c.Get(ctx(), "/blocking/check", q("name", args[0]), &matches); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
note := pausedNote(c)
|
||||||
|
return output(matches, func(w io.Writer) {
|
||||||
|
if note != "" {
|
||||||
|
fmt.Fprint(w, note+"While it is, every name is answered. Otherwise:\n")
|
||||||
|
}
|
||||||
|
if len(matches) == 0 {
|
||||||
|
fmt.Fprintln(w, "Blocking isn't set up in any view.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, m := range matches {
|
||||||
|
where := ""
|
||||||
|
if m.View != "" {
|
||||||
|
where = "view " + m.View + ":\t"
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case m.Result == "none":
|
||||||
|
fmt.Fprintf(w, "%snot blocked\n", where)
|
||||||
|
case m.List != "" && m.Step != "":
|
||||||
|
fmt.Fprintf(w, "%sblocked for clients at step %s and above, by blocklist %s (entry %s); not for others\n", where, m.Step, m.List, m.Entry)
|
||||||
|
case m.List != "":
|
||||||
|
fmt.Fprintf(w, "%sblocked by blocklist %s (entry %s)\n", where, m.List, m.Entry)
|
||||||
|
default:
|
||||||
|
fmt.Fprintf(w, "%s%s by your rule %d (entry %s)\n", where, m.Result, m.RuleID, m.Entry)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
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, blockStepCmd(), blockGroupCmd())
|
||||||
|
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"
|
||||||
|
}
|
||||||
+67
-28
@@ -120,8 +120,12 @@ func byIDOrMatch(op string, d map[string]any) api.ChangeRequest {
|
|||||||
// changeset given with --changeset.
|
// changeset given with --changeset.
|
||||||
func submitBatch(c *client.Client, description string, reqs []api.ChangeRequest) error {
|
func submitBatch(c *client.Client, description string, reqs []api.ChangeRequest) error {
|
||||||
var resp api.MutationResponse
|
var resp api.MutationResponse
|
||||||
if c.Changeset > 0 {
|
cs, err := c.UseChangeset(ctx())
|
||||||
if err := c.Do(ctx(), "POST", fmt.Sprintf("/changesets/%d/ops", c.Changeset), nil, map[string]any{"ops": reqs}, &resp); err != nil {
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if cs > 0 {
|
||||||
|
if err := c.Do(ctx(), "POST", fmt.Sprintf("/changesets/%d/ops", cs), nil, map[string]any{"ops": reqs}, &resp); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return report(&resp)
|
return report(&resp)
|
||||||
@@ -230,8 +234,9 @@ func changesetCmd() *cobra.Command {
|
|||||||
Example: ` export JARL_CHANGESET=$(jarl changeset create "move web servers" -q)
|
Example: ` export JARL_CHANGESET=$(jarl changeset create "move web servers" -q)
|
||||||
jarl record add example.com www2 A 192.0.2.20
|
jarl record add example.com www2 A 192.0.2.20
|
||||||
jarl record delete example.com www A
|
jarl record delete example.com www A
|
||||||
jarl changeset diff $JARL_CHANGESET
|
jarl changeset diff # JARL_CHANGESET's
|
||||||
jarl changeset apply $JARL_CHANGESET`,
|
jarl changeset apply
|
||||||
|
unset JARL_CHANGESET`,
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
c, err := newClient()
|
c, err := newClient()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -245,21 +250,25 @@ func changesetCmd() *cobra.Command {
|
|||||||
fmt.Println(cs.ID)
|
fmt.Println(cs.ID)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
fmt.Printf("created changeset %d\nstage changes with --changeset %d (or export JARL_CHANGESET=%d)\n", cs.ID, cs.ID, cs.ID)
|
fmt.Printf("created changeset %d\nstage changes with --changeset %d (or export JARL_CHANGESET=%d), then 'jarl changeset apply'\n", cs.ID, cs.ID, cs.ID)
|
||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
show := &cobra.Command{
|
show := &cobra.Command{
|
||||||
Use: "show ID",
|
Use: "show [ID]",
|
||||||
Short: "Show a changeset's operations",
|
Short: "Show a changeset's operations (default: -C or JARL_CHANGESET)",
|
||||||
Args: cobra.ExactArgs(1),
|
Args: cobra.MaximumNArgs(1),
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
c, err := newClient()
|
c, err := newClient()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
id, _, err := changesetArg(c, args, false)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
var cs api.Changeset
|
var cs api.Changeset
|
||||||
if err := c.Get(ctx(), "/changesets/"+args[0], nil, &cs); err != nil {
|
if err := c.Get(ctx(), "/changesets/"+id, nil, &cs); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return output(cs, func(w io.Writer) {
|
return output(cs, func(w io.Writer) {
|
||||||
@@ -280,16 +289,20 @@ func changesetCmd() *cobra.Command {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
diff := &cobra.Command{
|
diff := &cobra.Command{
|
||||||
Use: "diff ID",
|
Use: "diff [ID]",
|
||||||
Short: "Validate an open changeset and show what applying it would change",
|
Short: "Validate an open changeset and show what applying it would change (default: -C or JARL_CHANGESET)",
|
||||||
Args: cobra.ExactArgs(1),
|
Args: cobra.MaximumNArgs(1),
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
c, err := newClient()
|
c, err := newClient()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
id, fromEnv, err := changesetArg(c, args, true)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
var raw json.RawMessage
|
var raw json.RawMessage
|
||||||
if err := c.Get(ctx(), "/changesets/"+args[0]+"/preview", nil, &raw); err != nil {
|
if err := c.Get(ctx(), "/changesets/"+id+"/preview", nil, &raw); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if flagOutput != "table" {
|
if flagOutput != "table" {
|
||||||
@@ -312,56 +325,82 @@ func changesetCmd() *cobra.Command {
|
|||||||
fmt.Println()
|
fmt.Println()
|
||||||
fmt.Print(r.Diff)
|
fmt.Print(r.Diff)
|
||||||
if r.Valid {
|
if r.Valid {
|
||||||
fmt.Println("\nvalid: apply with 'jarl changeset apply " + args[0] + "'")
|
how := "jarl changeset apply " + id
|
||||||
|
if fromEnv {
|
||||||
|
how = "jarl changeset apply"
|
||||||
|
}
|
||||||
|
fmt.Println("\nvalid: apply with '" + how + "'")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return fmt.Errorf("the changeset can't be applied as it is")
|
return fmt.Errorf("the changeset can't be applied as it is")
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
apply := &cobra.Command{
|
apply := &cobra.Command{
|
||||||
Use: "apply ID",
|
Use: "apply [ID]",
|
||||||
Short: "Apply an open changeset",
|
Short: "Apply an open changeset (default: -C or JARL_CHANGESET)",
|
||||||
Args: cobra.ExactArgs(1),
|
Args: cobra.MaximumNArgs(1),
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
c, err := newClient()
|
c, err := newClient()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
id, fromEnv, err := changesetArg(c, args, true)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
var resp api.MutationResponse
|
var resp api.MutationResponse
|
||||||
if err := c.Do(ctx(), "POST", "/changesets/"+args[0]+"/apply", nil, map[string]any{"force": c.Force, "dry_run": c.DryRun}, &resp); err != nil {
|
if err := c.Do(ctx(), "POST", "/changesets/"+id+"/apply", nil, map[string]any{"force": c.Force, "dry_run": c.DryRun}, &resp); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return report(&resp)
|
if err := report(&resp); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if fromEnv && resp.Applied {
|
||||||
|
envChangesetDone(id, "applied")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
discard := &cobra.Command{
|
discard := &cobra.Command{
|
||||||
Use: "discard ID",
|
Use: "discard [ID]",
|
||||||
Short: "Throw away an open changeset",
|
Short: "Throw away an open changeset (default: -C or JARL_CHANGESET)",
|
||||||
Args: cobra.ExactArgs(1),
|
Args: cobra.MaximumNArgs(1),
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
c, err := newClient()
|
c, err := newClient()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := c.Do(ctx(), "POST", "/changesets/"+args[0]+"/discard", nil, nil, nil); err != nil {
|
id, fromEnv, err := changesetArg(c, args, true)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := c.Do(ctx(), "POST", "/changesets/"+id+"/discard", nil, nil, nil); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if !flagQuiet {
|
if !flagQuiet {
|
||||||
fmt.Println("discarded changeset", args[0])
|
fmt.Println("discarded changeset", id)
|
||||||
|
}
|
||||||
|
if fromEnv {
|
||||||
|
envChangesetDone(id, "discarded")
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
remove := &cobra.Command{
|
remove := &cobra.Command{
|
||||||
Use: "remove ID SEQ",
|
Use: "remove [ID] SEQ",
|
||||||
Short: "Remove one operation from an open changeset",
|
Short: "Remove one operation from an open changeset (default: -C or JARL_CHANGESET)",
|
||||||
Args: cobra.ExactArgs(2),
|
Args: cobra.RangeArgs(1, 2),
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
c, err := newClient()
|
c, err := newClient()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return c.Do(ctx(), "DELETE", "/changesets/"+args[0]+"/ops/"+args[1], nil, nil, nil)
|
seq := args[len(args)-1]
|
||||||
|
id, _, err := changesetArg(c, args[:len(args)-1], true)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return c.Do(ctx(), "DELETE", "/changesets/"+id+"/ops/"+seq, nil, nil, nil)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
revert := &cobra.Command{
|
revert := &cobra.Command{
|
||||||
|
|||||||
@@ -0,0 +1,271 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
|
"jarl/internal/api"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Blocking steps and client groups (docs/blocking.md).
|
||||||
|
|
||||||
|
func blockStepCmd() *cobra.Command {
|
||||||
|
cmd := &cobra.Command{Use: "step", Aliases: []string{"steps"}, Short: "Blocking steps: which lists apply to which client groups",
|
||||||
|
Long: `Steps decide which blocklists a client group gets. Each step includes the
|
||||||
|
lists of the steps below it, plus its own:
|
||||||
|
|
||||||
|
off only your rules (built in)
|
||||||
|
normal your rules + the normal lists; clients not in a group (built in)
|
||||||
|
strict normal + the lists at step strict (a step you add)
|
||||||
|
...
|
||||||
|
|
||||||
|
Lists start at a step ('jarl blocklist update LIST --step strict'), client
|
||||||
|
groups are at one ('jarl block group add kids 10.10.0.50 --step strict').`}
|
||||||
|
list := &cobra.Command{
|
||||||
|
Use: "list",
|
||||||
|
Short: "List the steps, lowest first",
|
||||||
|
Args: cobra.NoArgs,
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
c, err := newClient()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var steps []api.BlockStep
|
||||||
|
if err := c.Get(ctx(), "/blocking/steps", nil, &steps); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return output(steps, func(w io.Writer) {
|
||||||
|
fmt.Fprintln(w, "STEP\tLISTS\tGROUPS\tCOMMENT")
|
||||||
|
fmt.Fprintln(w, "off\t-\t(built in)\tonly your rules")
|
||||||
|
fmt.Fprintln(w, "normal\t-\t(built in)\tthe default")
|
||||||
|
for _, s := range steps {
|
||||||
|
fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", s.Name, orDash(strings.Join(s.Lists, ", ")), orDash(strings.Join(s.Groups, ", ")), s.Comment)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
}
|
||||||
|
var comment string
|
||||||
|
add := &cobra.Command{
|
||||||
|
Use: "add NAME",
|
||||||
|
Short: "Add a step above the existing ones",
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
c, err := newClient()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
resp, err := c.Mutate(ctx(), "POST", "/blocking/steps", nil, map[string]any{"name": args[0], "comment": comment})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return report(resp)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
add.Flags().StringVar(&comment, "comment", "", "comment")
|
||||||
|
rename := &cobra.Command{
|
||||||
|
Use: "rename STEP NEW-NAME",
|
||||||
|
Short: "Rename a step",
|
||||||
|
Args: cobra.ExactArgs(2),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
c, err := newClient()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
resp, err := c.Mutate(ctx(), "PATCH", "/blocking/steps/"+url.PathEscape(args[0]), nil, map[string]any{"name": args[1]})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return report(resp)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
order := &cobra.Command{
|
||||||
|
Use: "order STEP...",
|
||||||
|
Short: "Set the order of the steps above normal, lowest first",
|
||||||
|
Example: " jarl block step order strict very-strict",
|
||||||
|
Args: cobra.MinimumNArgs(1),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
c, err := newClient()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
resp, err := c.Mutate(ctx(), "POST", "/blocking/steps/order", nil, map[string]any{"steps": args})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return report(resp)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
del := &cobra.Command{
|
||||||
|
Use: "delete STEP",
|
||||||
|
Short: "Delete a step (refused while lists or groups use it)",
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
c, err := newClient()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
resp, err := c.Mutate(ctx(), "DELETE", "/blocking/steps/"+url.PathEscape(args[0]), nil, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return report(resp)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
cmd.AddCommand(list, add, rename, order, del)
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func blockGroupCmd() *cobra.Command {
|
||||||
|
cmd := &cobra.Command{Use: "group", Aliases: []string{"groups"}, Short: "Client groups: blocking steps for some devices",
|
||||||
|
Long: `A client group puts devices at a blocking step: off (only your rules),
|
||||||
|
normal, or a step you added ('jarl block step'). Members are addresses,
|
||||||
|
prefixes, or ACLs made of them. Devices not in a group are at normal.
|
||||||
|
named must see the devices' own addresses (not a router forwarding for
|
||||||
|
them), and they need fixed addresses (DHCP reservations).`}
|
||||||
|
list := &cobra.Command{
|
||||||
|
Use: "list",
|
||||||
|
Short: "List client groups",
|
||||||
|
Args: cobra.NoArgs,
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
c, err := newClient()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var groups []api.ClientGroup
|
||||||
|
if err := c.Get(ctx(), "/blocking/groups", nil, &groups); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return output(groups, func(w io.Writer) {
|
||||||
|
fmt.Fprintln(w, "GROUP\tSTEP\tMEMBERS\tVIEWS\tCOMMENT")
|
||||||
|
for _, g := range groups {
|
||||||
|
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", g.Name, g.Step, orDash(strings.Join(g.Members, ", ")), viewsText(g.Views, g.AutoViews), g.Comment)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
}
|
||||||
|
var step, comment string
|
||||||
|
var views []string
|
||||||
|
add := &cobra.Command{
|
||||||
|
Use: "add NAME MEMBER...",
|
||||||
|
Short: "Add a client group",
|
||||||
|
Example: ` jarl block group add work 10.10.0.20 --step off
|
||||||
|
jarl block group add kids 10.10.0.50 10.10.0.51 2001:db8:1::/64 --step strict
|
||||||
|
jarl block group add guests guest-net --step normal # an ACL`,
|
||||||
|
Args: cobra.MinimumNArgs(1),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
c, err := newClient()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
body := map[string]any{"name": args[0], "members": args[1:], "comment": comment}
|
||||||
|
if step != "" {
|
||||||
|
body["step"] = step
|
||||||
|
}
|
||||||
|
viewsBody(body, views)
|
||||||
|
resp, err := c.Mutate(ctx(), "POST", "/blocking/groups", nil, body)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return report(resp)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
add.Flags().StringVar(&step, "step", "", "the group's step: off, normal (default) or one you added")
|
||||||
|
add.Flags().StringArrayVar(&views, "view", nil, "a view it applies in (repeat for several; default: every view with blocking)")
|
||||||
|
add.Flags().StringVar(&comment, "comment", "", "comment")
|
||||||
|
var addM, removeM []string
|
||||||
|
var rename string
|
||||||
|
update := &cobra.Command{
|
||||||
|
Use: "update GROUP",
|
||||||
|
Short: "Change a group's members, step, name, views or comment",
|
||||||
|
Example: ` jarl block group update kids --add 10.10.0.52 --remove 10.10.0.50
|
||||||
|
jarl block group update kids --step very-strict`,
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
c, err := newClient()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
body := map[string]any{}
|
||||||
|
f := cmd.Flags()
|
||||||
|
if len(addM) > 0 || len(removeM) > 0 {
|
||||||
|
var g api.ClientGroup
|
||||||
|
var all []api.ClientGroup
|
||||||
|
if err := c.Get(ctx(), "/blocking/groups", nil, &all); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, x := range all {
|
||||||
|
if x.Name == strings.ToLower(args[0]) {
|
||||||
|
g = x
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if g.ID == 0 {
|
||||||
|
return fmt.Errorf("client group %s not found", args[0])
|
||||||
|
}
|
||||||
|
drop := map[string]bool{}
|
||||||
|
for _, m := range removeM {
|
||||||
|
drop[m] = true
|
||||||
|
}
|
||||||
|
members := []string{}
|
||||||
|
for _, m := range g.Members {
|
||||||
|
if !drop[m] {
|
||||||
|
members = append(members, m)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
body["members"] = append(members, addM...)
|
||||||
|
}
|
||||||
|
if f.Changed("step") {
|
||||||
|
body["step"] = step
|
||||||
|
}
|
||||||
|
if f.Changed("name") {
|
||||||
|
body["name"] = rename
|
||||||
|
}
|
||||||
|
if f.Changed("comment") {
|
||||||
|
body["comment"] = comment
|
||||||
|
}
|
||||||
|
viewsBody(body, views)
|
||||||
|
if len(body) == 0 {
|
||||||
|
return fmt.Errorf("nothing to change (see --help)")
|
||||||
|
}
|
||||||
|
resp, err := c.Mutate(ctx(), "PATCH", "/blocking/groups/"+url.PathEscape(args[0]), nil, body)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return report(resp)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
update.Flags().StringArrayVar(&addM, "add", nil, "add a member (repeat for several)")
|
||||||
|
update.Flags().StringArrayVar(&removeM, "remove", nil, "remove a member (repeat for several)")
|
||||||
|
update.Flags().StringVar(&step, "step", "", "new step")
|
||||||
|
update.Flags().StringVar(&rename, "name", "", "new name")
|
||||||
|
update.Flags().StringArrayVar(&views, "view", nil, `a view it applies in (repeat; "auto": every view with blocking)`)
|
||||||
|
update.Flags().StringVar(&comment, "comment", "", "new comment")
|
||||||
|
del := &cobra.Command{
|
||||||
|
Use: "delete GROUP",
|
||||||
|
Short: "Delete a client group (its devices go back to normal)",
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
c, err := newClient()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
resp, err := c.Mutate(ctx(), "DELETE", "/blocking/groups/"+url.PathEscape(args[0]), nil, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return report(resp)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
cmd.AddCommand(list, add, update, del)
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func orDash(s string) string {
|
||||||
|
if s == "" {
|
||||||
|
return "-"
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
@@ -76,6 +76,8 @@ func argCompletion(c *cobra.Command) cobra.CompletionFunc {
|
|||||||
return append([]string{"default"}, model.ViewColors...), cobra.ShellCompDirectiveNoFileComp
|
return append([]string{"default"}, model.ViewColors...), cobra.ShellCompDirectiveNoFileComp
|
||||||
case p == "ACL":
|
case p == "ACL":
|
||||||
return lookup(cmd, "acl")
|
return lookup(cmd, "acl")
|
||||||
|
case p == "LIST":
|
||||||
|
return lookup(cmd, "blocklist")
|
||||||
case p == "USER":
|
case p == "USER":
|
||||||
return lookup(cmd, "user")
|
return lookup(cmd, "user")
|
||||||
case p == "KEY" && parent == "tsig":
|
case p == "KEY" && parent == "tsig":
|
||||||
@@ -199,6 +201,14 @@ func lookup(cmd *cobra.Command, kind string) ([]cobra.Completion, cobra.ShellCom
|
|||||||
for _, v := range views {
|
for _, v := range views {
|
||||||
addName(v.Name, v.Comment)
|
addName(v.Name, v.Comment)
|
||||||
}
|
}
|
||||||
|
case "blocklist":
|
||||||
|
var lists []api.Blocklist
|
||||||
|
if c.Get(ctx(), "/blocking/lists", nil, &lists) != nil {
|
||||||
|
return nil, none
|
||||||
|
}
|
||||||
|
for _, b := range lists {
|
||||||
|
addName(b.Name, b.URL)
|
||||||
|
}
|
||||||
case "acl":
|
case "acl":
|
||||||
var acls []api.ACL
|
var acls []api.ACL
|
||||||
if c.Get(ctx(), "/acls", nil, &acls) != nil {
|
if c.Get(ctx(), "/acls", nil, &acls) != nil {
|
||||||
|
|||||||
+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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"jarl/internal/api"
|
||||||
|
"jarl/internal/client"
|
||||||
|
"jarl/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
// A changeset given with --changeset is used as given (jarld still refuses
|
||||||
|
// what isn't allowed). One taken from JARL_CHANGESET is checked first: an
|
||||||
|
// exported ID outlives the changeset (it gets applied), and changeset IDs
|
||||||
|
// keep growing, so a stale or mistyped one can name someone else's
|
||||||
|
// changeset later. It is used only when it is open and the caller's own,
|
||||||
|
// and the CLI says on stderr which changeset that is.
|
||||||
|
|
||||||
|
// envChangeset returns the changeset in JARL_CHANGESET (0 if unset).
|
||||||
|
func envChangeset() (int64, error) {
|
||||||
|
v := os.Getenv("JARL_CHANGESET")
|
||||||
|
if v == "" {
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
n, err := strconv.ParseInt(v, 10, 64)
|
||||||
|
if err != nil || n <= 0 {
|
||||||
|
return 0, fmt.Errorf("invalid JARL_CHANGESET %q", v)
|
||||||
|
}
|
||||||
|
return n, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkEnvChangeset checks the changeset id from JARL_CHANGESET before it
|
||||||
|
// is used. With mustOwn, it must also be open and the caller's own (for
|
||||||
|
// staging into it, applying or discarding it).
|
||||||
|
func checkEnvChangeset(c *client.Client, id int64, mustOwn bool) (*api.Changeset, error) {
|
||||||
|
var cs api.Changeset
|
||||||
|
if err := c.Get(ctx(), fmt.Sprintf("/changesets/%d", id), nil, &cs); err != nil {
|
||||||
|
var ce *client.Error
|
||||||
|
if errors.As(err, &ce) && ce.Status == 404 {
|
||||||
|
return nil, fmt.Errorf("JARL_CHANGESET is %d, but there is no changeset %d: start one with 'export JARL_CHANGESET=$(jarl changeset create -q)', or 'unset JARL_CHANGESET' to make changes live", id, id)
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if mustOwn {
|
||||||
|
if cs.Status != model.ChangesetOpen {
|
||||||
|
return nil, fmt.Errorf("JARL_CHANGESET is %d, but changeset %d is %s: 'unset JARL_CHANGESET' to make changes live, or start a new one with 'export JARL_CHANGESET=$(jarl changeset create -q)'", id, id, cs.Status)
|
||||||
|
}
|
||||||
|
var me api.Whoami
|
||||||
|
if err := c.Get(ctx(), "/auth/whoami", nil, &me); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
mine := cs.UserID == me.User.ID && (me.User.ID != 0 || cs.Username == me.User.Username)
|
||||||
|
if !mine {
|
||||||
|
return nil, fmt.Errorf("JARL_CHANGESET is %d, but changeset %d is %s's (made via %s), not yours, so it isn't used from JARL_CHANGESET; give it as -C %d to use it anyway, or 'unset JARL_CHANGESET'", id, id, cs.Username, cs.Via, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !flagQuiet {
|
||||||
|
desc := cs.Description
|
||||||
|
if desc == "" {
|
||||||
|
desc = "no description"
|
||||||
|
}
|
||||||
|
fmt.Fprintf(os.Stderr, "changeset %d (%s, %d operations) from JARL_CHANGESET\n", id, desc, len(cs.Ops))
|
||||||
|
}
|
||||||
|
return &cs, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// changesetArg returns the changeset a changeset subcommand works on: the
|
||||||
|
// ID argument, else --changeset, else JARL_CHANGESET (checked; mustOwn as
|
||||||
|
// for checkEnvChangeset). fromEnv says it came from JARL_CHANGESET.
|
||||||
|
func changesetArg(c *client.Client, args []string, mustOwn bool) (id string, fromEnv bool, err error) {
|
||||||
|
if len(args) > 0 {
|
||||||
|
return args[0], false, nil
|
||||||
|
}
|
||||||
|
if flagChangeset > 0 {
|
||||||
|
return strconv.FormatInt(flagChangeset, 10), false, nil
|
||||||
|
}
|
||||||
|
n, err := envChangeset()
|
||||||
|
if err != nil {
|
||||||
|
return "", false, err
|
||||||
|
}
|
||||||
|
if n == 0 {
|
||||||
|
return "", false, errors.New("which changeset? Give its ID, or set JARL_CHANGESET (export JARL_CHANGESET=$(jarl changeset create -q))")
|
||||||
|
}
|
||||||
|
if _, err := checkEnvChangeset(c, n, mustOwn); err != nil {
|
||||||
|
return "", false, err
|
||||||
|
}
|
||||||
|
return strconv.FormatInt(n, 10), true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// envChangesetDone reminds that JARL_CHANGESET still names a changeset
|
||||||
|
// that was just applied or discarded: later changes would fail to stage.
|
||||||
|
func envChangesetDone(id, what string) {
|
||||||
|
if !flagQuiet {
|
||||||
|
fmt.Fprintf(os.Stderr, "JARL_CHANGESET still names changeset %s, which is now %s: run 'unset JARL_CHANGESET' (or export a new one) before making more changes\n", id, what)
|
||||||
|
}
|
||||||
|
}
|
||||||
+22
-5
@@ -13,7 +13,6 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"slices"
|
"slices"
|
||||||
"strconv"
|
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"text/tabwriter"
|
"text/tabwriter"
|
||||||
@@ -149,12 +148,18 @@ func newClient() (*client.Client, error) {
|
|||||||
}
|
}
|
||||||
c.Changeset = flagChangeset
|
c.Changeset = flagChangeset
|
||||||
if c.Changeset == 0 {
|
if c.Changeset == 0 {
|
||||||
if v := os.Getenv("JARL_CHANGESET"); v != "" {
|
n, err := envChangeset()
|
||||||
n, err := strconv.ParseInt(v, 10, 64)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("invalid JARL_CHANGESET %q", v)
|
return nil, err
|
||||||
}
|
}
|
||||||
|
if n > 0 {
|
||||||
|
// Checked when a change is first staged into it
|
||||||
|
// (envchangeset.go).
|
||||||
c.Changeset = n
|
c.Changeset = n
|
||||||
|
c.CheckChangeset = func(context.Context) error {
|
||||||
|
_, err := checkEnvChangeset(c, n, true)
|
||||||
|
return err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
c.DryRun, c.Force = flagDryRun || c.Preflight, flagForce
|
c.DryRun, c.Force = flagDryRun || c.Preflight, flagForce
|
||||||
@@ -250,7 +255,7 @@ Exit codes: 0 ok, 1 error, 3 not authenticated or not allowed, 4 not found,
|
|||||||
root.AddCommand(c)
|
root.AddCommand(c)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
add("dns", zoneCmd(), recordCmd(), viewCmd(), aclCmd(), tsigCmd(), optionCmd(), settingsCmd(), acmeCmd(), searchCmd(), statsCmd())
|
add("dns", zoneCmd(), recordCmd(), viewCmd(), aclCmd(), tsigCmd(), optionCmd(), settingsCmd(), blockCmd(), blocklistCmd(), acmeCmd(), searchCmd(), statsCmd())
|
||||||
add("changes", applyCmd(), changesetCmd(), historyCmd())
|
add("changes", applyCmd(), changesetCmd(), historyCmd())
|
||||||
add("admin", statusCmd(), whoamiCmd(), userCmd(), tokenCmd(), auditCmd(), checkCmd(), resyncCmd(), configCmd())
|
add("admin", statusCmd(), whoamiCmd(), userCmd(), tokenCmd(), auditCmd(), checkCmd(), resyncCmd(), configCmd())
|
||||||
setupCompletion(root)
|
setupCompletion(root)
|
||||||
@@ -530,6 +535,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)
|
||||||
|
}
|
||||||
+92
-7
@@ -92,6 +92,9 @@ export the data.`,
|
|||||||
if s.NXDomainRatio != nil {
|
if s.NXDomainRatio != nil {
|
||||||
fmt.Fprintf(w, "NXDOMAIN:\t%.1f%% of queries\n", *s.NXDomainRatio*100)
|
fmt.Fprintf(w, "NXDOMAIN:\t%.1f%% of queries\n", *s.NXDomainRatio*100)
|
||||||
}
|
}
|
||||||
|
if s.BlockedRatio != nil {
|
||||||
|
fmt.Fprintf(w, "Blocked:\t%d (%.1f%% of queries): %s\n", s.Blocked, *s.BlockedRatio*100, entryLine(s.Blocklists, s.Blocked, 6))
|
||||||
|
}
|
||||||
fmt.Fprintf(w, "Result codes:\t%s\n", entryLine(s.RCodes, s.Queries, 6))
|
fmt.Fprintf(w, "Result codes:\t%s\n", entryLine(s.RCodes, s.Queries, 6))
|
||||||
fmt.Fprintf(w, "Record types:\t%s\n", entryLine(s.QTypes, s.Queries, 8))
|
fmt.Fprintf(w, "Record types:\t%s\n", entryLine(s.QTypes, s.Queries, 8))
|
||||||
if len(s.Answers) > 0 {
|
if len(s.Answers) > 0 {
|
||||||
@@ -103,12 +106,12 @@ export the data.`,
|
|||||||
if len(s.Zones) > 0 {
|
if len(s.Zones) > 0 {
|
||||||
fmt.Fprintf(w, "Zones:\t%s\n", strings.ReplaceAll(entryLine(s.Zones, 0, 10), stats.OtherZone+" ", "other "))
|
fmt.Fprintf(w, "Zones:\t%s\n", strings.ReplaceAll(entryLine(s.Zones, 0, 10), stats.OtherZone+" ", "other "))
|
||||||
}
|
}
|
||||||
for _, k := range []string{stats.KindName, stats.KindClient, stats.KindNXDomain} {
|
for _, k := range []string{stats.KindName, stats.KindClient, stats.KindNXDomain, stats.KindBlocked} {
|
||||||
es := s.Tops[k]
|
es := s.Tops[k]
|
||||||
if len(es) == 0 {
|
if len(es) == 0 {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
fmt.Fprintf(w, "\n%s\t\n", map[string]string{stats.KindName: "Top names", stats.KindClient: "Top clients", stats.KindNXDomain: "Top NXDOMAIN names"}[k])
|
fmt.Fprintf(w, "\n%s\t\n", map[string]string{stats.KindName: "Top names", stats.KindClient: "Top clients", stats.KindNXDomain: "Top NXDOMAIN names", stats.KindBlocked: "Top blocked names"}[k])
|
||||||
for _, e := range es {
|
for _, e := range es {
|
||||||
key := e.Key
|
key := e.Key
|
||||||
if e.Name != "" {
|
if e.Name != "" {
|
||||||
@@ -125,12 +128,14 @@ export the data.`,
|
|||||||
|
|
||||||
var limit int
|
var limit int
|
||||||
top := &cobra.Command{
|
top := &cobra.Command{
|
||||||
Use: "top [names|clients|nxdomain|servfail|refused]",
|
Use: "top [names|clients|nxdomain|servfail|refused|blocked|blocked-clients]",
|
||||||
Short: "The most queried names, busiest clients, names with errors, refused clients",
|
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-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
|
||||||
@@ -139,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 {
|
||||||
@@ -153,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"
|
||||||
}
|
}
|
||||||
@@ -317,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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -390,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
|
||||||
|
|||||||
+41
-2
@@ -123,6 +123,10 @@ func openApp(allowMissingConfig bool) (*app, error) {
|
|||||||
NoReload: cfg.BIND.NoReload,
|
NoReload: cfg.BIND.NoReload,
|
||||||
Extras: renderExtras(cfg),
|
Extras: renderExtras(cfg),
|
||||||
}, log)
|
}, log)
|
||||||
|
eng.Limits = engine.BlocklistLimits{
|
||||||
|
Refresh: time.Duration(cfg.Blocking.Refresh), RetryAfter: time.Hour, Timeout: 2 * time.Minute,
|
||||||
|
MaxBytes: int64(cfg.Blocking.MaxDownloadMB) << 20, MaxEntries: cfg.Blocking.MaxEntries,
|
||||||
|
}
|
||||||
return &app{cfg: cfg, log: log, store: st, eng: eng, owner: owner}, nil
|
return &app{cfg: cfg, log: log, store: st, eng: eng, owner: owner}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -144,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())
|
||||||
@@ -163,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)
|
||||||
@@ -299,6 +303,11 @@ func serveCmd() *cobra.Command {
|
|||||||
a.log.Warn("the configuration file holds the Porkbun API key and everyone can read it; make it readable only by root and jarld (chown root:jarl, chmod 640)", "file", configPath)
|
a.log.Warn("the configuration file holds the Porkbun API key and everyone can read it; make it readable only by root and jarld (chown root:jarl, chmod 640)", "file", configPath)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if a.cfg.Metrics.Enabled && a.cfg.Metrics.Token != "" {
|
||||||
|
if fi, err := os.Stat(configPath); err == nil && fi.Mode().Perm()&0o004 != 0 {
|
||||||
|
a.log.Warn("the configuration file holds metrics.token and everyone can read it; make it readable only by root and jarld (chown root:jarl, chmod 640)", "file", configPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
srv := server.New(server.Config{
|
srv := server.New(server.Config{
|
||||||
Listen: a.cfg.Listen, TLSCertFile: a.cfg.TLS.CertFile, TLSKeyFile: a.cfg.TLS.KeyFile,
|
Listen: a.cfg.Listen, TLSCertFile: a.cfg.TLS.CertFile, TLSKeyFile: a.cfg.TLS.KeyFile,
|
||||||
TrustedProxies: a.cfg.Proxies(), SessionLifetime: time.Duration(a.cfg.Session.Lifetime),
|
TrustedProxies: a.cfg.Proxies(), SessionLifetime: time.Duration(a.cfg.Session.Lifetime),
|
||||||
@@ -307,6 +316,8 @@ func serveCmd() *cobra.Command {
|
|||||||
BackupDir: a.cfg.Backup.Dir, BackupWarnAfter: time.Duration(a.cfg.Backup.WarnAfter),
|
BackupDir: a.cfg.Backup.Dir, BackupWarnAfter: time.Duration(a.cfg.Backup.WarnAfter),
|
||||||
Porkbun: pb, PorkbunAllows: a.cfg.Porkbun.Allows,
|
Porkbun: pb, PorkbunAllows: a.cfg.Porkbun.Allows,
|
||||||
TOTPRequire: a.cfg.TOTP.Require, TOTPIssuer: a.cfg.TOTP.IssuerName(),
|
TOTPRequire: a.cfg.TOTP.Require, TOTPIssuer: a.cfg.TOTP.IssuerName(),
|
||||||
|
Metrics: server.Metrics{Enabled: a.cfg.Metrics.Enabled, Listen: a.cfg.Metrics.Listen,
|
||||||
|
Allow: a.cfg.Metrics.AllowPrefixes(), Token: a.cfg.Metrics.Token},
|
||||||
HTTPReq: server.HTTPReq{
|
HTTPReq: server.HTTPReq{
|
||||||
Enabled: a.cfg.ACME.HTTPReq.Enabled,
|
Enabled: a.cfg.ACME.HTTPReq.Enabled,
|
||||||
AllowPlainHTTP: !a.cfg.ACME.HTTPReq.RequireHTTPS,
|
AllowPlainHTTP: !a.cfg.ACME.HTTPReq.RequireHTTPS,
|
||||||
@@ -323,6 +334,7 @@ func serveCmd() *cobra.Command {
|
|||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
go auditRetention(ctx, a)
|
go auditRetention(ctx, a)
|
||||||
|
go blocklistLoop(ctx, a)
|
||||||
a.log.Info("jarld starting", "version", version.String(), "database", a.cfg.Database, "bind_config", a.cfg.BIND.ConfigDir)
|
a.log.Info("jarld starting", "version", version.String(), "database", a.cfg.Database, "bind_config", a.cfg.BIND.ConfigDir)
|
||||||
err = srv.Serve(ctx, func() { sdNotify("READY=1\nSTATUS=serving") })
|
err = srv.Serve(ctx, func() { sdNotify("READY=1\nSTATUS=serving") })
|
||||||
sdNotify("STOPPING=1")
|
sdNotify("STOPPING=1")
|
||||||
@@ -332,6 +344,33 @@ func serveCmd() *cobra.Command {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// blocklistLoop keeps blocklists up to date (docs/blocking.md): every few
|
||||||
|
// minutes it downloads the lists that are due, and at once when a list
|
||||||
|
// was added or changed. It also ends a pause of blocking on time.
|
||||||
|
func blocklistLoop(ctx context.Context, a *app) {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-time.After(10 * time.Second): // let jarld finish starting
|
||||||
|
}
|
||||||
|
for {
|
||||||
|
a.eng.RefreshDue(ctx)
|
||||||
|
wait := 5 * time.Minute
|
||||||
|
if until := a.eng.ResumeBlocking(ctx); !until.IsZero() {
|
||||||
|
wait = min(wait, max(time.Until(until), time.Second))
|
||||||
|
}
|
||||||
|
t := time.NewTimer(wait)
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
t.Stop()
|
||||||
|
return
|
||||||
|
case <-t.C:
|
||||||
|
case <-a.eng.BlocklistKicks():
|
||||||
|
t.Stop()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func auditRetention(ctx context.Context, a *app) {
|
func auditRetention(ctx context.Context, a *app) {
|
||||||
if a.cfg.Audit.RetentionDays <= 0 {
|
if a.cfg.Audit.RetentionDays <= 0 {
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
+53
-7
@@ -10,6 +10,8 @@ what is documented here.
|
|||||||
(`www.adbraun.lol.`), and display helpers drop it (`display_name`,
|
(`www.adbraun.lol.`), and display helpers drop it (`display_name`,
|
||||||
`relative_name`).
|
`relative_name`).
|
||||||
* `GET /healthz` (no authentication) returns `{"ok": true, "version": ...}`.
|
* `GET /healthz` (no authentication) returns `{"ok": true, "version": ...}`.
|
||||||
|
* `GET /metrics` serves Prometheus metrics when `metrics.enabled` is set.
|
||||||
|
Access is set in jarl.yaml, not with API tokens ([metrics.md](metrics.md)).
|
||||||
|
|
||||||
## Authentication
|
## Authentication
|
||||||
|
|
||||||
@@ -147,6 +149,14 @@ honours `If-Match: <version>`, and batch operations take `base_version`. If
|
|||||||
the object changed in the meantime the request fails with 409. Staged
|
the object changed in the meantime the request fails with 409. Staged
|
||||||
operations record the version they were staged against automatically.
|
operations record the version they were staged against automatically.
|
||||||
|
|
||||||
|
**Stale changesets.** When an object a staged operation needs was changed
|
||||||
|
or deleted after it was staged (from the CLI, say), preview and apply fail
|
||||||
|
on that operation: the error has `op` (its number in the changeset),
|
||||||
|
`op_summary` and a message saying what happened; remove the operation
|
||||||
|
(`DELETE /changesets/{id}/ops/{seq}`) or stage it again. A staged delete of
|
||||||
|
a record that is already gone is not an error: it counts as done, with a
|
||||||
|
warning.
|
||||||
|
|
||||||
## Resources
|
## Resources
|
||||||
|
|
||||||
`{zone}` is a zone ID or name (`root` for the root zone). Add `?view=NAME`
|
`{zone}` is a zone ID or name (`root` for the root zone). Add `?view=NAME`
|
||||||
@@ -160,7 +170,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 +396,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.
|
||||||
@@ -404,13 +415,41 @@ send the request with that changeset (`?changeset=` or
|
|||||||
| Method | Path | Body |
|
| Method | Path | Body |
|
||||||
|--------|------|------|
|
|--------|------|------|
|
||||||
| GET | `/acls` | each with `used_by` |
|
| GET | `/acls` | each with `used_by` |
|
||||||
| POST | `/acls` | `{"name": "homelab", "elements": ["192.168.1.0/24", "!192.168.1.66", "key xfer", "localhost"], "comment": ""}` |
|
| POST | `/acls` | `{"name": "homelab", "elements": ["192.168.1.0/24", "!192.168.1.66", "10.0.10.101-10.0.10.199", "key xfer", "localhost"], "comment": ""}` (ranges: [concepts.md](concepts.md#model-views-zones-records-options)) |
|
||||||
| GET / PATCH / DELETE | `/acls/{id}` | renaming updates references; deleting one in use → 409 |
|
| GET / PATCH / DELETE | `/acls/{id}` | renaming updates references; deleting one in use → 409 |
|
||||||
| GET | `/tsig-keys` | without secrets |
|
| GET | `/tsig-keys` | without secrets |
|
||||||
| POST | `/tsig-keys` | `{"name": "xfer", "algorithm": "hmac-sha256", "secret": "<base64>"}` (secret generated when absent; the response `object` shows it once) |
|
| POST | `/tsig-keys` | `{"name": "xfer", "algorithm": "hmac-sha256", "secret": "<base64>"}` (secret generated when absent; the response `object` shows it once) |
|
||||||
| GET | `/tsig-keys/{id}` | `?secret=1` includes the secret (needs `tsig:write`, audited) |
|
| GET | `/tsig-keys/{id}` | `?secret=1` includes the secret (needs `tsig:write`, audited) |
|
||||||
| PATCH / DELETE | `/tsig-keys/{id}` | PATCH `{"secret": "..."}` rotates |
|
| PATCH / DELETE | `/tsig-keys/{id}` | PATCH `{"secret": "..."}` rotates |
|
||||||
|
|
||||||
|
### Blocking
|
||||||
|
|
||||||
|
Needs `blocking:read` (`blocking:write` to change). See
|
||||||
|
[blocking.md](blocking.md).
|
||||||
|
|
||||||
|
| Method | Path | Body / query |
|
||||||
|
|--------|------|--------------|
|
||||||
|
| GET | `/blocking/lists` | each with `views` (where it applies), `auto_views` (not chosen: every view that offers recursion), `state` (ok, waiting, error, disabled, idle), `status` (`entries`, `records`, `skipped`, `checked_at`, `updated_at`, `error`) and `memory_mb` (named's memory for it, estimated) |
|
||||||
|
| POST | `/blocking/lists` | `{"name": "hagezi-multi", "url": "https://...", "views": ["internal"], "comment": "", "step": "normal"}` (`views` empty or absent: automatic; `step` the blocking step it starts at); jarld downloads it right after |
|
||||||
|
| GET / PATCH / DELETE | `/blocking/lists/{id or name}` | PATCH any of `name`, `url`, `views`, `disabled`, `comment` |
|
||||||
|
| POST | `/blocking/lists/{id or name}/refresh` | download now and wait; `?force=1` even when the server says nothing changed. Returns the list; a failed update is in `state` and `status.error` |
|
||||||
|
| GET | `/blocking/rules` | each with `views` and `auto_views` |
|
||||||
|
| POST | `/blocking/rules` | `{"name": "doubleclick.net", "action": "block", "exact": false, "views": [], "comment": ""}` (`action` block or allow; `exact`: only the name, not the names below it) |
|
||||||
|
| PATCH / DELETE | `/blocking/rules/{id}` | |
|
||||||
|
| DELETE | `/blocking/rules` | `?name=DOMAIN[&view=V][&missing_ok=1]`: the rules for a domain |
|
||||||
|
| GET | `/blocking/check` | `?name=DOMAIN`: per view with blocking, `{view, result: blocked\|allowed\|none, entry, rule_id, list, step}` (what applies to normal devices when blocking isn't paused; `step` when only devices at that step or above are blocked) |
|
||||||
|
| GET / POST | `/blocking/steps` | the steps above normal, lowest first, with the `lists` and `groups` at each; POST `{"name": "strict", "comment": ""}` adds one on top |
|
||||||
|
| PATCH / DELETE | `/blocking/steps/{id or name}` | `{"name", "position", "comment"}`; delete is refused while lists or groups use it |
|
||||||
|
| POST | `/blocking/steps/order` | `{"steps": ["strict", "very-strict"]}`: every step above normal, lowest first |
|
||||||
|
| GET / POST | `/blocking/groups` | client groups; POST `{"name": "kids", "members": ["10.10.0.50", "tablets"], "step": "strict", "views": []}` |
|
||||||
|
| PATCH / DELETE | `/blocking/groups/{id or name}` | the same fields |
|
||||||
|
| 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
|
||||||
|
listing with `?changeset=` marks them `pending`.
|
||||||
|
|
||||||
### Options and settings
|
### Options and settings
|
||||||
|
|
||||||
| Method | Path | Body / query |
|
| Method | Path | Body / query |
|
||||||
@@ -421,7 +460,9 @@ send the request with that changeset (`?changeset=` or
|
|||||||
| 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
|
||||||
|
|
||||||
@@ -430,6 +471,7 @@ checked against the BIND 9.20 grammar for that scope.
|
|||||||
| GET | `/catalog/options` | scopes and zone types; with `?scope=options\|view\|toplevel\|zone:primary\|...` every statement allowed there (name, grammar, kind, status, ARM description). `?all=1` includes statements Jarl manages itself |
|
| GET | `/catalog/options` | scopes and zone types; with `?scope=options\|view\|toplevel\|zone:primary\|...` every statement allowed there (name, grammar, kind, status, ARM description). `?all=1` includes statements Jarl manages itself |
|
||||||
| GET | `/catalog/rrtypes` | record types with field hints and examples |
|
| GET | `/catalog/rrtypes` | record types with field hints and examples |
|
||||||
| GET | `/catalog/grants` | resources, roles and the grant syntax |
|
| GET | `/catalog/grants` | resources, roles and the grant syntax |
|
||||||
|
| GET | `/catalog/ranges?r=A-B,C-D` | for each address range: `range` (canonical), `size` ("99 addresses"), and the `prefixes` it is written to named.conf as; or `error`. Text that isn't a range is left out |
|
||||||
| GET | `/search?q=text` | zones, records and ACLs matching the text |
|
| GET | `/search?q=text` | zones, records and ACLs matching the text |
|
||||||
|
|
||||||
### Changesets
|
### Changesets
|
||||||
@@ -462,6 +504,9 @@ An **Operation** is `{"op": "...", "id": 0, "base_version": 0, "data": {...}}`:
|
|||||||
| `tsig.create` / `tsig.update` / `tsig.delete` | key | key fields |
|
| `tsig.create` / `tsig.update` / `tsig.delete` | key | key fields |
|
||||||
| `option.create` / `option.update` / `option.delete` | option | option fields |
|
| `option.create` / `option.update` / `option.delete` | option | option fields |
|
||||||
| `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` |
|
||||||
|
| `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
|
||||||
@@ -471,7 +516,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`) |
|
| 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` |
|
||||||
@@ -484,11 +529,12 @@ Needs `stats:read`. When statistics are disabled, every endpoint but
|
|||||||
| Method | Path | Query | Returns |
|
| Method | Path | Query | Returns |
|
||||||
|--------|------|-------|---------|
|
|--------|------|-------|---------|
|
||||||
| 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`, `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), `since`, `limit` (default 20) | `[{key, count}]`; client and refused entries add `name`, the host name of Jarl's record for the address (the summary's client list too) |
|
| GET | `/stats/top` | `kind` (name, client, nxdomain, servfail, refused, blocked, blocked_client), `since`, `limit` (default 20) | `[{key, count}]`; client, refused and blocked_client entries add `name`, the host name of Jarl's record for the address (the summary's client list too) |
|
||||||
| GET | `/stats/heatmap` | `since` (default 28d), `key` (default queries) | `{key, from, avg[7][24], samples[7][24]}`, Monday first, local time, `-1` = no data |
|
| GET | `/stats/heatmap` | `since` (default 28d), `key` (default queries) | `{key, from, avg[7][24], samples[7][24]}`, Monday first, local time, `-1` = no data |
|
||||||
| GET | `/stats/breakdown` | `group` (qtype, rcode, view, zone, answer, transport, rtt, dnssec, cache, ratelimit), `since` | `[{key, count}]` |
|
| 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}]` |
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
curl -sf -H "$AUTH" "$API/stats/top?kind=nxdomain&since=7d&limit=10" | jq -r '.[] | "\(.count)\t\(.key)"'
|
curl -sf -H "$AUTH" "$API/stats/top?kind=nxdomain&since=7d&limit=10" | jq -r '.[] | "\(.count)\t\(.key)"'
|
||||||
|
|||||||
@@ -0,0 +1,331 @@
|
|||||||
|
# Blocking
|
||||||
|
|
||||||
|
Jarl can make named answer "this name doesn't exist" (NXDOMAIN) for ads,
|
||||||
|
trackers and malware, like Pi-hole does, for every device that uses your
|
||||||
|
server to resolve names. It has two parts:
|
||||||
|
|
||||||
|
- **Blocklists**: lists of domains published by others, which jarld
|
||||||
|
downloads and keeps up to date.
|
||||||
|
- **Your rules**: domains you block or allow yourself. They take precedence
|
||||||
|
over every list.
|
||||||
|
|
||||||
|
It uses BIND's own mechanism for this, response policy zones (RPZ), so named
|
||||||
|
does the blocking itself; jarld only prepares the lists.
|
||||||
|
|
||||||
|
- [Quick start](#quick-start)
|
||||||
|
- [Where blocking applies](#where-blocking-applies)
|
||||||
|
- [Your rules](#your-rules)
|
||||||
|
- [Blocklists](#blocklists)
|
||||||
|
- [Checking a name](#checking-a-name)
|
||||||
|
- [Pausing](#pausing)
|
||||||
|
- [Statistics](#statistics)
|
||||||
|
- [Memory and performance](#memory-and-performance)
|
||||||
|
- [How it works](#how-it-works)
|
||||||
|
- [Troubleshooting](#troubleshooting)
|
||||||
|
|
||||||
|
## Quick start
|
||||||
|
|
||||||
|
In the web UI, open **Blocking**, pick one of the well-known lists, and
|
||||||
|
click **Add list**. Or with the CLI:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
jarl blocklist add hagezi-multi https://raw.githubusercontent.com/hagezi/dns-blocklists/main/adblock/multi.txt
|
||||||
|
jarl blocklist list # "ok" once downloaded, usually within seconds
|
||||||
|
jarl block check ads.doubleclick.net # blocked? by what?
|
||||||
|
jarl block allow cdn.example.com # something you need was blocked
|
||||||
|
```
|
||||||
|
|
||||||
|
Like every change in Jarl, adding a list or rule can be staged and reviewed
|
||||||
|
first (the web UI does this by default: apply it under **Changes**).
|
||||||
|
|
||||||
|
## Where blocking applies
|
||||||
|
|
||||||
|
Blocking changes the answers named gives when it resolves names for a
|
||||||
|
client (recursion). It doesn't change what the internet sees of your own
|
||||||
|
zones: other resolvers ask your server without recursion.
|
||||||
|
|
||||||
|
**Views.** A list or rule applies to the views you choose, or by default
|
||||||
|
to every view that offers recursion. With the usual split (an `internal`
|
||||||
|
view that resolves for your network, an `external` view that only answers
|
||||||
|
for your zones) that is just `internal`. A view that doesn't offer
|
||||||
|
recursion gets nothing, and naming one gives a warning. Without views,
|
||||||
|
blocking applies to the whole server.
|
||||||
|
|
||||||
|
**What it can't do:**
|
||||||
|
|
||||||
|
- Devices that don't use your server for DNS aren't affected. That includes
|
||||||
|
browsers using their own DNS over HTTPS, and devices with a hard-coded
|
||||||
|
resolver (8.8.8.8). A firewall rule that sends port 53 to your server, and
|
||||||
|
your network's DNS settings, are the usual answers.
|
||||||
|
- A device that validates DNSSEC itself sees a blocked name as a
|
||||||
|
validation failure rather than "doesn't exist". It's still blocked.
|
||||||
|
- A list can contain names in your own zones. They are blocked like any
|
||||||
|
other; allow them with a rule if that's not what you want.
|
||||||
|
|
||||||
|
## Your rules
|
||||||
|
|
||||||
|
A rule **blocks** or **allows** a domain and every name below it, or with
|
||||||
|
`--exact` (web UI: "only this exact name") just the domain itself.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
jarl block add doubleclick.net # doubleclick.net and everything below it
|
||||||
|
jarl block add telemetry.example.com --exact # just that name
|
||||||
|
jarl block allow cdn.example.com --view internal # an exception, in one view
|
||||||
|
jarl block list
|
||||||
|
jarl block delete doubleclick.net # or: --id ID
|
||||||
|
```
|
||||||
|
|
||||||
|
**Precedence:** your rules come before every blocklist, and among your
|
||||||
|
rules the most specific one wins. With these rules:
|
||||||
|
|
||||||
|
| Rule | |
|
||||||
|
|------|-|
|
||||||
|
| block `example.com` | and below |
|
||||||
|
| allow `cdn.example.com` | exact |
|
||||||
|
|
||||||
|
`example.com` and `www.example.com` are blocked, `cdn.example.com` is
|
||||||
|
allowed, and `img.cdn.example.com` is blocked again (the allow rule was for
|
||||||
|
the exact name). An allow rule beats a blocklist entry whatever the lists
|
||||||
|
say.
|
||||||
|
|
||||||
|
There is one rule per domain and view; a second one is refused. A rule
|
||||||
|
takes effect as soon as the change is applied.
|
||||||
|
|
||||||
|
## Blocklists
|
||||||
|
|
||||||
|
**Formats.** Lists are read line by line; one list may mix formats:
|
||||||
|
|
||||||
|
| Line | Blocks |
|
||||||
|
|------|--------|
|
||||||
|
| `\|\|ads.example^` (Adblock) | the name and every name below it |
|
||||||
|
| `*.ads.example` | the name and every name below it |
|
||||||
|
| `0.0.0.0 ads.example` (hosts file, any address) | just the name |
|
||||||
|
| `ads.example` (plain domain) | just the name |
|
||||||
|
|
||||||
|
Comments (`#`, `!`), headers and blank lines are ignored. Lines that DNS
|
||||||
|
can't act on are skipped and counted: Adblock exceptions (`@@`), cosmetic
|
||||||
|
and URL rules, addresses, invalid names, and single labels (a broken list
|
||||||
|
can't block `com`). Prefer lists in Adblock format: they say which entries
|
||||||
|
cover the names below them.
|
||||||
|
|
||||||
|
**Well-known lists** (sizes in October 2026; they change):
|
||||||
|
|
||||||
|
| List | URL | Domains | Memory per view |
|
||||||
|
|------|-----|--------:|----------------:|
|
||||||
|
| Hagezi Light | `https://raw.githubusercontent.com/hagezi/dns-blocklists/main/adblock/light.txt` | 55,000 | ~65 MB |
|
||||||
|
| Hagezi Multi Normal | `https://raw.githubusercontent.com/hagezi/dns-blocklists/main/adblock/multi.txt` | 159,000 | ~190 MB |
|
||||||
|
| Hagezi Pro | `https://raw.githubusercontent.com/hagezi/dns-blocklists/main/adblock/pro.txt` | 195,000 | ~235 MB |
|
||||||
|
| OISD Small | `https://small.oisd.nl/` | 58,000 | ~70 MB |
|
||||||
|
| OISD Big | `https://big.oisd.nl/` | 241,000 | ~290 MB |
|
||||||
|
| StevenBlack hosts | `https://raw.githubusercontent.com/StevenBlack/hosts/master/hosts` | 73,000 | ~45 MB |
|
||||||
|
|
||||||
|
One good list is usually enough; lists overlap a lot. Hagezi Multi Normal
|
||||||
|
is a balanced start; Light and OISD Small block less but almost never
|
||||||
|
something you need.
|
||||||
|
|
||||||
|
**Updates.** jarld checks each list every `blocking.refresh` (a day by
|
||||||
|
default) and downloads it only when the server says it changed. A new
|
||||||
|
version is checked with `named-checkzone` and replaces the old one only
|
||||||
|
when its content differs; named then reloads it. If a download or check
|
||||||
|
fails, named keeps using the previous version, jarld tries again within
|
||||||
|
the hour, and the dashboard shows the error. To update now:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
jarl blocklist refresh # every list; or: refresh hagezi-multi [--force]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Managing lists:**
|
||||||
|
|
||||||
|
```sh
|
||||||
|
jarl blocklist update hagezi-multi --view internal # --view auto: every view with recursion
|
||||||
|
jarl blocklist update hagezi-multi --url URL --name NEW-NAME
|
||||||
|
jarl blocklist disable hagezi-multi # stop using it, keep it (enable: back without a download)
|
||||||
|
jarl blocklist delete hagezi-multi
|
||||||
|
```
|
||||||
|
|
||||||
|
A list's state is **ok** (in use), **waiting** (not downloaded yet),
|
||||||
|
**error** (the last update failed; the previous version stays in use),
|
||||||
|
**disabled**, or **idle** (it applies to no view, so it does nothing).
|
||||||
|
|
||||||
|
## Different blocking for different devices
|
||||||
|
|
||||||
|
Client groups give some devices less or more blocking than the rest, like
|
||||||
|
Pi-hole's groups: no blocking for a work laptop, an extra list for the
|
||||||
|
kids' tablets.
|
||||||
|
|
||||||
|
**Steps** decide which lists a device gets. Each step includes the lists
|
||||||
|
of the steps below it, plus its own:
|
||||||
|
|
||||||
|
| Step | Gets |
|
||||||
|
|------|------|
|
||||||
|
| `off` (built in) | only your rules |
|
||||||
|
| `normal` (built in) | your rules and the normal lists; every device not in a group |
|
||||||
|
| `strict` (one you add) | normal, plus the lists at step `strict` |
|
||||||
|
| `very-strict` (one you add) | strict, plus the lists at step `very-strict` |
|
||||||
|
|
||||||
|
Every list starts at a step (normal unless you choose one), and every
|
||||||
|
group is at a step. Your rules apply at every step, `off` included.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
jarl block step add strict
|
||||||
|
jarl blocklist update social-media --step strict
|
||||||
|
jarl block group add work 10.10.0.20 --step off
|
||||||
|
jarl block group add kids 10.10.0.50 10.10.0.51 2001:db8:1::/64 --step strict
|
||||||
|
jarl block group list
|
||||||
|
```
|
||||||
|
|
||||||
|
In the web UI, the Blocking page's **Devices** panel holds the steps and
|
||||||
|
the groups, and each list has a step.
|
||||||
|
|
||||||
|
- **Members** are addresses, networks (`10.10.0.0/24`), ranges
|
||||||
|
(`10.10.0.20-10.10.0.49`), or ACLs made only of those (`key`, `localnets` and `!` can't be used: blocking matches
|
||||||
|
clients by address). A device is in one group: two groups with the same
|
||||||
|
address are refused, but a more specific address in another group wins
|
||||||
|
(`10.10.0.0/24` in one group, `10.10.0.20` in another).
|
||||||
|
- **Steps only add.** A group gets everything up to its step, so "kids get
|
||||||
|
list A, guests get list B, and neither gets the other's" can't be done
|
||||||
|
with steps. That needs a view per group, each with its own lists (and
|
||||||
|
their memory).
|
||||||
|
- Lists aren't copied per step: each is loaded once, and the steps cost a
|
||||||
|
small zone each. Lists at a step no group reaches block nothing (Jarl
|
||||||
|
warns, and shows them as "no effect").
|
||||||
|
- named must see the device's own address. When a router hands out itself
|
||||||
|
as DNS server and forwards to named, every query comes from the router,
|
||||||
|
and groups can't tell devices apart. Devices also need fixed addresses
|
||||||
|
(DHCP reservations); IPv6 privacy addresses change, so add a device's
|
||||||
|
network, or rely on its IPv4 address.
|
||||||
|
- `jarl block check NAME` (and the Blocking page) answers for normal
|
||||||
|
devices, and says when a list of a higher step blocks the name.
|
||||||
|
|
||||||
|
## Checking a name
|
||||||
|
|
||||||
|
`jarl block check NAME`, or the box at the top of the Blocking page, says
|
||||||
|
for each view whether a name is blocked or allowed, and by which rule or
|
||||||
|
list entry:
|
||||||
|
|
||||||
|
```
|
||||||
|
$ jarl block check img.ads.example.com
|
||||||
|
view internal: blocked by blocklist hagezi-multi (entry *.ads.example.com)
|
||||||
|
view guest: allowed by your rule 12 (entry *.ads.example.com)
|
||||||
|
```
|
||||||
|
|
||||||
|
Views without blocking aren't listed.
|
||||||
|
|
||||||
|
In the web UI a blocked name gets an **Allow** button, and the statistics'
|
||||||
|
most blocked names link to this check.
|
||||||
|
|
||||||
|
## Pausing
|
||||||
|
|
||||||
|
To see whether blocking is what breaks a site, pause it: every name is
|
||||||
|
answered normally until the time is up (at most a day), then blocking
|
||||||
|
resumes by itself, also if jarld was restarted in between. 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
|
||||||
|
|
||||||
|
With [query statistics](stats.md) on (including `query_log`), Jarl counts
|
||||||
|
what blocking does: blocked queries over time (next to all queries) and
|
||||||
|
as a share of them, which rules and lists blocked them, the most blocked
|
||||||
|
names, and the clients with the most blocked queries (`jarl stats summary`,
|
||||||
|
`jarl stats top blocked`, `jarl stats top blocked-clients`, `jarl stats
|
||||||
|
breakdown blocklist`). Blocked answers are left out of the NXDOMAIN figures, which
|
||||||
|
are about names that really don't exist.
|
||||||
|
|
||||||
|
The Blocking page shows the last 24 hours too: a **Blocked (24h)** column
|
||||||
|
for each list, and one count for your rules together (named logs which
|
||||||
|
policy zone matched, not which rule in it).
|
||||||
|
|
||||||
|
Without statistics named doesn't log what it blocks at all.
|
||||||
|
|
||||||
|
## Memory and performance
|
||||||
|
|
||||||
|
**Memory** is the cost to watch. named keeps each list in memory, once per
|
||||||
|
view it applies to: about 0.6 KB per policy record (an Adblock entry is
|
||||||
|
two records: the name, and the names below it). The Blocking page and
|
||||||
|
`jarl blocklist list` show the estimate per list. Hagezi Pro in one view
|
||||||
|
took named from 25 MB to 260 MB in a test with BIND 9.20; while a list
|
||||||
|
reloads, the old and new versions briefly coexist.
|
||||||
|
|
||||||
|
**Answers aren't slowed**: named looks blocked names up in memory, and
|
||||||
|
answers them without asking the internet.
|
||||||
|
|
||||||
|
**Updating a list** (Hagezi Pro: download, parse, check) takes jarld about
|
||||||
|
2 seconds. While named loads the new version, the queries that the loading
|
||||||
|
thread would have answered wait, up to half a second; this only happens
|
||||||
|
when the list changed, at most once per `blocking.refresh`.
|
||||||
|
|
||||||
|
**Other changes are unaffected**: the lists' contents never go through
|
||||||
|
Jarl's apply, so adding a record takes as long with a list as without.
|
||||||
|
|
||||||
|
Downloads are limited by `blocking.max_download_mb` and
|
||||||
|
`blocking.max_entries` ([configuration.md](configuration.md#blocking)).
|
||||||
|
|
||||||
|
## How it works
|
||||||
|
|
||||||
|
For each view (or the server, without views) Jarl writes a
|
||||||
|
`response-policy` statement and the policy zones it lists, in this order:
|
||||||
|
|
||||||
|
```
|
||||||
|
response-policy {
|
||||||
|
zone "rules.jarl.invalid" min-update-interval 0; # your rules
|
||||||
|
zone "hagezi-multi.blocklist.jarl.invalid" min-update-interval 0;
|
||||||
|
} qname-wait-recurse no break-dnssec yes;
|
||||||
|
```
|
||||||
|
|
||||||
|
- Policy zones are named under `.invalid`, so they can't clash with real
|
||||||
|
zones (zone names under `jarl.invalid` are refused). They allow no
|
||||||
|
queries or transfers.
|
||||||
|
- The rules zone of each view is a file next to your zone files
|
||||||
|
(`/var/lib/bind/jarl/VIEW/rules.jarl.invalid.rpz`) and shows up in the
|
||||||
|
changeset diff like any other.
|
||||||
|
- A list's file is `/var/cache/bind/jarl/blocklists/list-ID.rpz`, shared by
|
||||||
|
the views that use it. Until the first download it is an empty policy
|
||||||
|
zone, so named always starts. Files of deleted lists are removed.
|
||||||
|
- `qname-wait-recurse no` blocks without first resolving the real answer;
|
||||||
|
`break-dnssec yes` also blocks for clients that ask for DNSSEC data;
|
||||||
|
`min-update-interval 0` applies a new version at once.
|
||||||
|
- named looks names up in a policy zone like in any zone, so a wildcard
|
||||||
|
doesn't apply below a name the zone also contains. Jarl writes the zones
|
||||||
|
so that the most specific rule still wins.
|
||||||
|
- A `response-policy` option of your own can't be combined with Jarl's
|
||||||
|
blocking in the same view: Jarl refuses the change and says so.
|
||||||
|
- Rewrites are logged to Jarl's response log when statistics read it, and
|
||||||
|
not at all otherwise.
|
||||||
|
- With client groups, a **gate** zone stands in front of the lists of each
|
||||||
|
step: `off.gate.jarl.invalid` before the normal lists,
|
||||||
|
`STEP.gate.jarl.invalid` before a step's. named checks the zones in
|
||||||
|
order and the first match decides; a gate matches the devices below its
|
||||||
|
step by address (`rpz-client-ip` triggers) with `rpz-passthru`, so they
|
||||||
|
never reach the lists behind it. A gate is never logged, and its file is
|
||||||
|
next to the rules zone (`/var/lib/bind/jarl/VIEW/strict.gate.jarl.invalid.rpz`).
|
||||||
|
For "everyone except these devices", a gate lists the address ranges
|
||||||
|
around them, at most a few hundred entries. named matches IPv4 clients as
|
||||||
|
IPv4-mapped IPv6 addresses, so the IPv6 part leaves `::ffff:0:0/96` out.
|
||||||
|
|
||||||
|
Lists and rules need the `blocking` permission ([permissions.md](permissions.md)).
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
- **"no domains found in N lines"**: the URL didn't return a list (often an
|
||||||
|
HTML error page). Check it in a browser.
|
||||||
|
- **A list has no effect / is "idle"**: no view it applies to offers
|
||||||
|
recursion. Pick the view your devices use (`--view internal`).
|
||||||
|
- **Something is still answered**: `jarl block check NAME` shows whether
|
||||||
|
Jarl blocks it. If it does, the device probably doesn't use your server
|
||||||
|
(DNS over HTTPS, another resolver), or has the answer cached for a
|
||||||
|
while.
|
||||||
|
- **Something you need is blocked**: `jarl block check NAME` shows which
|
||||||
|
list; `jarl block allow NAME` overrides it.
|
||||||
+91
-7
@@ -41,7 +41,7 @@ jarl whoami
|
|||||||
| API token (`@FILE` reads it from a file) | `--token` | `JARL_TOKEN` | `token` |
|
| API token (`@FILE` reads it from a file) | `--token` | `JARL_TOKEN` | `token` |
|
||||||
| Skip TLS verification | `--insecure` | | `insecure` |
|
| Skip TLS verification | `--insecure` | | `insecure` |
|
||||||
| Extra trusted CA (PEM) | `--ca-cert` | | `ca_cert` |
|
| Extra trusted CA (PEM) | `--ca-cert` | | `ca_cert` |
|
||||||
| Changeset to stage into | `-C/--changeset` | `JARL_CHANGESET` | |
|
| Changeset to stage into (and the default for `jarl changeset diff/apply/...`) | `-C/--changeset` | `JARL_CHANGESET` (checked: open and yours, see [Changesets](#changesets-stage-review-apply)) | |
|
||||||
|
|
||||||
Flags win over the environment, and the environment wins over the file. The
|
Flags win over the environment, and the environment wins over the file. The
|
||||||
file is `jarl/config.yaml` in the user's configuration directory, or
|
file is `jarl/config.yaml` in the user's configuration directory, or
|
||||||
@@ -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
|
||||||
@@ -324,9 +345,9 @@ reads standard input.
|
|||||||
export JARL_CHANGESET=$(jarl changeset create "renumber lab" -q)
|
export JARL_CHANGESET=$(jarl changeset create "renumber lab" -q)
|
||||||
jarl record set lab.adbraun.lol gw A 10.20.0.1
|
jarl record set lab.adbraun.lol gw A 10.20.0.1
|
||||||
jarl record set lab.adbraun.lol nas A 10.20.0.10
|
jarl record set lab.adbraun.lol nas A 10.20.0.10
|
||||||
jarl changeset diff $JARL_CHANGESET # validation + named-checkconf + diff
|
jarl changeset diff # validation + named-checkconf + diff
|
||||||
jarl changeset remove $JARL_CHANGESET 2 # drop operation #2
|
jarl changeset remove 2 # drop operation #2
|
||||||
jarl changeset apply $JARL_CHANGESET
|
jarl changeset apply
|
||||||
unset JARL_CHANGESET
|
unset JARL_CHANGESET
|
||||||
|
|
||||||
jarl changeset list [--status open|applied|discarded] [--mine]
|
jarl changeset list [--status open|applied|discarded] [--mine]
|
||||||
@@ -336,6 +357,29 @@ jarl history [--limit 50]
|
|||||||
jarl changeset revert 42 -q | xargs jarl changeset apply # undo an applied changeset
|
jarl changeset revert 42 -q | xargs jarl changeset apply # undo an applied changeset
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`show`, `diff`, `apply`, `discard` and `remove` work on the changeset you
|
||||||
|
name, else the one in `-C`, else the one in `JARL_CHANGESET`.
|
||||||
|
|
||||||
|
A changeset from `JARL_CHANGESET` is checked before anything uses it,
|
||||||
|
because an exported ID easily outlives its changeset, and IDs keep growing
|
||||||
|
(every change, live ones included, gets the next one), so a stale or
|
||||||
|
mistyped ID can later name a changeset that isn't yours:
|
||||||
|
|
||||||
|
- Staging into it, and `diff`, `apply`, `discard` and `remove` on it, need
|
||||||
|
it to be open and yours. Otherwise the command stops and says why
|
||||||
|
(`there is no changeset 99`, `changeset 42 is applied`, `changeset 57 is
|
||||||
|
certbot's`) and what to do. Commands that only read (`jarl zone list`)
|
||||||
|
and `jarl changeset create` don't care, so you can always start a new one.
|
||||||
|
- Each command says on stderr which changeset it uses: `changeset 42 (renumber
|
||||||
|
lab, 2 operations) from JARL_CHANGESET` (not with `-q`).
|
||||||
|
- After `apply` or `discard`, a reminder to `unset JARL_CHANGESET`: the CLI
|
||||||
|
can't change your shell's environment, and later changes would fail to
|
||||||
|
stage.
|
||||||
|
|
||||||
|
`-C ID` and an ID argument are used as given, without these checks (jarld
|
||||||
|
still refuses a changeset that isn't open, or another user's unless you are
|
||||||
|
an administrator).
|
||||||
|
|
||||||
Changesets staged in the web UI show up here too, and the other way round.
|
Changesets staged in the web UI show up here too, and the other way round.
|
||||||
|
|
||||||
## Views, ACLs, TSIG keys, options, settings
|
## Views, ACLs, TSIG keys, options, settings
|
||||||
@@ -347,8 +391,10 @@ 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 create dhcp 10.0.10.101-10.0.10.199 2001:db8::100-2001:db8::1ff # ranges (concepts.md)
|
||||||
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
|
||||||
jarl acl update homelab --rename lan # references are updated
|
jarl acl update homelab --rename lan # references are updated
|
||||||
jarl acl delete lan # refused while in use
|
jarl acl delete lan # refused while in use
|
||||||
@@ -362,12 +408,22 @@ 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 allow-query '10.0.20.1-10.0.20.99; localhost' --view internal
|
||||||
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,15 +475,43 @@ 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 [--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
|
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.
|
||||||
See [stats.md](stats.md).
|
See [stats.md](stats.md).
|
||||||
|
|
||||||
|
## Blocking
|
||||||
|
|
||||||
|
```sh
|
||||||
|
jarl blocklist list
|
||||||
|
jarl blocklist add NAME URL [--view V]... # default: every view that offers recursion
|
||||||
|
jarl blocklist update NAME [--url U] [--name N] [--view V | --view auto] [--comment C]
|
||||||
|
jarl blocklist disable | enable | delete NAME
|
||||||
|
jarl blocklist refresh [NAME...] [--force] # download now (all lists without NAME)
|
||||||
|
|
||||||
|
jarl block add DOMAIN [--exact] [--view V]... # block it (and the names below it)
|
||||||
|
jarl block allow DOMAIN [--exact] [--view V]... # an exception that beats every list
|
||||||
|
jarl block list
|
||||||
|
jarl block delete DOMAIN [--view V] | --id ID
|
||||||
|
jarl block check DOMAIN # blocked? by which rule or list?
|
||||||
|
jarl block pause [DURATION] # let everything through for a while (default 10m, at most 24h)
|
||||||
|
jarl block pause indefinitely # ... until 'jarl block resume'
|
||||||
|
jarl block step list | add NAME | rename STEP NEW | order STEP... | delete STEP
|
||||||
|
jarl block group list
|
||||||
|
jarl block group add NAME MEMBER... [--step S] [--view V]... # addresses, networks or ACLs
|
||||||
|
jarl block group update GROUP [--add M] [--remove M] [--step S] [--name N]
|
||||||
|
jarl block group delete GROUP
|
||||||
|
jarl blocklist update LIST --step STEP # devices at that step or above get it
|
||||||
|
jarl block resume
|
||||||
|
```
|
||||||
|
|
||||||
|
See [blocking.md](blocking.md).
|
||||||
|
|
||||||
## ACME DNS-01
|
## ACME DNS-01
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
|
|||||||
+35
-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,38 @@ 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.
|
||||||
|
* **Address ranges.** Wherever named takes an address match list (ACLs,
|
||||||
|
views' `match-clients` and `match-destinations`, options such as
|
||||||
|
`allow-query` or `allow-recursion`) and in blocking's client groups, Jarl
|
||||||
|
also takes a range of addresses: `10.0.10.101-10.0.10.199`,
|
||||||
|
`2001:db8::100-2001:db8::1ff`, `!10.0.10.150-10.0.10.160`. BIND only
|
||||||
|
matches addresses and prefixes, so Jarl keeps the range as you wrote it
|
||||||
|
and writes the fewest prefixes that cover exactly that range to
|
||||||
|
named.conf, with the range as a comment above them:
|
||||||
|
```
|
||||||
|
acl "dhcp" {
|
||||||
|
// 10.0.10.101-10.0.10.199
|
||||||
|
10.0.10.101;
|
||||||
|
10.0.10.102/31;
|
||||||
|
10.0.10.104/29;
|
||||||
|
10.0.10.112/28;
|
||||||
|
10.0.10.128/26;
|
||||||
|
10.0.10.192/29;
|
||||||
|
};
|
||||||
|
```
|
||||||
|
Nothing outside the range is matched (writing such lists by hand, it is
|
||||||
|
easy to round to `10.0.10.96/27` and let in more than you meant). Both
|
||||||
|
ends are full addresses of the same family, and spaces around the dash
|
||||||
|
are fine. A range takes at most 62 prefixes for IPv4 and 254 for IPv6.
|
||||||
|
The web UI shows what a range becomes while you type it. A range in the
|
||||||
|
IPv4-mapped space (`::ffff:0:0/96`) gets a warning: named can see IPv4
|
||||||
|
clients as such addresses, so it may match IPv4 clients too.
|
||||||
* **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.
|
||||||
|
|
||||||
|
|||||||
@@ -73,6 +73,18 @@ The `stats:` section turns on query statistics (off by default). See
|
|||||||
[stats.md](stats.md) for every setting and what it changes in named's
|
[stats.md](stats.md) for every setting and what it changes in named's
|
||||||
configuration. Changing it takes effect when jarld restarts.
|
configuration. Changing it takes effect when jarld restarts.
|
||||||
|
|
||||||
|
### Prometheus metrics
|
||||||
|
|
||||||
|
| Key | Default | Meaning |
|
||||||
|
|-----|---------|---------|
|
||||||
|
| `metrics.enabled` | `false` | Serve Prometheus metrics at `/metrics` on `listen` and the local socket ([metrics.md](metrics.md)). |
|
||||||
|
| `metrics.listen` | `[]` | Extra `ADDRESS:PORT`s that serve only `/metrics`, over plain HTTP, e.g. `["0.0.0.0:9450"]`. |
|
||||||
|
| `metrics.allow` | `["127.0.0.1", "::1"]` | Addresses or prefixes that may scrape without a token. |
|
||||||
|
| `metrics.token` | `""` | A bearer token (at least 16 characters) that lets any address scrape. Keep jarl.yaml readable by root and jarld only when set. |
|
||||||
|
|
||||||
|
Query counters need `stats.enabled` as well. Changes take effect when jarld
|
||||||
|
restarts.
|
||||||
|
|
||||||
### ACME (lego httpreq endpoint)
|
### ACME (lego httpreq endpoint)
|
||||||
|
|
||||||
An endpoint for lego's `httpreq` DNS provider, so lego and Traefik can
|
An endpoint for lego's `httpreq` DNS provider, so lego and Traefik can
|
||||||
@@ -134,6 +146,17 @@ jarl.yaml; chmod 640 jarl.yaml`, as the Debian package installs it); jarld
|
|||||||
warns at start when everyone can read it. At porkbun.com, switch on "API
|
warns at start when everyone can read it. At porkbun.com, switch on "API
|
||||||
Access" for the domain, and limit the key to the domain if you can.
|
Access" for the domain, and limit the key to the domain if you can.
|
||||||
|
|
||||||
|
### Blocking
|
||||||
|
|
||||||
|
How jarld downloads blocklists ([blocking.md](blocking.md)). The lists and
|
||||||
|
rules themselves are configuration in the database.
|
||||||
|
|
||||||
|
| Key | Default | Meaning |
|
||||||
|
|-----|---------|---------|
|
||||||
|
| `blocking.refresh` | `24h` | How often each blocklist is checked for changes (at least `1h`); it is downloaded only when it changed ([blocking.md](blocking.md)). |
|
||||||
|
| `blocking.max_download_mb` | `64` | Larger downloads are refused (the previous version stays in use). |
|
||||||
|
| `blocking.max_entries` | `1000000` | Lists with more domains are refused. |
|
||||||
|
|
||||||
## Example: management network with TLS
|
## Example: management network with TLS
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ internal/
|
|||||||
dnsname/ DNS name handling: FQDNs, relative names, check-names rules, reverse zones
|
dnsname/ DNS name handling: FQDNs, relative names, check-names rules, reverse zones
|
||||||
rr/ record data parsing/canonicalization (miekg/dns), TXT quoting, the record type catalog
|
rr/ record data parsing/canonicalization (miekg/dns), TXT quoting, the record type catalog
|
||||||
namedconf/ named.conf lexer, parser and printer; address match lists
|
namedconf/ named.conf lexer, parser and printer; address match lists
|
||||||
|
iprange/ address ranges (10.0.10.101-10.0.10.199): parsing and the exact prefix cover
|
||||||
catalog/ every BIND 9.20 statement: grammar (from named.conf(5)) + ARM descriptions, embedded
|
catalog/ every BIND 9.20 statement: grammar (from named.conf(5)) + ARM descriptions, embedded
|
||||||
validate/ whole-configuration validation and security lints
|
validate/ whole-configuration validation and security lints
|
||||||
render/ State → named.conf, keys.conf and zone files, with a source map for error messages
|
render/ State → named.conf, keys.conf and zone files, with a source map for error messages
|
||||||
@@ -28,6 +29,8 @@ internal/
|
|||||||
nscheck/ DNS queries to other name servers: zone serials, the parent's delegation and DS records
|
nscheck/ DNS queries to other name servers: zone serials, the parent's delegation and DS records
|
||||||
dnssec/ reads named's DNSSEC key and state files; DS records; the signing phases (docs/dnssec.md)
|
dnssec/ reads named's DNSSEC key and state files; DS records; the signing phases (docs/dnssec.md)
|
||||||
porkbun/ Porkbun API client (name servers, glue, DS records) and an in-memory fake for tests
|
porkbun/ Porkbun API client (name servers, glue, DS records) and an in-memory fake for tests
|
||||||
|
blocking/ blocklist parser, response policy zone writers, and matching a name the way named does
|
||||||
|
(the downloads are in engine/blocklists.go)
|
||||||
config/ jarld's YAML configuration
|
config/ jarld's YAML configuration
|
||||||
deploy/ systemd units (jarld, the daily backup service and timer), sysusers.d and tmpfiles.d files
|
deploy/ systemd units (jarld, the daily backup service and timer), sysusers.d and tmpfiles.d files
|
||||||
deploy/debian/ the Debian package's control template and postinst/prerm/postrm
|
deploy/debian/ the Debian package's control template and postinst/prerm/postrm
|
||||||
|
|||||||
+231
@@ -0,0 +1,231 @@
|
|||||||
|
# Prometheus metrics
|
||||||
|
|
||||||
|
jarld can serve metrics for [Prometheus](https://prometheus.io/) at
|
||||||
|
`/metrics`:
|
||||||
|
- **health** you can alert on: named up, validation problems, files changed
|
||||||
|
outside Jarl, blocklists that fail to download, backup age, DNSSEC zones
|
||||||
|
waiting for you at the parent, and API tokens about to expire,
|
||||||
|
- **queries**, once [query statistics](stats.md) are on: named's counters
|
||||||
|
(result codes, record types, cache, upstream times, DNSSEC validation, rate
|
||||||
|
limiting) and Jarl's own (responses per view and zone, blocked queries per
|
||||||
|
blocklist), plus named's memory, CPU, cache and sockets.
|
||||||
|
|
||||||
|
Metrics are **off by default**. Everything is set in `/etc/jarl/jarl.yaml`;
|
||||||
|
nothing is stored in the database.
|
||||||
|
|
||||||
|
## Turning it on
|
||||||
|
|
||||||
|
Prometheus on the same server needs only:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
metrics:
|
||||||
|
enabled: true
|
||||||
|
```
|
||||||
|
|
||||||
|
```sh
|
||||||
|
sudo systemctl restart jarl
|
||||||
|
curl -s http://127.0.0.1:8053/metrics | head
|
||||||
|
```
|
||||||
|
|
||||||
|
`/metrics` is served on the addresses in `listen` (and their TLS, if set)
|
||||||
|
and on the local socket. Query counters also need `stats.enabled: true`
|
||||||
|
([stats.md](stats.md)); without it you get only the health metrics.
|
||||||
|
|
||||||
|
### Prometheus on another machine
|
||||||
|
|
||||||
|
Give `/metrics` an address of its own and say who may scrape it:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
metrics:
|
||||||
|
enabled: true
|
||||||
|
listen: ["0.0.0.0:9450"] # serves only /metrics, over plain HTTP
|
||||||
|
allow: ["192.168.1.20"] # the Prometheus server
|
||||||
|
```
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# prometheus.yml
|
||||||
|
scrape_configs:
|
||||||
|
- job_name: jarl
|
||||||
|
static_configs:
|
||||||
|
- targets: ["dns.lan:9450"]
|
||||||
|
```
|
||||||
|
|
||||||
|
Or use a token instead of (or as well as) the address list. Any address
|
||||||
|
that sends it may scrape:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
metrics:
|
||||||
|
enabled: true
|
||||||
|
listen: ["0.0.0.0:9450"]
|
||||||
|
token: "…" # e.g. from: openssl rand -hex 24
|
||||||
|
```
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# prometheus.yml
|
||||||
|
scrape_configs:
|
||||||
|
- job_name: jarl
|
||||||
|
authorization:
|
||||||
|
credentials: "…" # or credentials_file: /etc/prometheus/jarl.token
|
||||||
|
static_configs:
|
||||||
|
- targets: ["dns.lan:9450"]
|
||||||
|
```
|
||||||
|
|
||||||
|
With a token in jarl.yaml, keep the file readable by root and jarld only
|
||||||
|
(`chown root:jarl /etc/jarl/jarl.yaml; chmod 640 /etc/jarl/jarl.yaml`);
|
||||||
|
jarld warns at start when everyone can read it. On `metrics.listen` the
|
||||||
|
token travels unencrypted, so use it on a network you trust, or scrape the
|
||||||
|
main listener over HTTPS instead (`scheme: https` and its port in
|
||||||
|
Prometheus).
|
||||||
|
|
||||||
|
### Settings
|
||||||
|
|
||||||
|
| Key | Default | Meaning |
|
||||||
|
|-----|---------|---------|
|
||||||
|
| `metrics.enabled` | `false` | Serve `/metrics`. |
|
||||||
|
| `metrics.listen` | `[]` | Extra `ADDRESS:PORT`s that serve only `/metrics`, over plain HTTP. |
|
||||||
|
| `metrics.allow` | `["127.0.0.1", "::1"]` | Addresses or prefixes that may scrape without a token. Behind a reverse proxy in `trusted_proxies`, this is the client's address from `X-Forwarded-For`. `[]` with a token: the token is required everywhere. |
|
||||||
|
| `metrics.token` | `""` | A bearer token (at least 16 characters) that lets any address scrape. |
|
||||||
|
|
||||||
|
A scrape is allowed when it comes from an address in `allow`, **or**
|
||||||
|
carries the token, **or** comes over the local socket from root or jarld's
|
||||||
|
own user. A wrong token is refused (and counts towards the per-address
|
||||||
|
limit on failed logins). Jarl's API tokens don't work for `/metrics`.
|
||||||
|
|
||||||
|
The firewall must let Prometheus reach the port. `allow` is checked by
|
||||||
|
jarld, not the kernel.
|
||||||
|
|
||||||
|
## What is exported
|
||||||
|
|
||||||
|
Everything is prefixed `jarl_`. The text format is checked with `promtool
|
||||||
|
check metrics`.
|
||||||
|
|
||||||
|
### Health (always)
|
||||||
|
|
||||||
|
These are read at most every 15 seconds; a scrape in between gets the
|
||||||
|
same values.
|
||||||
|
|
||||||
|
| Metric | Meaning |
|
||||||
|
|--------|---------|
|
||||||
|
| `jarl_build_info{version}` | Always 1. |
|
||||||
|
| `jarl_start_time_seconds` | When jarld started. |
|
||||||
|
| `jarl_metrics_state_ok` | 0 when Jarl's state couldn't be read (see jarld's log). |
|
||||||
|
| `jarl_adopted` | 1 when named loads Jarl's configuration. |
|
||||||
|
| `jarl_named_up` | 1 when named answers `rndc status` (missing with `bind.no_reload`). |
|
||||||
|
| `jarl_named_recursive_clients`, `…_soft_limit`, `…_limit` | Recursive clients now, and named's limits (`rndc status`). |
|
||||||
|
| `jarl_named_tcp_clients`, `…_limit` | TCP clients now, and the limit. |
|
||||||
|
| `jarl_open_changesets` | Changesets with staged changes not applied yet. |
|
||||||
|
| `jarl_last_apply_timestamp_seconds` | When the last changeset was applied. |
|
||||||
|
| `jarl_problems{severity}` | Validation problems (`error`, `warning`, `info`) as on the dashboard, including named's own warnings. |
|
||||||
|
| `jarl_drifted_files` | Managed files changed or removed outside Jarl. |
|
||||||
|
| `jarl_objects{kind}` | Views, zones, records, ACLs, TSIG keys and options. |
|
||||||
|
| `jarl_backup_last_success_timestamp_seconds`, `jarl_backup_last_size_bytes` | The newest backup by `jarld backup`. |
|
||||||
|
| `jarl_backup_last_failure_timestamp_seconds` | A failed backup newer than the last good one. |
|
||||||
|
| `jarl_backup_stale` | 1 when the newest backup is older than `backup.warn_after`. |
|
||||||
|
| `jarl_blocking_paused` | 1 while blocking is paused (only with blocklists or rules). |
|
||||||
|
| `jarl_blocking_paused_until_timestamp_seconds` | When a timed pause ends. |
|
||||||
|
| `jarl_blocklist_enabled{list}` | 1 for lists that are switched on. |
|
||||||
|
| `jarl_blocklist_entries{list}` | Domains in the last good download. |
|
||||||
|
| `jarl_blocklist_failing{list}` | 1 when the last download or check of an enabled list failed. |
|
||||||
|
| `jarl_blocklist_last_check_timestamp_seconds{list}`, `jarl_blocklist_last_change_timestamp_seconds{list}` | When the list was last checked, and last changed. |
|
||||||
|
| `jarl_dnssec_phase{zone,view,phase}` | Always 1, one per signed zone; `phase` is one of the [DNSSEC phases](dnssec.md). `ready` means: add the DS at the parent. `removing_ds`: remove it. |
|
||||||
|
| `jarl_token_expiry_timestamp_seconds{id,user,token}` | When each API token with an expiry date expires. |
|
||||||
|
|
||||||
|
### Queries (with `stats.enabled`)
|
||||||
|
|
||||||
|
The counters start at zero when jarld starts (Prometheus handles that).
|
||||||
|
named's counters move once a minute, when Jarl polls named's statistics
|
||||||
|
channel. Counters from the response log (`responses`, views, zones,
|
||||||
|
blocking) move as lines are read and need `stats.query_log`. Clients in
|
||||||
|
`stats.exclude_clients` are left out of those. Names and clients are not
|
||||||
|
exported, because there would be too many of them. Use the Statistics page
|
||||||
|
for them.
|
||||||
|
|
||||||
|
| Metric | Meaning |
|
||||||
|
|--------|---------|
|
||||||
|
| `jarl_queries_total` | Queries named received. |
|
||||||
|
| `jarl_responses_by_rcode_total{rcode}` | Responses by result code (`NOERROR`, `NXDOMAIN`, `SERVFAIL`, `REFUSED`, …). |
|
||||||
|
| `jarl_queries_by_type_total{type}` | Queries by record type. |
|
||||||
|
| `jarl_queries_by_transport_total{transport}` | `udp` and `tcp`. |
|
||||||
|
| `jarl_answers_total{source}` | `authoritative` or `recursive`. |
|
||||||
|
| `jarl_recursion_queries_total`, `jarl_dropped_queries_total`, `jarl_duplicate_queries_total` | Queries that caused recursion, that named dropped, and duplicates. |
|
||||||
|
| `jarl_ratelimit_responses_total{action}` | Responses rate limiting `dropped` or `slipped`. |
|
||||||
|
| `jarl_upstream_responses_by_rtt_total{rtt}` | Upstream answers during recursion by round-trip time (`<10ms` … `>1600ms`). |
|
||||||
|
| `jarl_dnssec_validations_total{result}` | `valid`, `valid-negative` and `failed`. |
|
||||||
|
| `jarl_cache_lookups_total{result}` | `hits` and `misses`. |
|
||||||
|
| `jarl_logged_responses_total` | Responses read from the response log. |
|
||||||
|
| `jarl_responses_by_view_total{view}` | Logged responses per view (`_default` without views). |
|
||||||
|
| `jarl_responses_by_zone_total{zone}` | Logged responses per local zone; `-` for names in no local zone. |
|
||||||
|
| `jarl_blocked_queries_total` | Queries blocking answered. |
|
||||||
|
| `jarl_blocked_by_list_total{list}` | Blocked queries per blocklist; `rules` for your own rules. |
|
||||||
|
| `jarl_stats_poll_ok`, `jarl_stats_last_poll_timestamp_seconds` | Whether the last poll of named's statistics channel worked, and when it was. |
|
||||||
|
| `jarl_stats_log_lines_total`, `jarl_stats_last_log_line_timestamp_seconds` | Response log lines read, and the time of the last one. |
|
||||||
|
| `jarl_stats_warnings` | Warnings on the Statistics page (e.g. no log lines while named answers queries). |
|
||||||
|
| `jarl_named_start_time_seconds`, `jarl_named_config_time_seconds` | When named started, and last loaded its configuration. |
|
||||||
|
| `jarl_named_resident_memory_bytes`, `jarl_named_threads`, `jarl_named_cpu_seconds_total` | named's process (`/proc`). |
|
||||||
|
| `jarl_named_memory_in_use_bytes` | Memory named counts as in use. |
|
||||||
|
| `jarl_named_cache_rrsets`, `jarl_named_cache_memory_bytes` | named's caches, all views together. |
|
||||||
|
| `jarl_named_sockets{proto}` | Open UDP and TCP sockets. |
|
||||||
|
| `jarl_named_recursive_clients_highwater`, `jarl_named_tcp_clients_highwater` | The most clients at once since named started. |
|
||||||
|
|
||||||
|
Host metrics (load, memory, disks) are left to
|
||||||
|
[node_exporter](https://github.com/prometheus/node_exporter).
|
||||||
|
|
||||||
|
## Example queries
|
||||||
|
|
||||||
|
```promql
|
||||||
|
# Queries per second, and the share that failed
|
||||||
|
rate(jarl_queries_total[5m])
|
||||||
|
rate(jarl_responses_by_rcode_total{rcode="SERVFAIL"}[5m]) / ignoring(rcode) rate(jarl_queries_total[5m])
|
||||||
|
|
||||||
|
# Blocked share of logged responses, per list
|
||||||
|
rate(jarl_blocked_by_list_total[1h]) / ignoring(list) group_left rate(jarl_logged_responses_total[1h])
|
||||||
|
|
||||||
|
# Busiest zones
|
||||||
|
topk(5, rate(jarl_responses_by_zone_total{zone!="-"}[1h]))
|
||||||
|
|
||||||
|
# Cache hit rate
|
||||||
|
rate(jarl_cache_lookups_total{result="hits"}[5m]) / ignoring(result) sum without(result) (rate(jarl_cache_lookups_total[5m]))
|
||||||
|
```
|
||||||
|
|
||||||
|
## Example alerts
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
groups:
|
||||||
|
- name: jarl
|
||||||
|
rules:
|
||||||
|
- alert: JarlDown
|
||||||
|
expr: up{job="jarl"} == 0
|
||||||
|
for: 5m
|
||||||
|
- alert: NamedDown
|
||||||
|
expr: jarl_named_up == 0
|
||||||
|
for: 2m
|
||||||
|
- alert: JarlConfigErrors
|
||||||
|
expr: jarl_problems{severity="error"} > 0
|
||||||
|
for: 15m
|
||||||
|
- alert: JarlDrift
|
||||||
|
expr: jarl_drifted_files > 0
|
||||||
|
for: 15m
|
||||||
|
- alert: BlocklistFailing
|
||||||
|
expr: jarl_blocklist_failing == 1
|
||||||
|
for: 6h
|
||||||
|
- alert: BackupStale
|
||||||
|
expr: jarl_backup_stale == 1
|
||||||
|
- alert: DNSSECWaitingForYou
|
||||||
|
expr: jarl_dnssec_phase{phase=~"ready|removing_ds|unsigned"} == 1
|
||||||
|
for: 1h
|
||||||
|
annotations:
|
||||||
|
summary: "{{ $labels.zone }}: DNSSEC is waiting for a step at the parent ({{ $labels.phase }})"
|
||||||
|
- alert: TokenExpiresSoon
|
||||||
|
expr: jarl_token_expiry_timestamp_seconds - time() < 7 * 86400
|
||||||
|
annotations:
|
||||||
|
summary: "API token {{ $labels.token }} of {{ $labels.user }} expires within a week"
|
||||||
|
- alert: BlockingPausedForLong
|
||||||
|
expr: jarl_blocking_paused == 1
|
||||||
|
for: 24h
|
||||||
|
- alert: NamedRecursiveClientsHigh
|
||||||
|
expr: jarl_named_recursive_clients / jarl_named_recursive_clients_soft_limit > 0.8
|
||||||
|
for: 5m
|
||||||
|
- alert: StatsNotCollecting
|
||||||
|
expr: jarl_stats_poll_ok == 0 or jarl_stats_warnings > 0
|
||||||
|
for: 15m
|
||||||
|
```
|
||||||
@@ -363,6 +363,9 @@ rndc reconfig
|
|||||||
## Monitoring
|
## Monitoring
|
||||||
|
|
||||||
* `GET /healthz` (no authentication) reports that jarld is up.
|
* `GET /healthz` (no authentication) reports that jarld is up.
|
||||||
|
* Prometheus: `/metrics` covers named up, configuration problems, drift,
|
||||||
|
blocklist failures, backup age, DNSSEC steps waiting at the parent and
|
||||||
|
expiring tokens, with example alerts ([metrics.md](metrics.md)).
|
||||||
* On the server, a check can use the local socket and needs no token:
|
* On the server, a check can use the local socket and needs no token:
|
||||||
`sudo jarl status -o json`.
|
`sudo jarl status -o json`.
|
||||||
* `GET /api/v1/status` with a read-only token reports named reachability,
|
* `GET /api/v1/status` with a read-only token reports named reachability,
|
||||||
|
|||||||
+2
-1
@@ -135,6 +135,7 @@ RESOURCE:ACCESS [zones=PATTERNS] [views=PATTERNS] [names=PATTERNS] [types=TYPES]
|
|||||||
| `options` | server options, top-level statements, the defaults for new zones, `jarl check` |
|
| `options` | server options, top-level statements, the defaults for new zones, `jarl check` |
|
||||||
| `audit` | everyone's audit events and changesets (without it, users see only their own) |
|
| `audit` | everyone's audit events and changesets (without it, users see only their own) |
|
||||||
| `stats` | query statistics, including client addresses and the names they look up (read only) |
|
| `stats` | query statistics, including client addresses and the names they look up (read only) |
|
||||||
|
| `blocking` | blocklists and block/allow rules ([blocking.md](blocking.md)); `write` also downloads lists now |
|
||||||
| `*` | all of the above |
|
| `*` | all of the above |
|
||||||
|
|
||||||
`ACCESS` is `read` or `write` (write includes read). Filters narrow a grant.
|
`ACCESS` is `read` or `write` (write includes read). Filters narrow a grant.
|
||||||
@@ -168,7 +169,7 @@ you can then edit.
|
|||||||
| Role | Grants |
|
| Role | Grants |
|
||||||
|------|--------|
|
|------|--------|
|
||||||
| `readonly` | `*:read` (includes statistics) |
|
| `readonly` | `*:read` (includes statistics) |
|
||||||
| `dns-editor` | `zones:write`, `records:write`, `views:read`, `acls:read`, `options:read` |
|
| `dns-editor` | `zones:write`, `records:write`, `views:read`, `acls:read`, `options:read`, `blocking:read` |
|
||||||
| `operator` | `*:write` (everything except user management) |
|
| `operator` | `*:write` (everything except user management) |
|
||||||
| `acme` | `records:write names=_acme-challenge.* types=TXT` |
|
| `acme` | `records:write names=_acme-challenge.* types=TXT` |
|
||||||
|
|
||||||
|
|||||||
+33
-2
@@ -102,10 +102,34 @@ lists, views, zones). named's own counters can't be filtered.
|
|||||||
| `view:NAME` | response log | answers per view (`_default` without views) |
|
| `view:NAME` | response log | answers per view (`_default` without views) |
|
||||||
| `zone:NAME` | response log | answers per local zone; `zone:-` for names in none of your zones |
|
| `zone:NAME` | response log | answers per local zone; `zone:-` for names in none of your zones |
|
||||||
| `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) |
|
||||||
|
|
||||||
|
**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).
|
(names answered SERVFAIL), `refused` (clients that were refused), `blocked`
|
||||||
|
(names blocking answered), `blocked_client` (clients by blocked queries). Blocked answers are NXDOMAIN too, but they
|
||||||
|
aren't counted as names that don't exist: not in the `nxdomain` list, and
|
||||||
|
not in the summary's NXDOMAIN ratio.
|
||||||
|
|
||||||
Nothing else is stored. In particular there's no per-query record, so you
|
Nothing else is stored. In particular there's no per-query record, so you
|
||||||
can't look up "who asked for X at 14:02". That is deliberate, both for size
|
can't look up "who asked for X at 14:02". That is deliberate, both for size
|
||||||
@@ -127,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**:
|
||||||
|
|
||||||
@@ -142,11 +168,16 @@ 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)
|
||||||
```
|
```
|
||||||
|
|
||||||
**API**: see [api.md](api.md#query-statistics).
|
**API**: see [api.md](api.md#query-statistics).
|
||||||
|
|
||||||
|
**Prometheus**: with `metrics.enabled`, the counters are also served at
|
||||||
|
`/metrics` ([metrics.md](metrics.md)).
|
||||||
|
|
||||||
**Permissions**: the `stats` resource (`stats:read`). The `readonly` and
|
**Permissions**: the `stats` resource (`stats:read`). The `readonly` and
|
||||||
`operator` roles include it, `dns-editor` doesn't. Statistics show client
|
`operator` roles include it, `dns-editor` doesn't. Statistics show client
|
||||||
addresses and what they look up, so grant it with care.
|
addresses and what they look up, so grant it with care.
|
||||||
|
|||||||
+32
-3
@@ -30,6 +30,7 @@ showing them.
|
|||||||
| `record_out_of_zone` | error | The name must be inside the zone (catches `www.example.com` without the final dot) |
|
| `record_out_of_zone` | error | The name must be inside the zone (catches `www.example.com` without the final dot) |
|
||||||
| `record_duplicate` | error | The same record can't exist twice |
|
| `record_duplicate` | error | The same record can't exist twice |
|
||||||
| `cname_conflict` | error | A CNAME must be the only record at its name (RFC 1034), so no CNAME next to A, MX, TXT... |
|
| `cname_conflict` | error | A CNAME must be the only record at its name (RFC 1034), so no CNAME next to A, MX, TXT... |
|
||||||
|
| `disabled_conflict` | warning | A disabled record that couldn't be enabled again as the zone is now (a CNAME now shares its name) |
|
||||||
| `cname_multiple` | error | A name can have only one CNAME |
|
| `cname_multiple` | error | A name can have only one CNAME |
|
||||||
| `cname_apex` | error | No CNAME at the zone apex (it needs SOA and NS) |
|
| `cname_apex` | error | No CNAME at the zone apex (it needs SOA and NS) |
|
||||||
| `cname_loop` | error | CNAMEs that point back to themselves |
|
| `cname_loop` | error | CNAMEs that point back to themselves |
|
||||||
@@ -56,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 |
|
||||||
@@ -64,11 +67,12 @@ showing them.
|
|||||||
|
|
||||||
| Code | Severity | Rule |
|
| Code | Severity | Rule |
|
||||||
|------|----------|------|
|
|------|----------|------|
|
||||||
| `acl_element_invalid` | error | Address match elements: addresses, prefixes (host bits must be zero, like BIND requires), `!negation`, `key NAME`, ACL names, `{ nested lists }`, `any`/`none`/`localhost`/`localnets` |
|
| `acl_element_invalid` | error | Address match elements: addresses, prefixes (host bits must be zero, like BIND requires), ranges (`10.0.10.101-10.0.10.199`: both ends full addresses of one family, start not after end), `!negation`, `key NAME`, ACL names, `{ nested lists }`, `any`/`none`/`localhost`/`localnets`. Ranges in options are checked when they're entered, and only options with address match lists take them (not `forwarders`, say) |
|
||||||
|
| `range_mapped4` | warning | An IPv6 range that reaches into `::ffff:0:0/96`: named can see IPv4 clients as such addresses, so it may match IPv4 clients too |
|
||||||
| `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, names that look like addresses or ranges, elements that can never match (a prefix or range an earlier one already covers) |
|
||||||
| `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) |
|
||||||
@@ -100,6 +104,24 @@ applied.
|
|||||||
| `dnssec_old_keys` | info | Signing a zone whose key directory still has old keys |
|
| `dnssec_old_keys` | info | Signing a zone whose key directory still has old keys |
|
||||||
| `serial_not_higher` | error | An explicit serial on a signed zone must be above the one named has loaded (automatic serials are raised by themselves) |
|
| `serial_not_higher` | error | An explicit serial on a signed zone must be above the one named has loaded (automatic serials are raised by themselves) |
|
||||||
|
|
||||||
|
## Blocking
|
||||||
|
|
||||||
|
From [blocking.md](blocking.md).
|
||||||
|
|
||||||
|
| Code | Severity | Rule |
|
||||||
|
|------|----------|------|
|
||||||
|
| `blocking_rule_duplicate` | error | One rule per domain and view |
|
||||||
|
| `blocking_response_policy` | error | No `response-policy` option where Jarl's blocking writes that statement (the view, or the server) |
|
||||||
|
| `zone_name_reserved` | error | Zone names under `jarl.invalid` are reserved for blocking's policy zones |
|
||||||
|
| `blocking_view_missing` | error | A list or rule names a view that doesn't exist |
|
||||||
|
| `blocking_no_view` | warning | A list or rule applies to no view: none offers recursion, and blocking only changes recursive answers |
|
||||||
|
| `blocking_no_recursion` | warning | A list or rule names a view that doesn't offer recursion, where it has no effect |
|
||||||
|
| `blocking_step_missing` | error | A list or client group is at a step that doesn't exist (a list can't be at `off`) |
|
||||||
|
| `client_group_member_invalid` | error | Client group members are addresses, networks, or ACLs made only of those |
|
||||||
|
| `client_group_overlap` | error | The same address or network is in two client groups of a view |
|
||||||
|
| `client_group_empty` | warning | A client group has no members |
|
||||||
|
| `blocklist_step_unused` | warning | A list is at a step that no client group reaches, so it blocks nothing |
|
||||||
|
|
||||||
## Security lints
|
## Security lints
|
||||||
|
|
||||||
| Code | Severity | What it catches |
|
| Code | Severity | What it catches |
|
||||||
@@ -116,3 +138,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`
|
||||||
|
|||||||
+67
-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.
|
||||||
@@ -242,6 +246,21 @@ type StatusResponse struct {
|
|||||||
Backup *BackupStatus `json:"backup,omitempty"`
|
Backup *BackupStatus `json:"backup,omitempty"`
|
||||||
// TOTP is about two-factor login across users (administrators only).
|
// TOTP is about two-factor login across users (administrators only).
|
||||||
TOTP *TOTPOverview `json:"totp,omitempty"`
|
TOTP *TOTPOverview `json:"totp,omitempty"`
|
||||||
|
// BlocklistErrors are blocklists whose last update failed (for those
|
||||||
|
// who may see blocking).
|
||||||
|
BlocklistErrors []BlocklistError `json:"blocklist_errors,omitempty"`
|
||||||
|
// BlockingPausedUntil is set while blocking is paused 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
|
||||||
|
// the version named keeps using (nil: none yet).
|
||||||
|
type BlocklistError struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Error string `json:"error"`
|
||||||
|
UpdatedAt *time.Time `json:"updated_at,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// TOTPOverview is what the dashboard reports about two-factor login.
|
// TOTPOverview is what the dashboard reports about two-factor login.
|
||||||
@@ -471,3 +490,49 @@ type RegistrarChangeResponse struct {
|
|||||||
Checks []nscheck.Finding `json:"checks"`
|
Checks []nscheck.Finding `json:"checks"`
|
||||||
Warnings []string `json:"warnings"`
|
Warnings []string `json:"warnings"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Blocklist is a blocklist as returned by the API (docs/blocking.md).
|
||||||
|
type Blocklist struct {
|
||||||
|
model.Blocklist
|
||||||
|
// Views are the views it applies to; AutoViews is set when that is
|
||||||
|
// "every view that offers recursion" (view_ids is empty).
|
||||||
|
Views []string `json:"views"`
|
||||||
|
AutoViews bool `json:"auto_views,omitempty"`
|
||||||
|
// State is ok, waiting (not downloaded yet), error (the last update
|
||||||
|
// failed; the previous version stays in use), disabled, or idle (it
|
||||||
|
// applies to no view).
|
||||||
|
State string `json:"state"`
|
||||||
|
Status *model.BlocklistStatus `json:"status,omitempty"`
|
||||||
|
// MemoryMB estimates what named needs for the list, in all its views.
|
||||||
|
MemoryMB int `json:"memory_mb,omitempty"`
|
||||||
|
Pending string `json:"pending,omitempty"`
|
||||||
|
// Step is the name of the step the list starts at ("normal" or one
|
||||||
|
// above it).
|
||||||
|
Step string `json:"step"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// BlockStep is a blocking step above normal as returned by the API, with
|
||||||
|
// what uses it.
|
||||||
|
type BlockStep struct {
|
||||||
|
model.BlockStep
|
||||||
|
Lists []string `json:"lists"`
|
||||||
|
Groups []string `json:"groups"`
|
||||||
|
Pending string `json:"pending,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClientGroup is a client group as returned by the API.
|
||||||
|
type ClientGroup struct {
|
||||||
|
model.ClientGroup
|
||||||
|
Step string `json:"step"`
|
||||||
|
Views []string `json:"views"`
|
||||||
|
AutoViews bool `json:"auto_views,omitempty"`
|
||||||
|
Pending string `json:"pending,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// BlockRule is a block or allow rule as returned by the API.
|
||||||
|
type BlockRule struct {
|
||||||
|
model.BlockRule
|
||||||
|
Views []string `json:"views"`
|
||||||
|
AutoViews bool `json:"auto_views,omitempty"`
|
||||||
|
Pending string `json:"pending,omitempty"`
|
||||||
|
}
|
||||||
@@ -20,11 +20,12 @@ const (
|
|||||||
ResOptions = "options" // server options, top-level statements, settings
|
ResOptions = "options" // server options, top-level statements, settings
|
||||||
ResAudit = "audit" // audit log and everyone's changesets
|
ResAudit = "audit" // audit log and everyone's changesets
|
||||||
ResStats = "stats" // query statistics
|
ResStats = "stats" // query statistics
|
||||||
|
ResBlocking = "blocking" // blocklists and block/allow rules
|
||||||
ResAll = "*"
|
ResAll = "*"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Resources lists the resources in display order.
|
// Resources lists the resources in display order.
|
||||||
var Resources = []string{ResZones, ResRecords, ResViews, ResACLs, ResTSIG, ResOptions, ResAudit, ResStats}
|
var Resources = []string{ResZones, ResRecords, ResViews, ResACLs, ResTSIG, ResOptions, ResAudit, ResStats, ResBlocking}
|
||||||
|
|
||||||
// Access levels.
|
// Access levels.
|
||||||
const (
|
const (
|
||||||
@@ -377,6 +378,7 @@ var RoleTemplates = map[string][]model.Grant{
|
|||||||
{Resource: ResViews, Access: Read},
|
{Resource: ResViews, Access: Read},
|
||||||
{Resource: ResACLs, Access: Read},
|
{Resource: ResACLs, Access: Read},
|
||||||
{Resource: ResOptions, Access: Read},
|
{Resource: ResOptions, Access: Read},
|
||||||
|
{Resource: ResBlocking, Access: Read},
|
||||||
},
|
},
|
||||||
"operator": {{Resource: ResAll, Access: Write}},
|
"operator": {{Resource: ResAll, Access: Write}},
|
||||||
"acme": {{Resource: ResRecords, Access: Write, Names: []string{"_acme-challenge.*"}, Types: []string{"TXT"}}},
|
"acme": {{Resource: ResRecords, Access: Write, Names: []string{"_acme-challenge.*"}, Types: []string{"TXT"}}},
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,180 @@
|
|||||||
|
package blocking
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestParse(t *testing.T) {
|
||||||
|
src := "\uFEFF[Adblock Plus]\r\n" + `! Title: test list
|
||||||
|
# a comment
|
||||||
|
|
||||||
|
||ads.example^
|
||||||
|
||Tracker.Example.^$important
|
||||||
|
||cosmetic.example^$third-party
|
||||||
|
@@||allowed.example^
|
||||||
|
example.org##.banner
|
||||||
|
|https://url.example/path
|
||||||
|
0.0.0.0 hosts.example other.example # two names
|
||||||
|
127.0.0.1 localhost
|
||||||
|
127.0.0.1 localhost.localdomain
|
||||||
|
::1 ip6-localhost
|
||||||
|
0.0.0.0 0.0.0.0
|
||||||
|
plain.example
|
||||||
|
*.wild.example
|
||||||
|
single
|
||||||
|
1.2.3.4
|
||||||
|
bad_label!.example
|
||||||
|
münchen.example
|
||||||
|
www.ads.example
|
||||||
|
` + strings.Repeat("a", 64) + ".example\n"
|
||||||
|
l, err := Parse(strings.NewReader(src), Limits{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
want := map[string]uint8{
|
||||||
|
"ads.example": Exact | Below, "tracker.example": Exact | Below,
|
||||||
|
"hosts.example": Exact, "other.example": Exact, "plain.example": Exact,
|
||||||
|
"wild.example": Exact | Below, "xn--mnchen-3ya.example": Exact, "www.ads.example": Exact,
|
||||||
|
}
|
||||||
|
if len(l.Domains) != len(want) {
|
||||||
|
t.Errorf("domains = %v", l.Domains)
|
||||||
|
}
|
||||||
|
for n, f := range want {
|
||||||
|
if l.Domains[n] != f {
|
||||||
|
t.Errorf("%s = %d, want %d", n, l.Domains[n], f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Skipped: $third-party, @@, ##, |url, localhost (x3), 0.0.0.0, single, 1.2.3.4, bad_label!, 64-char label.
|
||||||
|
if l.Skipped != 12 || l.Lines != 19 {
|
||||||
|
t.Errorf("lines %d, skipped %d", l.Lines, l.Skipped)
|
||||||
|
}
|
||||||
|
|
||||||
|
Compact(l)
|
||||||
|
if _, ok := l.Domains["www.ads.example"]; ok {
|
||||||
|
t.Error("www.ads.example is covered by ||ads.example^ and should be dropped")
|
||||||
|
}
|
||||||
|
var sb strings.Builder
|
||||||
|
n, hash, err := FeedZone(&sb, "test", 7, l)
|
||||||
|
if err != nil || n != 10 {
|
||||||
|
t.Fatalf("FeedZone: %d records, %v", n, err)
|
||||||
|
}
|
||||||
|
for _, line := range []string{"ads.example\tCNAME\t.\n", "*.ads.example\tCNAME\t.\n", "plain.example\tCNAME\t.\n", "@\tSOA\tlocalhost. hostmaster.localhost. 7 "} {
|
||||||
|
if !strings.Contains(sb.String(), line) {
|
||||||
|
t.Errorf("zone lacks %q:\n%s", line, sb.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.Contains(sb.String(), "*.plain.example") || strings.Contains(sb.String(), "$ORIGIN") {
|
||||||
|
t.Errorf("zone:\n%s", sb.String())
|
||||||
|
}
|
||||||
|
// The hash ignores the serial.
|
||||||
|
var other strings.Builder
|
||||||
|
if _, h2, _ := FeedZone(&other, "test", 8, l); h2 != hash {
|
||||||
|
t.Error("hash depends on the serial")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseLimits(t *testing.T) {
|
||||||
|
_, err := Parse(strings.NewReader("a.example\nb.example\na.example\nc.example\n"), Limits{MaxEntries: 2})
|
||||||
|
if !errors.Is(err, ErrTooManyEntries) {
|
||||||
|
t.Errorf("err = %v", err)
|
||||||
|
}
|
||||||
|
if _, err := Parse(strings.NewReader("a.example\nb.example\na.example\n"), Limits{MaxEntries: 2}); err != nil {
|
||||||
|
t.Errorf("a repeated name counted against the limit: %v", err)
|
||||||
|
}
|
||||||
|
l, _ := Parse(strings.NewReader("long-name.example\nshort.example\n"), Limits{MaxNameLen: 15})
|
||||||
|
if len(l.Domains) != 1 || l.Skipped != 1 {
|
||||||
|
t.Errorf("max name length: %v", l.Domains)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRuleEntries(t *testing.T) {
|
||||||
|
rules := []Rule{
|
||||||
|
{Name: "ads.example", Action: ActionBlock}, // and everything below
|
||||||
|
{Name: "cdn.ads.example", Action: ActionAllow, Exact: true}, // just this name
|
||||||
|
{Name: "wide.example", Action: ActionBlock}, // and below
|
||||||
|
{Name: "ok.wide.example", Action: ActionAllow}, // and below
|
||||||
|
{Name: "deep.x.y.wide.example", Action: ActionAllow, Exact: true}, // two empty names in between
|
||||||
|
{Name: "solo.example", Action: ActionBlock, Exact: true},
|
||||||
|
}
|
||||||
|
e := RuleEntries(rules)
|
||||||
|
for name, want := range map[string]string{
|
||||||
|
"ads.example": ActionBlock,
|
||||||
|
"x.ads.example": ActionBlock,
|
||||||
|
"cdn.ads.example": ActionAllow,
|
||||||
|
"y.cdn.ads.example": ActionBlock, // the wildcard above still applies
|
||||||
|
"ok.wide.example": ActionAllow,
|
||||||
|
"a.ok.wide.example": ActionAllow,
|
||||||
|
"y.wide.example": ActionBlock, // exists only because of a deeper rule
|
||||||
|
"x.y.wide.example": ActionBlock,
|
||||||
|
"z.x.y.wide.example": ActionBlock,
|
||||||
|
"deep.x.y.wide.example": ActionAllow,
|
||||||
|
"below.deep.x.y.wide.example": ActionBlock,
|
||||||
|
"solo.example": ActionBlock,
|
||||||
|
"other.example": "",
|
||||||
|
"sub.solo.example": "",
|
||||||
|
"example": "",
|
||||||
|
"unrelated.org": "",
|
||||||
|
} {
|
||||||
|
_, act, ok := MatchRules(e, name)
|
||||||
|
if !ok {
|
||||||
|
act = ""
|
||||||
|
}
|
||||||
|
if act != want {
|
||||||
|
t.Errorf("%s: %q, want %q", name, act, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
z := RulesZoneFile("rules", rules)
|
||||||
|
if !strings.Contains(z, "cdn.ads.example\tCNAME\trpz-passthru.\n*.cdn.ads.example\tCNAME\t.\n") {
|
||||||
|
t.Errorf("rules zone:\n%s", z)
|
||||||
|
}
|
||||||
|
if strings.Index(z, "ads.example\t") > strings.Index(z, "*.ads.example\t") {
|
||||||
|
t.Errorf("a name should come before its wildcard:\n%s", z)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMatchFeed(t *testing.T) {
|
||||||
|
l, _ := Parse(strings.NewReader("||tracker.example^\nexact.example\n||deep.a.example^\n"), Limits{})
|
||||||
|
Compact(l)
|
||||||
|
path := filepath.Join(t.TempDir(), "list.rpz")
|
||||||
|
f, _ := os.Create(path)
|
||||||
|
FeedZone(f, "t", 1, l)
|
||||||
|
f.Close()
|
||||||
|
for name, want := range map[string]string{
|
||||||
|
"tracker.example": "tracker.example",
|
||||||
|
"a.b.tracker.example": "*.tracker.example",
|
||||||
|
"exact.example": "exact.example",
|
||||||
|
"sub.exact.example": "",
|
||||||
|
"x.deep.a.example": "*.deep.a.example",
|
||||||
|
"a.example": "",
|
||||||
|
"example": "",
|
||||||
|
} {
|
||||||
|
owner, ok, err := MatchFeed(path, name)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
owner = ""
|
||||||
|
}
|
||||||
|
if owner != want {
|
||||||
|
t.Errorf("%s: %q, want %q", name, owner, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNames(t *testing.T) {
|
||||||
|
if ListZone("hagezi") != "hagezi.blocklist.jarl.invalid" || ListName("hagezi.blocklist.jarl.invalid.") != "hagezi" || ListName(RulesZone) != "" || ListName("x.y.blocklist.jarl.invalid") != "" {
|
||||||
|
t.Error("zone names")
|
||||||
|
}
|
||||||
|
if n, err := NormalizeName("Ads.Example.COM."); err != nil || n != "ads.example.com" {
|
||||||
|
t.Errorf("NormalizeName = %q, %v", n, err)
|
||||||
|
}
|
||||||
|
for _, bad := range []string{"com", "1.2.3.4", "a b.example", "*.example.com", ""} {
|
||||||
|
if _, err := NormalizeName(bad); err == nil {
|
||||||
|
t.Errorf("%q accepted", bad)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
package blocking
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"math"
|
||||||
|
"net/netip"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Client steps (docs/blocking.md): named checks policy zones in order and
|
||||||
|
// the first match decides, so a "gate" zone that lets some clients through
|
||||||
|
// (rpz-passthru, matched on the client's address) keeps them from every
|
||||||
|
// zone after it. Jarl puts a gate in front of the lists of each step,
|
||||||
|
// letting through the clients whose step is below it.
|
||||||
|
|
||||||
|
// GateSuffix follows a gate's name: off.gate.jarl.invalid,
|
||||||
|
// strict.gate.jarl.invalid.
|
||||||
|
const GateSuffix = ".gate.jarl.invalid"
|
||||||
|
|
||||||
|
// v4Mapped holds the IPv4-mapped IPv6 addresses.
|
||||||
|
var v4Mapped = netip.MustParsePrefix("::ffff:0:0/96")
|
||||||
|
|
||||||
|
// GateZone returns the policy zone name of the gate in front of a step's
|
||||||
|
// lists ("off" for the gate in front of the normal lists).
|
||||||
|
func GateZone(step string) string { return step + GateSuffix }
|
||||||
|
|
||||||
|
// Assignment gives the addresses in Prefix a step rank (-1 off, 0 normal,
|
||||||
|
// 1, 2, ... the steps above it).
|
||||||
|
type Assignment struct {
|
||||||
|
Prefix netip.Prefix
|
||||||
|
Rank int
|
||||||
|
}
|
||||||
|
|
||||||
|
// PassBelow returns the fewest prefixes that cover exactly the addresses whose
|
||||||
|
// rank is below k. An address has the rank of the most specific
|
||||||
|
// assignment containing it, and rank 0 (normal) when none does. When two
|
||||||
|
// assignments have the same prefix, the higher rank counts (validation
|
||||||
|
// refuses that anyway).
|
||||||
|
func PassBelow(assigns []Assignment, k int) []netip.Prefix {
|
||||||
|
var out []netip.Prefix
|
||||||
|
for _, root := range []netip.Prefix{netip.MustParsePrefix("0.0.0.0/0"), netip.MustParsePrefix("::/0")} {
|
||||||
|
var mine []Assignment
|
||||||
|
if !root.Addr().Is4() {
|
||||||
|
// named matches IPv4 clients as IPv4-mapped IPv6 addresses:
|
||||||
|
// keep IPv6 prefixes clear of them, so only the IPv4 ones
|
||||||
|
// decide for IPv4 clients.
|
||||||
|
mine = append(mine, Assignment{v4Mapped, math.MaxInt})
|
||||||
|
}
|
||||||
|
rank := 0
|
||||||
|
for _, a := range assigns {
|
||||||
|
p := a.Prefix.Masked()
|
||||||
|
if p.Addr().Is4() != root.Addr().Is4() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if p.Bits() == 0 {
|
||||||
|
rank = max(rank, a.Rank)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
mine = append(mine, Assignment{p, a.Rank})
|
||||||
|
}
|
||||||
|
for _, p := range cover(root, rank, mine, k) {
|
||||||
|
if p.Bits() == 0 {
|
||||||
|
// A trigger needs at least one bit: use both halves.
|
||||||
|
lo, hi := halves(p)
|
||||||
|
out = append(out, lo, hi)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// cover returns the prefixes inside p whose addresses have a rank below k.
|
||||||
|
// rank is p's own; inside are the assignments strictly inside p.
|
||||||
|
func cover(p netip.Prefix, rank int, inside []Assignment, k int) []netip.Prefix {
|
||||||
|
if len(inside) == 0 {
|
||||||
|
if rank < k {
|
||||||
|
return []netip.Prefix{p}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var out []netip.Prefix
|
||||||
|
lo, hi := halves(p)
|
||||||
|
full := 0
|
||||||
|
for _, h := range []netip.Prefix{lo, hi} {
|
||||||
|
var sub []Assignment
|
||||||
|
for _, a := range inside {
|
||||||
|
if a.Prefix.Bits() > h.Bits() && h.Contains(a.Prefix.Addr()) {
|
||||||
|
sub = append(sub, a)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
c := cover(h, ownRank(inside, h, rank), sub, k)
|
||||||
|
if len(c) == 1 && c[0] == h {
|
||||||
|
full++
|
||||||
|
}
|
||||||
|
out = append(out, c...)
|
||||||
|
}
|
||||||
|
if full == 2 {
|
||||||
|
return []netip.Prefix{p}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// ownRank is the rank of h: its own assignments' (the highest), or the
|
||||||
|
// inherited one without any.
|
||||||
|
func ownRank(inside []Assignment, h netip.Prefix, inherited int) int {
|
||||||
|
r, found := 0, false
|
||||||
|
for _, a := range inside {
|
||||||
|
if a.Prefix == h {
|
||||||
|
if !found || a.Rank > r {
|
||||||
|
r = a.Rank
|
||||||
|
}
|
||||||
|
found = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
return inherited
|
||||||
|
}
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
// halves splits a prefix into its two halves.
|
||||||
|
func halves(p netip.Prefix) (netip.Prefix, netip.Prefix) {
|
||||||
|
bits := p.Bits() + 1
|
||||||
|
lo := netip.PrefixFrom(p.Addr(), bits).Masked()
|
||||||
|
b := lo.Addr().AsSlice()
|
||||||
|
i := (bits - 1) / 8
|
||||||
|
b[i] |= 0x80 >> ((bits - 1) % 8)
|
||||||
|
a, _ := netip.AddrFromSlice(b)
|
||||||
|
return lo, netip.PrefixFrom(a, bits)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClientIPOwner returns the policy zone owner name that matches clients
|
||||||
|
// in p (an rpz-client-ip trigger): 32.37.0.10.10.rpz-client-ip for
|
||||||
|
// 10.10.0.37/32, 64.zz.3d7f.8013.2a02.rpz-client-ip for 2a02:8013:3d7f::/64.
|
||||||
|
func ClientIPOwner(p netip.Prefix) string {
|
||||||
|
p = p.Masked()
|
||||||
|
a := p.Addr()
|
||||||
|
var labels []string
|
||||||
|
if a.Is4() {
|
||||||
|
b := a.As4()
|
||||||
|
labels = []string{fmt.Sprint(b[3]), fmt.Sprint(b[2]), fmt.Sprint(b[1]), fmt.Sprint(b[0])}
|
||||||
|
} else {
|
||||||
|
// RFC 5952 text, with "zz" for the "::" and the words reversed.
|
||||||
|
s := a.String()
|
||||||
|
var words []string
|
||||||
|
if left, right, ok := strings.Cut(s, "::"); ok {
|
||||||
|
if left != "" {
|
||||||
|
words = append(words, strings.Split(left, ":")...)
|
||||||
|
}
|
||||||
|
words = append(words, "zz")
|
||||||
|
if right != "" {
|
||||||
|
words = append(words, strings.Split(right, ":")...)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
words = strings.Split(s, ":")
|
||||||
|
}
|
||||||
|
for i := len(words) - 1; i >= 0; i-- {
|
||||||
|
labels = append(labels, words[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fmt.Sprint(p.Bits()) + "." + strings.Join(labels, ".") + ".rpz-client-ip"
|
||||||
|
}
|
||||||
|
|
||||||
|
// GateZoneFile renders a gate: every client in prefixes passes.
|
||||||
|
func GateZoneFile(title string, prefixes []netip.Prefix) string {
|
||||||
|
owners := make([]string, 0, len(prefixes))
|
||||||
|
for _, p := range prefixes {
|
||||||
|
owners = append(owners, ClientIPOwner(p))
|
||||||
|
}
|
||||||
|
sort.Strings(owners)
|
||||||
|
var sb strings.Builder
|
||||||
|
sb.WriteString(header(title, 1))
|
||||||
|
for _, o := range owners {
|
||||||
|
sb.WriteString(o + "\tCNAME\trpz-passthru.\n")
|
||||||
|
}
|
||||||
|
return sb.String()
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
package blocking
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/netip"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func pfx(s string) netip.Prefix { return netip.MustParsePrefix(s) }
|
||||||
|
|
||||||
|
// rankOf is the rank PassBelow's model gives an address.
|
||||||
|
func rankOf(assigns []Assignment, a netip.Addr) int {
|
||||||
|
best, bits := 0, -1
|
||||||
|
for _, x := range assigns {
|
||||||
|
if x.Prefix.Contains(a) && x.Prefix.Bits() >= bits {
|
||||||
|
if x.Prefix.Bits() > bits || x.Rank > best {
|
||||||
|
best = x.Rank
|
||||||
|
}
|
||||||
|
bits = x.Prefix.Bits()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return best
|
||||||
|
}
|
||||||
|
|
||||||
|
func covered(ps []netip.Prefix, a netip.Addr) bool {
|
||||||
|
for _, p := range ps {
|
||||||
|
if p.Contains(a) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPassBelow(t *testing.T) {
|
||||||
|
assigns := []Assignment{
|
||||||
|
{pfx("10.10.0.20/32"), -1}, // work laptop: off
|
||||||
|
{pfx("10.10.0.0/24"), 2}, // the kids' network: very strict
|
||||||
|
{pfx("10.10.0.50/32"), 1}, // except one tablet: strict
|
||||||
|
{pfx("2a02:db8::/64"), 1}, // an IPv6 network: strict
|
||||||
|
{pfx("2a02:db8::99/128"), 0}, // one device in it: normal
|
||||||
|
}
|
||||||
|
probe := []string{"10.10.0.20", "10.10.0.21", "10.10.0.50", "10.10.0.51", "10.10.1.1", "192.168.1.1", "0.0.0.1", "255.255.255.255",
|
||||||
|
"2a02:db8::1", "2a02:db8::99", "2a02:db8:0:1::1", "::1", "ffff::1"}
|
||||||
|
for k := 0; k <= 3; k++ {
|
||||||
|
gate := PassBelow(assigns, k)
|
||||||
|
for _, s := range probe {
|
||||||
|
a := netip.MustParseAddr(s)
|
||||||
|
if want := rankOf(assigns, a) < k; covered(gate, a) != want {
|
||||||
|
t.Errorf("k=%d %s: passes=%v, want %v (rank %d)", k, s, !want, want, rankOf(assigns, a))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, p := range gate {
|
||||||
|
if p.Bits() == 0 {
|
||||||
|
t.Errorf("k=%d: a /0 trigger", k)
|
||||||
|
}
|
||||||
|
// named matches IPv4 clients as ::ffff:a.b.c.d: no IPv6
|
||||||
|
// prefix may reach them.
|
||||||
|
if p.Addr().Is6() && p.Overlaps(v4Mapped) {
|
||||||
|
t.Errorf("k=%d: %s covers IPv4-mapped addresses", k, p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Off clients only, before the normal lists.
|
||||||
|
if got := PassBelow(assigns, 0); !slices.Equal(got, []netip.Prefix{pfx("10.10.0.20/32")}) {
|
||||||
|
t.Errorf("off gate = %v", got)
|
||||||
|
}
|
||||||
|
// Nobody above normal: everyone passes the gate of step 1: two IPv4
|
||||||
|
// halves, and IPv6 around the IPv4-mapped /96 (96 prefixes).
|
||||||
|
if got := PassBelow(nil, 1); len(got) != 2+96 {
|
||||||
|
t.Errorf("empty step-1 gate = %v", got)
|
||||||
|
}
|
||||||
|
// The complement of one IPv4 address is 32 prefixes (plus IPv6's 96).
|
||||||
|
if got := PassBelow([]Assignment{{pfx("10.10.0.50/32"), 1}}, 1); len(got) != 32+96 {
|
||||||
|
t.Errorf("complement of one address: %d prefixes", len(got))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClientIPOwner(t *testing.T) {
|
||||||
|
for p, want := range map[string]string{
|
||||||
|
"10.10.0.37/32": "32.37.0.10.10.rpz-client-ip",
|
||||||
|
"10.10.0.0/24": "24.0.0.10.10.rpz-client-ip",
|
||||||
|
"2a02:db8::/64": "64.zz.db8.2a02.rpz-client-ip",
|
||||||
|
"2a02:db8::1/128": "128.1.zz.db8.2a02.rpz-client-ip",
|
||||||
|
"::/1": "1.zz.rpz-client-ip",
|
||||||
|
"2001:db8:1:2:3:4:5:6/128": "128.6.5.4.3.2.1.db8.2001.rpz-client-ip",
|
||||||
|
} {
|
||||||
|
if got := ClientIPOwner(pfx(p)); got != want {
|
||||||
|
t.Errorf("%s: %s, want %s", p, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
z := GateZoneFile("t", []netip.Prefix{pfx("10.10.0.20/32")})
|
||||||
|
if !strings.Contains(z, "32.20.0.10.10.rpz-client-ip\tCNAME\trpz-passthru.\n") {
|
||||||
|
t.Errorf("gate zone:\n%s", z)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,198 @@
|
|||||||
|
// Package blocking turns blocklists and block/allow rules into BIND
|
||||||
|
// response policy zones (RPZ), and answers which rule or list matches a
|
||||||
|
// name.
|
||||||
|
//
|
||||||
|
// A policy zone holds one rule per owner name: "ads.example CNAME ."
|
||||||
|
// answers NXDOMAIN for ads.example, "*.ads.example CNAME ." for every name
|
||||||
|
// below it, and "CNAME rpz-passthru." answers normally (an exception).
|
||||||
|
// named looks names up in a policy zone like in any zone, so wildcards
|
||||||
|
// follow DNS rules: a wildcard doesn't apply below a name that exists in
|
||||||
|
// the zone. The writers here add the rules that keep "the most specific
|
||||||
|
// rule wins" true despite that (see RulesZone).
|
||||||
|
package blocking
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/netip"
|
||||||
|
"strings"
|
||||||
|
"unicode/utf8"
|
||||||
|
|
||||||
|
"jarl/internal/dnsname"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Flags say what a list entry covers.
|
||||||
|
const (
|
||||||
|
Exact uint8 = 1 << iota // the name itself
|
||||||
|
Below // every name below it
|
||||||
|
)
|
||||||
|
|
||||||
|
// List is a parsed blocklist.
|
||||||
|
type List struct {
|
||||||
|
// Domains maps names (lowercase, without the final dot) to flags.
|
||||||
|
Domains map[string]uint8
|
||||||
|
// Lines is the number of lines with an entry (not comments or blank
|
||||||
|
// lines); Skipped how many of them were not usable.
|
||||||
|
Lines, Skipped int
|
||||||
|
}
|
||||||
|
|
||||||
|
// Limits bound what Parse accepts.
|
||||||
|
type Limits struct {
|
||||||
|
MaxEntries int // distinct domains (0: no limit)
|
||||||
|
MaxNameLen int // longest name in characters (0: 253)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ErrTooManyEntries is returned when a list has more than MaxEntries
|
||||||
|
// domains.
|
||||||
|
var ErrTooManyEntries = errors.New("too many entries")
|
||||||
|
|
||||||
|
// Parse reads a blocklist. Each line is one of:
|
||||||
|
//
|
||||||
|
// ||ads.example^ Adblock style: the name and everything below it
|
||||||
|
// *.ads.example the name and everything below it
|
||||||
|
// 0.0.0.0 ads.example hosts file (any address; several names allowed)
|
||||||
|
// ads.example just this name
|
||||||
|
//
|
||||||
|
// Comments (#, !), Adblock headers ([Adblock Plus]) and blank lines are
|
||||||
|
// ignored. Lines that can't be used for DNS blocking (exceptions,
|
||||||
|
// cosmetic and URL rules, addresses, single-label names, invalid names)
|
||||||
|
// are counted as skipped.
|
||||||
|
func Parse(r io.Reader, lim Limits) (*List, error) {
|
||||||
|
maxLen := lim.MaxNameLen
|
||||||
|
if maxLen <= 0 || maxLen > 253 {
|
||||||
|
maxLen = 253
|
||||||
|
}
|
||||||
|
l := &List{Domains: map[string]uint8{}}
|
||||||
|
full := false // a new name was refused because of MaxEntries
|
||||||
|
add := func(name string, flags uint8) bool {
|
||||||
|
name, ok := normalize(name, maxLen)
|
||||||
|
if !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if _, seen := l.Domains[name]; !seen && lim.MaxEntries > 0 && len(l.Domains) >= lim.MaxEntries {
|
||||||
|
full = true
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
l.Domains[name] |= flags
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
sc := bufio.NewScanner(r)
|
||||||
|
sc.Buffer(make([]byte, 64*1024), 1024*1024)
|
||||||
|
first := true
|
||||||
|
for sc.Scan() {
|
||||||
|
line := sc.Text()
|
||||||
|
if first {
|
||||||
|
line = strings.TrimPrefix(line, "\uFEFF")
|
||||||
|
first = false
|
||||||
|
}
|
||||||
|
line = strings.TrimSpace(line)
|
||||||
|
if line == "" || line[0] == '#' || line[0] == '!' || line[0] == '[' {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
l.Lines++
|
||||||
|
ok := false
|
||||||
|
switch {
|
||||||
|
case strings.HasPrefix(line, "||"):
|
||||||
|
ok = add(adblockDomain(line[2:]), Exact|Below)
|
||||||
|
case strings.ContainsAny(line, "|/$^@") || strings.Contains(line, "##"):
|
||||||
|
// Exceptions (@@), URL and cosmetic rules: not for DNS.
|
||||||
|
default:
|
||||||
|
if i := strings.Index(line, "#"); i >= 0 {
|
||||||
|
line = strings.TrimSpace(line[:i])
|
||||||
|
}
|
||||||
|
fields := strings.Fields(line)
|
||||||
|
switch {
|
||||||
|
case len(fields) == 1 && strings.HasPrefix(fields[0], "*."):
|
||||||
|
ok = add(fields[0][2:], Exact|Below)
|
||||||
|
case len(fields) == 1:
|
||||||
|
ok = add(fields[0], Exact)
|
||||||
|
case len(fields) > 1 && isAddr(fields[0]):
|
||||||
|
for _, f := range fields[1:] {
|
||||||
|
if add(f, Exact) {
|
||||||
|
ok = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if full {
|
||||||
|
return l, fmt.Errorf("%w: the list has more than %d domains", ErrTooManyEntries, lim.MaxEntries)
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
l.Skipped++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := sc.Err(); err != nil {
|
||||||
|
return l, err
|
||||||
|
}
|
||||||
|
return l, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// adblockDomain returns the domain of an Adblock rule's body
|
||||||
|
// ("ads.example^" or "ads.example^$important"), or "" when the rule
|
||||||
|
// does more than block a domain (other modifiers, paths, wildcards).
|
||||||
|
func adblockDomain(s string) string {
|
||||||
|
d, rest, _ := strings.Cut(s, "^")
|
||||||
|
if rest != "" && rest != "$important" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if strings.ContainsAny(d, "*/:|$") {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
|
||||||
|
func isAddr(s string) bool {
|
||||||
|
_, err := netip.ParseAddr(s)
|
||||||
|
return err == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// localNames are hosts-file names that aren't blocklist entries.
|
||||||
|
var localNames = map[string]bool{"localhost.localdomain": true, "ip6-localhost": true, "ip6-loopback": true}
|
||||||
|
|
||||||
|
// normalize lowercases a name, removes the final dot, converts
|
||||||
|
// international names to their ASCII form, and checks it is usable: at
|
||||||
|
// least two labels of letters, digits, hyphens and underscores, and not an
|
||||||
|
// address.
|
||||||
|
func normalize(name string, maxLen int) (string, bool) {
|
||||||
|
name = strings.TrimSuffix(strings.ToLower(strings.TrimSpace(name)), ".")
|
||||||
|
if name == "" || localNames[name] || isAddr(name) {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
for i := 0; i < len(name); i++ {
|
||||||
|
if name[i] >= utf8.RuneSelf {
|
||||||
|
a, err := dnsname.ToASCII(name)
|
||||||
|
if err != nil {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
name = strings.TrimSuffix(strings.ToLower(a), ".")
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(name) > maxLen || !strings.Contains(name, ".") {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
for _, label := range strings.Split(name, ".") {
|
||||||
|
if label == "" || len(label) > 63 {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
for i := 0; i < len(label); i++ {
|
||||||
|
c := label[i]
|
||||||
|
if !(c >= 'a' && c <= 'z' || c >= '0' && c <= '9' || c == '-' || c == '_') {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return name, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// NormalizeName checks and normalizes a domain the way list entries are
|
||||||
|
// (for rules entered by hand). The name must have at least two labels.
|
||||||
|
func NormalizeName(name string) (string, error) {
|
||||||
|
n, ok := normalize(name, 253-len(RulesZone)-2)
|
||||||
|
if !ok {
|
||||||
|
return "", fmt.Errorf("%q is not a domain name that can be blocked (it needs at least two labels of letters, digits, hyphens or underscores, like ads.example.com)", name)
|
||||||
|
}
|
||||||
|
return n, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,316 @@
|
|||||||
|
package blocking
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Policy zone names. They are under .invalid (RFC 6761), so they can
|
||||||
|
// never clash with a real zone, and named only uses them internally.
|
||||||
|
const (
|
||||||
|
// RulesZone holds the rules entered in Jarl (one copy per view).
|
||||||
|
RulesZone = "rules.jarl.invalid"
|
||||||
|
// listSuffix follows a list's name: hagezi.blocklist.jarl.invalid.
|
||||||
|
listSuffix = ".blocklist.jarl.invalid"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ListZone returns the policy zone name of a blocklist.
|
||||||
|
func ListZone(name string) string { return name + listSuffix }
|
||||||
|
|
||||||
|
// ListName returns the blocklist name of a policy zone ("" for the rules
|
||||||
|
// zone and zones that aren't Jarl's).
|
||||||
|
func ListName(zone string) string {
|
||||||
|
zone = strings.TrimSuffix(strings.ToLower(zone), ".")
|
||||||
|
if n, ok := strings.CutSuffix(zone, listSuffix); ok && !strings.Contains(n, ".") {
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// Match says what blocking does with a name in one view.
|
||||||
|
type Match struct {
|
||||||
|
View string `json:"view,omitempty"`
|
||||||
|
Result string `json:"result"` // blocked, allowed or none
|
||||||
|
Entry string `json:"entry,omitempty"` // the matching entry: the name, or *.parent
|
||||||
|
RuleID int64 `json:"rule_id,omitempty"`
|
||||||
|
List string `json:"list,omitempty"` // the blocklist, when a list matched
|
||||||
|
// Step is the list's step when it is above normal: only clients at
|
||||||
|
// that step or above get this answer (others: none).
|
||||||
|
Step string `json:"step,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Actions of rules.
|
||||||
|
const (
|
||||||
|
ActionBlock = "block"
|
||||||
|
ActionAllow = "allow"
|
||||||
|
)
|
||||||
|
|
||||||
|
// cname is the CNAME target of each action: "." answers NXDOMAIN,
|
||||||
|
// rpz-passthru. answers as if there were no rule.
|
||||||
|
var cname = map[string]string{ActionBlock: ".", ActionAllow: "rpz-passthru."}
|
||||||
|
|
||||||
|
// header starts a policy zone file. There is no $ORIGIN: names are
|
||||||
|
// relative to the zone, so a list can be renamed without rewriting its
|
||||||
|
// file. The short TTLs make a change reach clients' caches quickly.
|
||||||
|
func header(title string, serial uint32) string {
|
||||||
|
return fmt.Sprintf("; %s\n; Generated by Jarl. DO NOT EDIT.\n$TTL 60\n@\tSOA\tlocalhost. hostmaster.localhost. %d 3600 600 86400 60\n\tNS\tlocalhost.\n", title, serial)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Placeholder is the content of a list's zone file before the list has
|
||||||
|
// been downloaded: a valid, empty policy zone.
|
||||||
|
func Placeholder() string {
|
||||||
|
return header("Blocklist not downloaded yet.", 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rule is a block or allow rule entered by hand.
|
||||||
|
type Rule struct {
|
||||||
|
Name string // lowercase, without the final dot
|
||||||
|
Action string // ActionBlock or ActionAllow
|
||||||
|
Exact bool // only the name itself, not the names below it
|
||||||
|
}
|
||||||
|
|
||||||
|
// RuleEntries returns the policy zone owners for rules (relative names,
|
||||||
|
// "*." for wildcards) and their actions, with the entries that make the
|
||||||
|
// most specific rule win added: named won't apply a wildcard below a name
|
||||||
|
// that exists in the zone, so every name below a wildcard that exists
|
||||||
|
// (because a more specific rule mentions it) gets the wildcard's action
|
||||||
|
// for itself and for the names below it, unless a rule says otherwise.
|
||||||
|
// Callers make sure no two rules have the same name and scope.
|
||||||
|
func RuleEntries(rules []Rule) map[string]string {
|
||||||
|
e := map[string]string{}
|
||||||
|
for _, r := range rules {
|
||||||
|
e[r.Name] = r.Action
|
||||||
|
if !r.Exact {
|
||||||
|
e["*."+r.Name] = r.Action
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Every name that exists in the zone: owners and their ancestors.
|
||||||
|
nodes := map[string]bool{}
|
||||||
|
for owner := range e {
|
||||||
|
n := strings.TrimPrefix(owner, "*.")
|
||||||
|
for {
|
||||||
|
nodes[n] = true
|
||||||
|
i := strings.IndexByte(n, '.')
|
||||||
|
if i < 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
n = n[i+1:]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ordered := make([]string, 0, len(nodes))
|
||||||
|
for n := range nodes {
|
||||||
|
ordered = append(ordered, n)
|
||||||
|
}
|
||||||
|
// Shallow names first, so a filled-in wildcard is inherited further
|
||||||
|
// down.
|
||||||
|
sort.Slice(ordered, func(i, j int) bool {
|
||||||
|
di, dj := strings.Count(ordered[i], "."), strings.Count(ordered[j], ".")
|
||||||
|
if di != dj {
|
||||||
|
return di < dj
|
||||||
|
}
|
||||||
|
return ordered[i] < ordered[j]
|
||||||
|
})
|
||||||
|
for _, n := range ordered {
|
||||||
|
inherited := ""
|
||||||
|
for a := n; ; {
|
||||||
|
i := strings.IndexByte(a, '.')
|
||||||
|
if i < 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
a = a[i+1:]
|
||||||
|
if act, ok := e["*."+a]; ok {
|
||||||
|
inherited = act
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if inherited == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, ok := e[n]; !ok {
|
||||||
|
e[n] = inherited
|
||||||
|
}
|
||||||
|
if _, ok := e["*."+n]; !ok {
|
||||||
|
e["*."+n] = inherited
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
|
||||||
|
// PausedZoneFile renders a rules zone that lets every name through, for
|
||||||
|
// while blocking is paused: the rules zone comes first, and its match
|
||||||
|
// stops named from looking at the lists.
|
||||||
|
func PausedZoneFile(title string) string {
|
||||||
|
return header(title+" (blocking is paused)", 1) + "*\tCNAME\trpz-passthru.\n"
|
||||||
|
}
|
||||||
|
|
||||||
|
// RulesZoneFile renders the rules zone of a view.
|
||||||
|
func RulesZoneFile(title string, rules []Rule) string {
|
||||||
|
e := RuleEntries(rules)
|
||||||
|
owners := make([]string, 0, len(e))
|
||||||
|
for o := range e {
|
||||||
|
owners = append(owners, o)
|
||||||
|
}
|
||||||
|
sort.Slice(owners, func(i, j int) bool { return ownerLess(owners[i], owners[j]) })
|
||||||
|
var sb strings.Builder
|
||||||
|
sb.WriteString(header(title, 1))
|
||||||
|
for _, o := range owners {
|
||||||
|
fmt.Fprintf(&sb, "%s\tCNAME\t%s\n", o, cname[e[o]])
|
||||||
|
}
|
||||||
|
return sb.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ownerLess sorts names by their labels from the right (example,
|
||||||
|
// ads.example, *.ads.example, cdn.ads.example), so related rules are next
|
||||||
|
// to each other.
|
||||||
|
func ownerLess(a, b string) bool {
|
||||||
|
ka, kb := reverseLabels(a), reverseLabels(b)
|
||||||
|
return ka < kb
|
||||||
|
}
|
||||||
|
|
||||||
|
func reverseLabels(n string) string {
|
||||||
|
labels := strings.Split(n, ".")
|
||||||
|
for i, j := 0, len(labels)-1; i < j; i, j = i+1, j-1 {
|
||||||
|
labels[i], labels[j] = labels[j], labels[i]
|
||||||
|
}
|
||||||
|
// A wildcard sorts right after the name it belongs to.
|
||||||
|
return strings.ReplaceAll(strings.Join(labels, "\x00"), "*", "\x01")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MatchRules finds the rule entry named would apply to name (lowercase,
|
||||||
|
// without the final dot) in a rules zone with entries e: the name itself,
|
||||||
|
// or else the wildcard of its closest existing ancestor. ok is false when
|
||||||
|
// no entry applies.
|
||||||
|
func MatchRules(e map[string]string, name string) (owner, action string, ok bool) {
|
||||||
|
if act, found := e[name]; found {
|
||||||
|
return name, act, true
|
||||||
|
}
|
||||||
|
nodes := map[string]bool{}
|
||||||
|
for o := range e {
|
||||||
|
n := strings.TrimPrefix(o, "*.")
|
||||||
|
for {
|
||||||
|
nodes[n] = true
|
||||||
|
i := strings.IndexByte(n, '.')
|
||||||
|
if i < 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
n = n[i+1:]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if nodes[name] {
|
||||||
|
return "", "", false // the name exists without a rule of its own
|
||||||
|
}
|
||||||
|
for a := name; ; {
|
||||||
|
i := strings.IndexByte(a, '.')
|
||||||
|
if i < 0 {
|
||||||
|
return "", "", false
|
||||||
|
}
|
||||||
|
a = a[i+1:]
|
||||||
|
if nodes[a] {
|
||||||
|
if act, found := e["*."+a]; found {
|
||||||
|
return "*." + a, act, true
|
||||||
|
}
|
||||||
|
return "", "", false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compact drops entries another entry already covers (a name below a
|
||||||
|
// name whose subdomains are blocked). That keeps the zone small, and it
|
||||||
|
// matters for correctness: no name exists below a wildcard, so every
|
||||||
|
// wildcard applies to all names below it.
|
||||||
|
func Compact(l *List) {
|
||||||
|
for n := range l.Domains {
|
||||||
|
for a := n; ; {
|
||||||
|
i := strings.IndexByte(a, '.')
|
||||||
|
if i < 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
a = a[i+1:]
|
||||||
|
if l.Domains[a]&Below != 0 {
|
||||||
|
delete(l.Domains, n)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// FeedZone writes a list's policy zone to w and returns the number of
|
||||||
|
// records and a hash of them (the serial isn't part of the hash, so it
|
||||||
|
// tells whether the content changed). l must be compacted.
|
||||||
|
func FeedZone(w io.Writer, title string, serial uint32, l *List) (records int, hash string, err error) {
|
||||||
|
names := make([]string, 0, len(l.Domains))
|
||||||
|
for n := range l.Domains {
|
||||||
|
names = append(names, n)
|
||||||
|
}
|
||||||
|
sort.Strings(names)
|
||||||
|
bw := bufio.NewWriterSize(w, 256*1024)
|
||||||
|
h := sha256.New()
|
||||||
|
if _, err := bw.WriteString(header(title, serial)); err != nil {
|
||||||
|
return 0, "", err
|
||||||
|
}
|
||||||
|
write := func(owner string) error {
|
||||||
|
line := owner + "\tCNAME\t.\n"
|
||||||
|
h.Write([]byte(line))
|
||||||
|
records++
|
||||||
|
_, err := bw.WriteString(line)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, n := range names {
|
||||||
|
f := l.Domains[n]
|
||||||
|
if f&Exact != 0 {
|
||||||
|
if err := write(n); err != nil {
|
||||||
|
return 0, "", err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if f&Below != 0 {
|
||||||
|
if err := write("*." + n); err != nil {
|
||||||
|
return 0, "", err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := bw.Flush(); err != nil {
|
||||||
|
return 0, "", err
|
||||||
|
}
|
||||||
|
return records, hex.EncodeToString(h.Sum(nil)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// MatchFeed reports which entry of a list's zone file blocks name: the
|
||||||
|
// name itself, or a wildcard of one of its ancestors (the closest).
|
||||||
|
// Lists are compacted, so no name exists below a wildcard and the
|
||||||
|
// closest wildcard ancestor is the one named uses.
|
||||||
|
func MatchFeed(path, name string) (owner string, ok bool, err error) {
|
||||||
|
want := map[string]int{name: 0}
|
||||||
|
depth := 1
|
||||||
|
for a := name; ; depth++ {
|
||||||
|
i := strings.IndexByte(a, '.')
|
||||||
|
if i < 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
a = a[i+1:]
|
||||||
|
want["*."+a] = depth
|
||||||
|
}
|
||||||
|
f, err := os.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
return "", false, err
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
best := -1
|
||||||
|
sc := bufio.NewScanner(f)
|
||||||
|
for sc.Scan() {
|
||||||
|
line := sc.Text()
|
||||||
|
o, _, found := strings.Cut(line, "\t")
|
||||||
|
if !found {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if d, hit := want[o]; hit && (best < 0 || d < best) {
|
||||||
|
best, owner = d, o
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return owner, best >= 0, sc.Err()
|
||||||
|
}
|
||||||
@@ -427,3 +427,87 @@ 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 "-":
|
||||||
|
// A range with spaces around the dash: 10.0.0.1 - 10.0.0.9.
|
||||||
|
if len(out) > 0 && i+1 < len(fields) {
|
||||||
|
out[len(out)-1] += " - " + fields[i+1]
|
||||||
|
i++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -30,6 +30,12 @@ type Client struct {
|
|||||||
// Changeset, if set, stages changes into this changeset instead of
|
// Changeset, if set, stages changes into this changeset instead of
|
||||||
// applying them.
|
// applying them.
|
||||||
Changeset int64
|
Changeset int64
|
||||||
|
// CheckChangeset, if set, runs once before Changeset is first used,
|
||||||
|
// and its error stops the request. The CLI checks a changeset taken
|
||||||
|
// from JARL_CHANGESET with it.
|
||||||
|
CheckChangeset func(ctx context.Context) error
|
||||||
|
csChecked bool
|
||||||
|
csErr error
|
||||||
DryRun bool
|
DryRun bool
|
||||||
Force bool
|
Force bool
|
||||||
// Preflight lets only requests through that can't change anything:
|
// Preflight lets only requests through that can't change anything:
|
||||||
@@ -243,6 +249,19 @@ func (c *Client) Get(ctx context.Context, path string, query url.Values, out any
|
|||||||
return c.Do(ctx, http.MethodGet, path, query, nil, out)
|
return c.Do(ctx, http.MethodGet, path, query, nil, out)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// UseChangeset returns the changeset to stage into (0 for none), once
|
||||||
|
// CheckChangeset has accepted it.
|
||||||
|
func (c *Client) UseChangeset(ctx context.Context) (int64, error) {
|
||||||
|
if c.Changeset == 0 {
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
if c.CheckChangeset != nil && !c.csChecked {
|
||||||
|
c.csChecked = true
|
||||||
|
c.csErr = c.CheckChangeset(ctx)
|
||||||
|
}
|
||||||
|
return c.Changeset, c.csErr
|
||||||
|
}
|
||||||
|
|
||||||
// Mutate sends a configuration change, honoring Changeset, DryRun and
|
// Mutate sends a configuration change, honoring Changeset, DryRun and
|
||||||
// Force.
|
// Force.
|
||||||
func (c *Client) Mutate(ctx context.Context, method, path string, query url.Values, body any) (*api.MutationResponse, error) {
|
func (c *Client) Mutate(ctx context.Context, method, path string, query url.Values, body any) (*api.MutationResponse, error) {
|
||||||
@@ -250,8 +269,12 @@ func (c *Client) Mutate(ctx context.Context, method, path string, query url.Valu
|
|||||||
for k, v := range query {
|
for k, v := range query {
|
||||||
q[k] = v
|
q[k] = v
|
||||||
}
|
}
|
||||||
if c.Changeset > 0 {
|
cs, err := c.UseChangeset(ctx)
|
||||||
q.Set("changeset", fmt.Sprint(c.Changeset))
|
if err != nil {
|
||||||
|
return &api.MutationResponse{}, err
|
||||||
|
}
|
||||||
|
if cs > 0 {
|
||||||
|
q.Set("changeset", fmt.Sprint(cs))
|
||||||
}
|
}
|
||||||
if c.DryRun {
|
if c.DryRun {
|
||||||
q.Set("dry_run", "1")
|
q.Set("dry_run", "1")
|
||||||
@@ -260,7 +283,7 @@ func (c *Client) Mutate(ctx context.Context, method, path string, query url.Valu
|
|||||||
q.Set("force", "1")
|
q.Set("force", "1")
|
||||||
}
|
}
|
||||||
var out api.MutationResponse
|
var out api.MutationResponse
|
||||||
err := c.Do(ctx, method, path, q, body, &out)
|
err = c.Do(ctx, method, path, q, body, &out)
|
||||||
return &out, err
|
return &out, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ package config
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
@@ -37,6 +38,77 @@ type Config struct {
|
|||||||
Stats Stats `yaml:"stats"`
|
Stats Stats `yaml:"stats"`
|
||||||
ACME ACME `yaml:"acme"`
|
ACME ACME `yaml:"acme"`
|
||||||
Porkbun Porkbun `yaml:"porkbun"`
|
Porkbun Porkbun `yaml:"porkbun"`
|
||||||
|
Blocking Blocking `yaml:"blocking"`
|
||||||
|
Metrics Metrics `yaml:"metrics"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Metrics configures the Prometheus endpoint /metrics (docs/metrics.md).
|
||||||
|
// A scrape is allowed from an address in Allow, or with Token as a bearer
|
||||||
|
// token; root and jarld's own user may also scrape on the local socket.
|
||||||
|
type Metrics struct {
|
||||||
|
Enabled bool `yaml:"enabled"`
|
||||||
|
// Listen are extra addresses that serve only /metrics, over plain
|
||||||
|
// HTTP. /metrics is always served on the main listeners too.
|
||||||
|
Listen []string `yaml:"listen"`
|
||||||
|
// Allow are the addresses or prefixes that may scrape without a token.
|
||||||
|
Allow []string `yaml:"allow"`
|
||||||
|
// Token, when set, lets any address that sends it as
|
||||||
|
// "Authorization: Bearer TOKEN" scrape.
|
||||||
|
Token string `yaml:"token"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Metrics) check() error {
|
||||||
|
if !m.Enabled {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
for _, a := range m.Listen {
|
||||||
|
if _, port, err := net.SplitHostPort(a); err != nil || port == "" {
|
||||||
|
return fmt.Errorf("listen %q must be ADDRESS:PORT, e.g. 0.0.0.0:9450", a)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, p := range m.Allow {
|
||||||
|
if _, err := ParsePrefix(p); err != nil {
|
||||||
|
return fmt.Errorf("allow: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if m.Token != "" && (len(m.Token) < 16 || strings.ContainsAny(m.Token, " \t\r\n")) {
|
||||||
|
return fmt.Errorf("token must be at least 16 characters, without spaces (e.g. from 'openssl rand -hex 24')")
|
||||||
|
}
|
||||||
|
if len(m.Allow) == 0 && m.Token == "" {
|
||||||
|
return fmt.Errorf("set allow, token or both, or nobody can scrape")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// AllowPrefixes returns the parsed allow list.
|
||||||
|
func (m *Metrics) AllowPrefixes() []netip.Prefix {
|
||||||
|
var out []netip.Prefix
|
||||||
|
for _, p := range m.Allow {
|
||||||
|
if pf, err := ParsePrefix(p); err == nil {
|
||||||
|
out = append(out, pf.Masked())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// Blocking configures blocklist downloads (docs/blocking.md). The lists
|
||||||
|
// and rules themselves are configuration in the database.
|
||||||
|
type Blocking struct {
|
||||||
|
// Refresh is how often each list is checked for changes; a list is
|
||||||
|
// only downloaded again when it changed.
|
||||||
|
Refresh Duration `yaml:"refresh"`
|
||||||
|
MaxDownloadMB int `yaml:"max_download_mb"`
|
||||||
|
MaxEntries int `yaml:"max_entries"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Blocking) check() error {
|
||||||
|
if b.Refresh < Duration(time.Hour) {
|
||||||
|
return fmt.Errorf("refresh must be at least 1h (list hosts ask not to be polled more often)")
|
||||||
|
}
|
||||||
|
if b.MaxDownloadMB < 1 || b.MaxEntries < 1 {
|
||||||
|
return fmt.Errorf("max_download_mb and max_entries must be at least 1")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Porkbun lets Jarl manage domains' name servers, glue records and DNSSEC
|
// Porkbun lets Jarl manage domains' name servers, glue records and DNSSEC
|
||||||
@@ -301,6 +373,8 @@ func Default() *Config {
|
|||||||
TopN: 100,
|
TopN: 100,
|
||||||
},
|
},
|
||||||
ACME: ACME{HTTPReq: HTTPReq{RequireHTTPS: true}},
|
ACME: ACME{HTTPReq: HTTPReq{RequireHTTPS: true}},
|
||||||
|
Blocking: Blocking{Refresh: Duration(24 * time.Hour), MaxDownloadMB: 64, MaxEntries: 1_000_000},
|
||||||
|
Metrics: Metrics{Allow: []string{"127.0.0.1", "::1"}},
|
||||||
Backup: Backup{Dir: "/var/backups/jarl", Retention: Duration(30 * 24 * time.Hour), KeepAtLeast: 7, WarnAfter: Duration(2 * 24 * time.Hour)},
|
Backup: Backup{Dir: "/var/backups/jarl", Retention: Duration(30 * 24 * time.Hour), KeepAtLeast: 7, WarnAfter: Duration(2 * 24 * time.Hour)},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -369,6 +443,12 @@ func (c *Config) check() error {
|
|||||||
if err := c.Porkbun.check(); err != nil {
|
if err := c.Porkbun.check(); err != nil {
|
||||||
return fmt.Errorf("porkbun: %w", err)
|
return fmt.Errorf("porkbun: %w", err)
|
||||||
}
|
}
|
||||||
|
if err := c.Blocking.check(); err != nil {
|
||||||
|
return fmt.Errorf("blocking: %w", err)
|
||||||
|
}
|
||||||
|
if err := c.Metrics.check(); err != nil {
|
||||||
|
return fmt.Errorf("metrics: %w", err)
|
||||||
|
}
|
||||||
switch c.Log.Format {
|
switch c.Log.Format {
|
||||||
case "text", "json":
|
case "text", "json":
|
||||||
default:
|
default:
|
||||||
@@ -600,4 +680,31 @@ porkbun:
|
|||||||
api_key: "" # pk1_...
|
api_key: "" # pk1_...
|
||||||
secret_api_key: "" # sk1_...
|
secret_api_key: "" # sk1_...
|
||||||
domains: [] # only these domains (default: any Jarl zone the key reaches)
|
domains: [] # only these domains (default: any Jarl zone the key reaches)
|
||||||
|
|
||||||
|
# Downloading blocklists (docs/blocking.md). The lists and rules are set up
|
||||||
|
# in the web UI or with 'jarl blocklist' and 'jarl block'; this only sets
|
||||||
|
# how jarld downloads them.
|
||||||
|
blocking:
|
||||||
|
refresh: 24h # check each list for changes this often (at least 1h)
|
||||||
|
max_download_mb: 64 # refuse larger downloads
|
||||||
|
max_entries: 1000000 # refuse lists with more domains
|
||||||
|
|
||||||
|
# Prometheus metrics at /metrics (docs/metrics.md): named's counters and
|
||||||
|
# Jarl's own (blocking, zones, views), plus health gauges to alert on
|
||||||
|
# (named up, validation problems, blocklist errors, backup age, DNSSEC
|
||||||
|
# steps, token expiry). Off by default. /metrics is served on the
|
||||||
|
# addresses in 'listen' at the top of this file; a scrape is allowed from
|
||||||
|
# an address in 'allow' below, or with the token. Query counters need
|
||||||
|
# stats.enabled.
|
||||||
|
metrics:
|
||||||
|
enabled: false
|
||||||
|
# Extra addresses that serve only /metrics, over plain HTTP, e.g.
|
||||||
|
# ["0.0.0.0:9450"] for a Prometheus server elsewhere on the network.
|
||||||
|
listen: []
|
||||||
|
# Addresses or prefixes that may scrape without a token.
|
||||||
|
allow: ["127.0.0.1", "::1"]
|
||||||
|
# A bearer token (at least 16 characters) that lets any address scrape:
|
||||||
|
# in Prometheus, 'authorization: { credentials: TOKEN }'. Keep this file
|
||||||
|
# readable by root and jarld only when you set it.
|
||||||
|
token: ""
|
||||||
`
|
`
|
||||||
@@ -87,3 +87,31 @@ func TestStatsConfig(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestMetricsConfig(t *testing.T) {
|
||||||
|
d := Default()
|
||||||
|
if d.Metrics.Enabled || len(d.Metrics.AllowPrefixes()) != 2 || d.Metrics.Token != "" {
|
||||||
|
t.Errorf("defaults = %+v", d.Metrics)
|
||||||
|
}
|
||||||
|
path := filepath.Join(t.TempDir(), "jarl.yaml")
|
||||||
|
for _, tc := range []struct {
|
||||||
|
yaml string
|
||||||
|
ok bool
|
||||||
|
}{
|
||||||
|
{"metrics: {enabled: true}", true},
|
||||||
|
{"metrics: {enabled: true, listen: [\"0.0.0.0:9450\", \"[::]:9450\"], allow: [10.0.0.5, 192.168.1.0/24]}", true},
|
||||||
|
{"metrics: {enabled: true, allow: [], token: 0123456789abcdef}", true},
|
||||||
|
{"metrics: {enabled: true, allow: []}", false}, // nobody could scrape
|
||||||
|
{"metrics: {enabled: true, token: short}", false},
|
||||||
|
{"metrics: {enabled: true, token: \"has space 0123456789\"}", false},
|
||||||
|
{"metrics: {enabled: true, listen: [\"9450\"]}", false},
|
||||||
|
{"metrics: {enabled: true, allow: [prometheus.lan]}", false},
|
||||||
|
{"metrics: {enabled: false, listen: [nonsense]}", true}, // only checked when enabled
|
||||||
|
} {
|
||||||
|
os.WriteFile(path, []byte(tc.yaml+"\n"), 0o644)
|
||||||
|
_, err := Load(path, false)
|
||||||
|
if (err == nil) != tc.ok {
|
||||||
|
t.Errorf("%s: err = %v, want ok=%v", tc.yaml, err, tc.ok)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,299 @@
|
|||||||
|
package engine
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"jarl/internal/blocking"
|
||||||
|
"jarl/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
func blockRule(name, action string, exact bool, views ...string) Request {
|
||||||
|
in := BlockRuleInput{Name: &name, Action: &action, Exact: &exact}
|
||||||
|
if len(views) > 0 {
|
||||||
|
in.Views = &views
|
||||||
|
}
|
||||||
|
return NewRequest(OpBlockRuleCreate, 0, in)
|
||||||
|
}
|
||||||
|
|
||||||
|
func blocklist(name, url string, views ...string) Request {
|
||||||
|
in := BlocklistInput{Name: &name, URL: &url}
|
||||||
|
if len(views) > 0 {
|
||||||
|
in.Views = &views
|
||||||
|
}
|
||||||
|
return NewRequest(OpBlocklistCreate, 0, in)
|
||||||
|
}
|
||||||
|
|
||||||
|
// listServer serves a blocklist with an ETag, and counts the downloads
|
||||||
|
// that sent content.
|
||||||
|
type listServer struct {
|
||||||
|
body atomic.Value
|
||||||
|
full atomic.Int32
|
||||||
|
calls atomic.Int32
|
||||||
|
}
|
||||||
|
|
||||||
|
func newListServer(t *testing.T, body string) (*listServer, *httptest.Server) {
|
||||||
|
ls := &listServer{}
|
||||||
|
ls.body.Store(body)
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ls.calls.Add(1)
|
||||||
|
b := ls.body.Load().(string)
|
||||||
|
etag := `"` + string(rune('a'+len(b)%26)) + "x" + string(rune('a'+len(b)/26%26)) + `"`
|
||||||
|
if r.Header.Get("If-None-Match") == etag {
|
||||||
|
w.WriteHeader(http.StatusNotModified)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("ETag", etag)
|
||||||
|
ls.full.Add(1)
|
||||||
|
w.Write([]byte(b))
|
||||||
|
}))
|
||||||
|
t.Cleanup(srv.Close)
|
||||||
|
return ls, srv
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBlockingRender(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(OpOptionCreate, 0, OptionInput{Scope: model.Ptr(model.ScopeView), ScopeID: &ext.ID, Name: model.Ptr("recursion"), Value: model.Ptr("no")}))
|
||||||
|
|
||||||
|
_, srv := newListServer(t, "||ads.example^\n")
|
||||||
|
res := env.submit(env.admin,
|
||||||
|
blocklist("ads", srv.URL+"/list.txt"),
|
||||||
|
blockRule("tracker.example", "block", false),
|
||||||
|
blockRule("cdn.tracker.example", "allow", true),
|
||||||
|
)
|
||||||
|
if res.Ops[1].Summary != "block tracker.example (and below)" || res.Ops[2].Summary != "allow cdn.tracker.example" {
|
||||||
|
t.Errorf("summaries: %q, %q", res.Ops[1].Summary, res.Ops[2].Summary)
|
||||||
|
}
|
||||||
|
conf, _ := os.ReadFile(filepath.Join(env.dir, "etc/named.conf"))
|
||||||
|
c := string(conf)
|
||||||
|
// Automatic views: only internal offers recursion.
|
||||||
|
internal := c[strings.Index(c, `view "internal"`):strings.Index(c, `view "external"`)]
|
||||||
|
for _, want := range []string{
|
||||||
|
"response-policy {\n\t\tzone \"rules.jarl.invalid\" min-update-interval 0 log no;\n\t\tzone \"ads.blocklist.jarl.invalid\" min-update-interval 0 log no;\n\t} qname-wait-recurse no break-dnssec yes;",
|
||||||
|
`zone "rules.jarl.invalid" {`,
|
||||||
|
"allow-query { none; };",
|
||||||
|
} {
|
||||||
|
if !strings.Contains(internal, want) {
|
||||||
|
t.Errorf("view internal lacks %q:\n%s", want, internal)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.Contains(c[strings.Index(c, `view "external"`):], "response-policy") {
|
||||||
|
t.Error("view external, without recursion, got blocking")
|
||||||
|
}
|
||||||
|
rules, err := os.ReadFile(filepath.Join(env.dir, "zones/internal/rules.jarl.invalid.rpz"))
|
||||||
|
if err != nil || !strings.Contains(string(rules), "cdn.tracker.example\tCNAME\trpz-passthru.\n*.cdn.tracker.example\tCNAME\t.\n") {
|
||||||
|
t.Errorf("rules zone: %v\n%s", err, rules)
|
||||||
|
}
|
||||||
|
// The list's file exists as an empty policy zone until downloaded.
|
||||||
|
b := env.state().BlocklistByName("ads")
|
||||||
|
listFile := filepath.Join(env.dir, "cache/blocklists/list-"+strconv.FormatInt(b.ID, 10)+".rpz")
|
||||||
|
if f, err := os.ReadFile(listFile); err != nil || !strings.Contains(string(f), "not downloaded yet") {
|
||||||
|
t.Errorf("placeholder: %v %s", err, f)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A view a list or rule names can't be deleted.
|
||||||
|
res = env.submit(env.admin, NewRequest(OpBlocklistUpdate, b.ID, BlocklistInput{Views: &[]string{"external"}}))
|
||||||
|
if len(res.Problems) != 1 || res.Problems[0].Code != "blocking_no_recursion" {
|
||||||
|
t.Errorf("a list in a view without recursion: %+v", res.Problems)
|
||||||
|
}
|
||||||
|
if e := env.submitErr(env.admin, NewRequest(OpViewDelete, ext.ID, DeleteInput{Cascade: true})); e.Kind != KindConflict || !strings.Contains(e.Message, "blocklist ads") {
|
||||||
|
t.Errorf("deleting a used view: %+v", e)
|
||||||
|
}
|
||||||
|
st = env.state()
|
||||||
|
// A second rule for the same name and view is refused.
|
||||||
|
if e := env.submitErr(env.admin, blockRule("tracker.example", "allow", true)); e.Kind != KindValidation || !strings.Contains(e.Message, "two rules for tracker.example") {
|
||||||
|
t.Errorf("duplicate rule: %+v", e)
|
||||||
|
}
|
||||||
|
// So is a response-policy option where Jarl writes one.
|
||||||
|
in := st.ViewByName("internal")
|
||||||
|
if e := env.submitErr(env.admin, NewRequest(OpOptionCreate, 0, OptionInput{Scope: model.Ptr(model.ScopeView), ScopeID: &in.ID, Name: model.Ptr("response-policy"), Value: model.Ptr(`{ zone "x"; }`)})); e.Kind != KindValidation {
|
||||||
|
t.Errorf("response-policy option: %+v", e)
|
||||||
|
}
|
||||||
|
// Bad input.
|
||||||
|
for _, req := range []Request{blocklist("Bad Name", srv.URL), blocklist("ok", "ftp://x/list"), blockRule("com", "block", false), blockRule("a.example", "deny", false), blockRule("a.example", "block", false, "nope")} {
|
||||||
|
if e := env.submitErr(env.admin, req); e.Kind != KindBadRequest {
|
||||||
|
t.Errorf("%s: %+v", req.Data, e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBlocklistRefresh(t *testing.T) {
|
||||||
|
env := newEnv(t)
|
||||||
|
setupZone(env)
|
||||||
|
ls, srv := newListServer(t, "# test\n||ads.example^\n0.0.0.0 tracker.example\nnot a domain line\n")
|
||||||
|
env.submit(env.admin, blocklist("ads", srv.URL), blockRule("good.ads.example", "allow", false))
|
||||||
|
b := env.state().BlocklistByName("ads")
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
env.e.RefreshDue(ctx)
|
||||||
|
s, _ := env.st.BlocklistStatuses(ctx)
|
||||||
|
st := s[b.ID]
|
||||||
|
if st == nil || st.Error != "" || st.Entries != 2 || st.Records != 3 || st.Skipped != 1 || st.UpdatedAt == nil {
|
||||||
|
t.Fatalf("status after the first download: %+v", st)
|
||||||
|
}
|
||||||
|
file := env.e.cfg.Paths.BlocklistFile(b.ID)
|
||||||
|
if f, _ := os.ReadFile(file); !strings.Contains(string(f), "*.ads.example\tCNAME\t.\n") || !strings.Contains(string(f), "tracker.example\tCNAME\t.\n") {
|
||||||
|
t.Errorf("list zone:\n%s", f)
|
||||||
|
}
|
||||||
|
// Not due again: nothing is fetched.
|
||||||
|
env.e.RefreshDue(ctx)
|
||||||
|
if ls.calls.Load() != 1 {
|
||||||
|
t.Errorf("downloads after a second RefreshDue: %d", ls.calls.Load())
|
||||||
|
}
|
||||||
|
// A cleared cache leaves an empty placeholder: downloaded again at once.
|
||||||
|
os.Remove(file)
|
||||||
|
env.e.RefreshDue(ctx)
|
||||||
|
if ls.calls.Load() != 2 {
|
||||||
|
t.Errorf("downloads after the file was lost: %d", ls.calls.Load())
|
||||||
|
}
|
||||||
|
if f, _ := os.ReadFile(file); !strings.Contains(string(f), "*.ads.example\tCNAME\t.\n") {
|
||||||
|
t.Errorf("list zone after the file was lost:\n%s", f)
|
||||||
|
}
|
||||||
|
// Unchanged on the server: 304, no rewrite.
|
||||||
|
firstHash := st.SHA256
|
||||||
|
fullBefore := ls.full.Load()
|
||||||
|
env.e.RefreshBlocklist(ctx, b.ID, false)
|
||||||
|
if ls.full.Load() != fullBefore {
|
||||||
|
t.Errorf("a conditional download fetched the list again")
|
||||||
|
}
|
||||||
|
// Changed: rewritten.
|
||||||
|
ls.body.Store("||ads.example^\n||more.example^\n")
|
||||||
|
st, err := env.e.RefreshBlocklist(ctx, b.ID, false)
|
||||||
|
if err != nil || st.Entries != 2 || st.Records != 4 || st.SHA256 == firstHash {
|
||||||
|
t.Errorf("after a change: %+v, %v", st, err)
|
||||||
|
}
|
||||||
|
// A broken download keeps the previous version and reports why.
|
||||||
|
before, _ := os.ReadFile(file)
|
||||||
|
ls.body.Store("<html><body>Not found</body></html>\n")
|
||||||
|
st, err = env.e.RefreshBlocklist(ctx, b.ID, true)
|
||||||
|
if err == nil || !strings.Contains(st.Error, "no domains found") {
|
||||||
|
t.Errorf("garbage: %+v, %v", st, err)
|
||||||
|
}
|
||||||
|
if after, _ := os.ReadFile(file); string(after) != string(before) {
|
||||||
|
t.Error("a failed update changed the list's file")
|
||||||
|
}
|
||||||
|
// A failed attempt is retried after RetryAfter, not at every check.
|
||||||
|
calls := ls.calls.Load()
|
||||||
|
env.e.RefreshDue(ctx)
|
||||||
|
if ls.calls.Load() != calls {
|
||||||
|
t.Error("a failed list was tried again before RetryAfter")
|
||||||
|
}
|
||||||
|
// Too big.
|
||||||
|
env.e.Limits.MaxBytes = 10
|
||||||
|
ls.body.Store("||ads.example^\n||more.example^\n||third.example^\n")
|
||||||
|
if st, _ = env.e.RefreshBlocklist(ctx, b.ID, true); !strings.Contains(st.Error, "larger than") {
|
||||||
|
t.Errorf("size limit: %+v", st)
|
||||||
|
}
|
||||||
|
env.e.Limits = DefaultBlocklistLimits()
|
||||||
|
env.e.RefreshBlocklist(ctx, b.ID, true)
|
||||||
|
|
||||||
|
// What blocks what.
|
||||||
|
check := func(name string) blocking.Match {
|
||||||
|
m, err := env.e.CheckBlocking(env.state(), name)
|
||||||
|
if err != nil || len(m) != 1 {
|
||||||
|
t.Fatalf("check %s: %+v %v", name, m, err)
|
||||||
|
}
|
||||||
|
return m[0]
|
||||||
|
}
|
||||||
|
if m := check("x.ads.example"); m.Result != "blocked" || m.List != "ads" || m.Entry != "*.ads.example" {
|
||||||
|
t.Errorf("x.ads.example: %+v", m)
|
||||||
|
}
|
||||||
|
if m := check("a.good.ads.example"); m.Result != "allowed" || m.RuleID == 0 || m.Entry != "*.good.ads.example" {
|
||||||
|
t.Errorf("a.good.ads.example: %+v", m)
|
||||||
|
}
|
||||||
|
if m := check("fine.example"); m.Result != "none" {
|
||||||
|
t.Errorf("fine.example: %+v", m)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deleting the list removes its file and status.
|
||||||
|
env.submit(env.admin, NewRequest(OpBlocklistDelete, b.ID, nil))
|
||||||
|
env.e.RefreshDue(ctx)
|
||||||
|
if _, err := os.Stat(file); !os.IsNotExist(err) {
|
||||||
|
t.Errorf("the deleted list's file is still there: %v", err)
|
||||||
|
}
|
||||||
|
if s, _ := env.st.BlocklistStatuses(ctx); s[b.ID] != nil {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,409 @@
|
|||||||
|
package engine
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"io/fs"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"slices"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"jarl/internal/blocking"
|
||||||
|
"jarl/internal/model"
|
||||||
|
"jarl/internal/render"
|
||||||
|
"jarl/internal/version"
|
||||||
|
)
|
||||||
|
|
||||||
|
// BlocklistLimits bound blocklist downloads (blocking in jarl.yaml).
|
||||||
|
type BlocklistLimits struct {
|
||||||
|
Refresh time.Duration // how often a list is checked for changes
|
||||||
|
RetryAfter time.Duration // after a failed attempt
|
||||||
|
MaxBytes int64 // per download
|
||||||
|
MaxEntries int // domains per list
|
||||||
|
Timeout time.Duration // per download
|
||||||
|
}
|
||||||
|
|
||||||
|
// DefaultBlocklistLimits are the limits without configuration.
|
||||||
|
func DefaultBlocklistLimits() BlocklistLimits {
|
||||||
|
return BlocklistLimits{Refresh: 24 * time.Hour, RetryAfter: time.Hour, MaxBytes: 64 << 20, MaxEntries: 1_000_000, Timeout: 2 * time.Minute}
|
||||||
|
}
|
||||||
|
|
||||||
|
// KickBlocklists asks the download loop to look for work now (a list was
|
||||||
|
// added or changed).
|
||||||
|
func (e *Engine) KickBlocklists() {
|
||||||
|
select {
|
||||||
|
case e.blKick <- struct{}{}:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// BlocklistKicks delivers KickBlocklists' signals to the download loop.
|
||||||
|
func (e *Engine) BlocklistKicks() <-chan struct{} { return e.blKick }
|
||||||
|
|
||||||
|
// ensureBlocklistFiles creates missing blocklist zone files as empty
|
||||||
|
// policy zones, so named can load its configuration before a list has
|
||||||
|
// been downloaded. Existing files are never touched.
|
||||||
|
func (e *Engine) ensureBlocklistFiles(paths []string) error {
|
||||||
|
var errs []error
|
||||||
|
for _, path := range paths {
|
||||||
|
if _, err := os.Stat(path); err == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := e.deploy.writeNew(path, []byte(blocking.Placeholder()), 0o640); err != nil && !errors.Is(err, fs.ErrExist) {
|
||||||
|
errs = append(errs, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return errors.Join(errs...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RefreshDue downloads the blocklists that are due: never tried, URL
|
||||||
|
// changed, file missing or empty (the cache was cleared), last check older
|
||||||
|
// than Refresh, or a failed attempt older than RetryAfter. Lists that are
|
||||||
|
// disabled or apply to no view are skipped. It also removes the files and
|
||||||
|
// status of deleted lists.
|
||||||
|
func (e *Engine) RefreshDue(ctx context.Context) {
|
||||||
|
st, err := e.store.LoadState(ctx)
|
||||||
|
if err != nil {
|
||||||
|
e.log.Warn("blocklists: loading the configuration failed", "err", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
statuses, err := e.store.BlocklistStatuses(ctx)
|
||||||
|
if err != nil {
|
||||||
|
e.log.Warn("blocklists: loading their status failed", "err", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
out := render.Render(st, e.cfg.Paths, e.extras(ctx))
|
||||||
|
if err := e.ensureBlocklistFiles(out.Blocklists); err != nil {
|
||||||
|
e.log.Warn("blocklists: creating empty zone files failed", "err", err)
|
||||||
|
}
|
||||||
|
e.cleanBlocklists(ctx, st, statuses)
|
||||||
|
now := e.Now()
|
||||||
|
for _, b := range st.SortedBlocklists() {
|
||||||
|
if b.Disabled || len(st.BlockingViews(b.ViewIDs)) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
s := statuses[b.ID]
|
||||||
|
due := s == nil || s.URL != b.URL || s.CheckedAt == nil
|
||||||
|
if !due {
|
||||||
|
wait := e.Limits.Refresh
|
||||||
|
if s.Error != "" {
|
||||||
|
wait = e.Limits.RetryAfter
|
||||||
|
}
|
||||||
|
due = now.Sub(*s.CheckedAt) >= wait || s.Error == "" && isPlaceholder(e.cfg.Paths.BlocklistFile(b.ID))
|
||||||
|
}
|
||||||
|
if !due {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, err := e.RefreshBlocklist(ctx, b.ID, false); err != nil && ctx.Err() == nil {
|
||||||
|
e.log.Warn("blocklist update failed; named keeps using the previous version", "list", b.Name, "err", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// isPlaceholder reports whether a list's zone file is missing or still the
|
||||||
|
// empty policy zone written before its download.
|
||||||
|
func isPlaceholder(path string) bool {
|
||||||
|
f, err := os.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
head := make([]byte, len(blocking.Placeholder()))
|
||||||
|
n, _ := io.ReadFull(f, head)
|
||||||
|
return string(head[:n]) == blocking.Placeholder()
|
||||||
|
}
|
||||||
|
|
||||||
|
// cleanBlocklists removes the zone files and status of deleted lists.
|
||||||
|
func (e *Engine) cleanBlocklists(ctx context.Context, st *model.State, statuses map[int64]*model.BlocklistStatus) {
|
||||||
|
for id := range statuses {
|
||||||
|
if st.Blocklists[id] == nil {
|
||||||
|
e.store.DeleteBlocklistStatus(ctx, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dir := e.cfg.Paths.BlocklistsDir()
|
||||||
|
entries, err := os.ReadDir(dir)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, ent := range entries {
|
||||||
|
n := ent.Name()
|
||||||
|
idText, ok := strings.CutPrefix(strings.TrimSuffix(n, ".rpz"), "list-")
|
||||||
|
id, err := strconv.ParseInt(idText, 10, 64)
|
||||||
|
if !ok || !strings.HasSuffix(n, ".rpz") || err != nil || st.Blocklists[id] != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := os.Remove(filepath.Join(dir, n)); err == nil {
|
||||||
|
e.log.Info("removed the zone file of a deleted blocklist", "file", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// errNotModified means the server said the list hasn't changed.
|
||||||
|
var errNotModified = errors.New("not modified")
|
||||||
|
|
||||||
|
// RefreshBlocklist downloads a list now and, when its content changed,
|
||||||
|
// replaces its zone file and makes named reload it. force downloads even
|
||||||
|
// when the server says nothing changed. A failure keeps the previous
|
||||||
|
// version. It returns the new status.
|
||||||
|
func (e *Engine) RefreshBlocklist(ctx context.Context, id int64, force bool) (*model.BlocklistStatus, error) {
|
||||||
|
e.blMu.Lock()
|
||||||
|
defer e.blMu.Unlock()
|
||||||
|
st, err := e.store.LoadState(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
b := st.Blocklists[id]
|
||||||
|
if b == nil {
|
||||||
|
return nil, errf(KindNotFound, "blocklist %d does not exist", id)
|
||||||
|
}
|
||||||
|
statuses, err := e.store.BlocklistStatuses(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
prev := statuses[id]
|
||||||
|
if prev == nil {
|
||||||
|
prev = &model.BlocklistStatus{ID: id}
|
||||||
|
}
|
||||||
|
file := e.cfg.Paths.BlocklistFile(id)
|
||||||
|
now := e.Now()
|
||||||
|
next := *prev
|
||||||
|
next.CheckedAt = &now
|
||||||
|
conditional := !force && prev.URL == b.URL && prev.SHA256 != "" && !isPlaceholder(file)
|
||||||
|
err = e.downloadBlocklist(ctx, st, b, &next, conditional, file)
|
||||||
|
next.URL, next.Error = b.URL, ""
|
||||||
|
if err != nil && !errors.Is(err, errNotModified) {
|
||||||
|
next.Error = err.Error()
|
||||||
|
}
|
||||||
|
if serr := e.store.SetBlocklistStatus(ctx, &next); serr != nil && err == nil {
|
||||||
|
err = serr
|
||||||
|
}
|
||||||
|
if errors.Is(err, errNotModified) {
|
||||||
|
err = nil
|
||||||
|
}
|
||||||
|
return &next, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// downloadBlocklist fetches, parses, checks and installs a list, filling
|
||||||
|
// in s. It returns errNotModified when there is nothing to do.
|
||||||
|
func (e *Engine) downloadBlocklist(ctx context.Context, st *model.State, b *model.Blocklist, s *model.BlocklistStatus, conditional bool, file string) error {
|
||||||
|
ctx, cancel := context.WithTimeout(ctx, e.Limits.Timeout)
|
||||||
|
defer cancel()
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, b.URL, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
req.Header.Set("User-Agent", "Jarl/"+version.Short()+" (blocklist download)")
|
||||||
|
if conditional {
|
||||||
|
if s.ETag != "" {
|
||||||
|
req.Header.Set("If-None-Match", s.ETag)
|
||||||
|
}
|
||||||
|
if s.LastModified != "" {
|
||||||
|
req.Header.Set("If-Modified-Since", s.LastModified)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
client := e.HTTP
|
||||||
|
if client == nil {
|
||||||
|
client = http.DefaultClient
|
||||||
|
}
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("download failed: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode == http.StatusNotModified && conditional {
|
||||||
|
return errNotModified
|
||||||
|
}
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return fmt.Errorf("download failed: the server answered %s", resp.Status)
|
||||||
|
}
|
||||||
|
zone := blocking.ListZone(b.Name)
|
||||||
|
body := &limitedReader{r: resp.Body, left: e.Limits.MaxBytes}
|
||||||
|
l, err := blocking.Parse(body, blocking.Limits{MaxEntries: e.Limits.MaxEntries, MaxNameLen: 253 - len(zone) - 3})
|
||||||
|
if body.over {
|
||||||
|
return fmt.Errorf("the list is larger than %d MB (blocking.max_download_mb in jarl.yaml)", e.Limits.MaxBytes>>20)
|
||||||
|
}
|
||||||
|
if errors.Is(err, blocking.ErrTooManyEntries) {
|
||||||
|
return fmt.Errorf("%v (blocking.max_entries in jarl.yaml)", err)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("download failed: %w", err)
|
||||||
|
}
|
||||||
|
if len(l.Domains) == 0 {
|
||||||
|
return fmt.Errorf("no domains found in %d lines: is this a blocklist in hosts, domains or Adblock format?", l.Lines)
|
||||||
|
}
|
||||||
|
entries, skipped := len(l.Domains), l.Skipped
|
||||||
|
blocking.Compact(l)
|
||||||
|
|
||||||
|
dir := filepath.Dir(file)
|
||||||
|
if err := e.deploy.EnsureDir(dir, e.deploy.dirMode(dir)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
tmp, err := os.CreateTemp(dir, ".list-*.rpz")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
tmpName := tmp.Name()
|
||||||
|
defer os.Remove(tmpName) // after a successful rename, there's nothing to remove
|
||||||
|
records, hash, err := blocking.FeedZone(tmp, fmt.Sprintf("Blocklist %s from %s, %s", b.Name, b.URL, e.Now().UTC().Format(time.RFC3339)), uint32(e.Now().Unix()), l)
|
||||||
|
if cerr := tmp.Close(); err == nil {
|
||||||
|
err = cerr
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
s.ETag, s.LastModified = resp.Header.Get("ETag"), resp.Header.Get("Last-Modified")
|
||||||
|
s.Entries, s.Skipped = entries, skipped
|
||||||
|
if hash == s.SHA256 && s.URL == b.URL && !isPlaceholder(file) {
|
||||||
|
return errNotModified // same content: named keeps what it has
|
||||||
|
}
|
||||||
|
if e.tools != nil && !e.cfg.SkipCheck {
|
||||||
|
res, err := e.tools.CheckZone(ctx, zone, tmpName)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("running named-checkzone failed: %w", err)
|
||||||
|
}
|
||||||
|
if !res.OK {
|
||||||
|
msg := "named-checkzone rejected the list's zone"
|
||||||
|
for _, m := range res.Messages {
|
||||||
|
if m.Error {
|
||||||
|
msg += ": " + m.Text
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return errors.New(msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := e.deploy.finish(tmpName, file, 0o640); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
now := e.Now()
|
||||||
|
s.UpdatedAt, s.Records, s.SHA256 = &now, records, hash
|
||||||
|
e.log.Info("blocklist updated", "list", b.Name, "domains", entries, "records", records, "skipped_lines", skipped)
|
||||||
|
return e.reloadBlocklist(ctx, st, b)
|
||||||
|
}
|
||||||
|
|
||||||
|
// reloadBlocklist makes named load a list's new file in every view that
|
||||||
|
// uses it.
|
||||||
|
func (e *Engine) reloadBlocklist(ctx context.Context, st *model.State, b *model.Blocklist) error {
|
||||||
|
if e.cfg.NoReload || e.tools == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if adopted, err := e.Adopted(); err != nil || !adopted {
|
||||||
|
return nil // the file is ready for when named loads Jarl's configuration
|
||||||
|
}
|
||||||
|
for _, z := range render.PolicyZones(st, e.cfg.Paths) {
|
||||||
|
if z.ListID != b.ID {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := e.tools.ReloadZone(ctx, z.Name, z.View); err != nil {
|
||||||
|
return fmt.Errorf("the list was downloaded, but named didn't reload it: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// limitedReader reads at most left bytes and notes when there was more.
|
||||||
|
type limitedReader struct {
|
||||||
|
r io.Reader
|
||||||
|
left int64
|
||||||
|
over bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *limitedReader) Read(p []byte) (int, error) {
|
||||||
|
if l.left <= 0 {
|
||||||
|
var one [1]byte
|
||||||
|
if n, _ := l.r.Read(one[:]); n > 0 {
|
||||||
|
l.over = true
|
||||||
|
}
|
||||||
|
return 0, io.EOF
|
||||||
|
}
|
||||||
|
if int64(len(p)) > l.left {
|
||||||
|
p = p[:l.left]
|
||||||
|
}
|
||||||
|
n, err := l.r.Read(p)
|
||||||
|
l.left -= int64(n)
|
||||||
|
return n, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// CheckBlocking reports, for every view with blocking, whether name is
|
||||||
|
// blocked or allowed there, and by which rule or list. It looks names up
|
||||||
|
// the way named does: rules first, then the lists in order. The answer is
|
||||||
|
// for clients at the normal step; a list of a higher step that blocks the
|
||||||
|
// name says so (Match.Step), and clients at step off skip every list.
|
||||||
|
func (e *Engine) CheckBlocking(st *model.State, name string) ([]blocking.Match, error) {
|
||||||
|
n, err := blocking.NormalizeName(name)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errf(KindBadRequest, "%v", err)
|
||||||
|
}
|
||||||
|
var out []blocking.Match
|
||||||
|
feeds := map[string]string{} // file -> matching entry ("" none)
|
||||||
|
var cur *blocking.Match
|
||||||
|
for _, z := range render.PolicyZones(st, e.cfg.Paths) {
|
||||||
|
if cur == nil || cur.View != z.View {
|
||||||
|
out = append(out, blocking.Match{View: z.View, Result: "none"})
|
||||||
|
cur = &out[len(out)-1]
|
||||||
|
}
|
||||||
|
if cur.Result != "none" || z.IsGate() {
|
||||||
|
continue // an earlier zone decided, or a gate (it never blocks)
|
||||||
|
}
|
||||||
|
if z.ListID == 0 {
|
||||||
|
owner, act, ok := blocking.MatchRules(blocking.RuleEntries(render.ViewRules(st, z.ViewID)), n)
|
||||||
|
if ok {
|
||||||
|
cur.Entry, cur.RuleID = owner, ruleFor(st, z.ViewID, owner)
|
||||||
|
cur.Result = map[string]string{blocking.ActionBlock: "blocked", blocking.ActionAllow: "allowed"}[act]
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
entry, seen := feeds[z.File]
|
||||||
|
if !seen {
|
||||||
|
owner, ok, err := blocking.MatchFeed(z.File, n)
|
||||||
|
if err != nil && !errors.Is(err, fs.ErrNotExist) {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if ok {
|
||||||
|
entry = owner
|
||||||
|
}
|
||||||
|
feeds[z.File] = entry
|
||||||
|
}
|
||||||
|
if entry != "" {
|
||||||
|
b := st.Blocklists[z.ListID]
|
||||||
|
cur.Result, cur.Entry, cur.List = "blocked", entry, b.Name
|
||||||
|
if b.StepID != model.StepNormal {
|
||||||
|
cur.Step = st.StepName(b.StepID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ruleFor finds the rule behind a rules-zone entry: the rule for the
|
||||||
|
// entry's name, or (for entries added so the most specific rule wins) the
|
||||||
|
// closest rule above it that covers the names below it.
|
||||||
|
func ruleFor(st *model.State, viewID int64, owner string) int64 {
|
||||||
|
name := strings.TrimPrefix(owner, "*.")
|
||||||
|
wild := name != owner
|
||||||
|
var best *model.BlockRule
|
||||||
|
for _, r := range st.SortedBlockRules() {
|
||||||
|
if !slices.Contains(st.BlockingViews(r.ViewIDs), viewID) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if r.Name == name && (!wild || !r.Exact) {
|
||||||
|
return r.ID
|
||||||
|
}
|
||||||
|
if !r.Exact && strings.HasSuffix(name, "."+r.Name) && (best == nil || len(r.Name) > len(best.Name)) {
|
||||||
|
best = r
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if best != nil {
|
||||||
|
return best.ID
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
@@ -0,0 +1,327 @@
|
|||||||
|
package engine
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/url"
|
||||||
|
"regexp"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"jarl/internal/auth"
|
||||||
|
"jarl/internal/blocking"
|
||||||
|
"jarl/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ---- blocking: blocklists and rules (docs/blocking.md)
|
||||||
|
|
||||||
|
var blocklistNameRe = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]{0,38}[a-z0-9])?$`)
|
||||||
|
|
||||||
|
func blockingAction() auth.Action { return auth.Action{Resource: auth.ResBlocking, Write: true} }
|
||||||
|
|
||||||
|
// viewIDs resolves the views of a blocklist or rule input: names or IDs,
|
||||||
|
// in view order, without duplicates. nil, nil means "not given".
|
||||||
|
func viewIDs(st *model.State, names *[]string, ids *[]int64) ([]int64, error) {
|
||||||
|
if names == nil && ids == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
want := map[int64]bool{}
|
||||||
|
if names != nil {
|
||||||
|
for _, n := range *names {
|
||||||
|
v := st.ViewByName(strings.TrimSpace(n))
|
||||||
|
if v == nil {
|
||||||
|
return nil, errf(KindBadRequest, "view %q does not exist", n)
|
||||||
|
}
|
||||||
|
want[v.ID] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ids != nil {
|
||||||
|
for _, id := range *ids {
|
||||||
|
if st.Views[id] == nil {
|
||||||
|
return nil, errf(KindBadRequest, "view %d does not exist", id)
|
||||||
|
}
|
||||||
|
want[id] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out := []int64{}
|
||||||
|
for _, v := range st.OrderedViews() {
|
||||||
|
if want[v.ID] {
|
||||||
|
out = append(out, v.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CheckBlocklistURL checks a blocklist URL: http or https, with a host.
|
||||||
|
func CheckBlocklistURL(raw string) (string, error) {
|
||||||
|
raw = strings.TrimSpace(raw)
|
||||||
|
u, err := url.Parse(raw)
|
||||||
|
if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" {
|
||||||
|
return "", fmt.Errorf("%q is not an http or https URL", raw)
|
||||||
|
}
|
||||||
|
return raw, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func applyBlocklistPut(st *model.State, op model.Op, c *applyCtx) error {
|
||||||
|
if err := c.require(blockingAction()); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var in BlocklistInput
|
||||||
|
if err := decode(op.Data, &in); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var b *model.Blocklist
|
||||||
|
if op.Kind == OpBlocklistCreate {
|
||||||
|
if op.ID == 0 || st.Blocklists[op.ID] != nil {
|
||||||
|
return errf(KindConflict, "blocklist ID %d is already in use", op.ID)
|
||||||
|
}
|
||||||
|
if in.Name == nil || in.URL == nil {
|
||||||
|
return errf(KindBadRequest, "a blocklist needs a name and a URL")
|
||||||
|
}
|
||||||
|
b = &model.Blocklist{ID: op.ID, ViewIDs: []int64{}}
|
||||||
|
} else {
|
||||||
|
b = st.Blocklists[op.ID]
|
||||||
|
if b == nil {
|
||||||
|
return errf(KindNotFound, "blocklist %d does not exist", op.ID)
|
||||||
|
}
|
||||||
|
if err := checkVersion(op, b.Version, "blocklist "+b.Name); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if in.Name != nil {
|
||||||
|
name := strings.ToLower(strings.TrimSpace(*in.Name))
|
||||||
|
if !blocklistNameRe.MatchString(name) {
|
||||||
|
return errf(KindBadRequest, "blocklist name %q: use 1 to 40 lowercase letters, digits and hyphens, like hagezi-pro", *in.Name)
|
||||||
|
}
|
||||||
|
if other := st.BlocklistByName(name); other != nil && other.ID != b.ID {
|
||||||
|
return errf(KindConflict, "blocklist %s already exists", name)
|
||||||
|
}
|
||||||
|
b.Name = name
|
||||||
|
}
|
||||||
|
if in.URL != nil {
|
||||||
|
u, err := CheckBlocklistURL(*in.URL)
|
||||||
|
if err != nil {
|
||||||
|
return errf(KindBadRequest, "%v", err)
|
||||||
|
}
|
||||||
|
b.URL = u
|
||||||
|
}
|
||||||
|
ids, err := viewIDs(st, in.Views, in.ViewIDs)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if ids != nil {
|
||||||
|
b.ViewIDs = ids
|
||||||
|
}
|
||||||
|
if in.Disabled != nil {
|
||||||
|
b.Disabled = *in.Disabled
|
||||||
|
}
|
||||||
|
step, given, err := stepRef(st, in.Step, in.StepID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if given {
|
||||||
|
if step == model.StepOff {
|
||||||
|
return errf(KindBadRequest, "a blocklist starts at normal or a step above it, not off")
|
||||||
|
}
|
||||||
|
b.StepID = step
|
||||||
|
}
|
||||||
|
if in.Comment != nil {
|
||||||
|
b.Comment = *in.Comment
|
||||||
|
}
|
||||||
|
b.Touch(c.user(), c.now)
|
||||||
|
st.Blocklists[b.ID] = b
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func applyBlocklistDelete(st *model.State, op model.Op, c *applyCtx) error {
|
||||||
|
if err := c.require(blockingAction()); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
b := st.Blocklists[op.ID]
|
||||||
|
if b == nil {
|
||||||
|
return errf(KindNotFound, "blocklist %d does not exist", op.ID)
|
||||||
|
}
|
||||||
|
if err := checkVersion(op, b.Version, "blocklist "+b.Name); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
delete(st.Blocklists, b.ID)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func applyBlockRulePut(st *model.State, op model.Op, c *applyCtx) error {
|
||||||
|
if err := c.require(blockingAction()); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var in BlockRuleInput
|
||||||
|
if err := decode(op.Data, &in); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var r *model.BlockRule
|
||||||
|
if op.Kind == OpBlockRuleCreate {
|
||||||
|
if op.ID == 0 || st.BlockRules[op.ID] != nil {
|
||||||
|
return errf(KindConflict, "rule ID %d is already in use", op.ID)
|
||||||
|
}
|
||||||
|
if in.Name == nil {
|
||||||
|
return errf(KindBadRequest, "a rule needs a domain name")
|
||||||
|
}
|
||||||
|
r = &model.BlockRule{ID: op.ID, Action: model.BlockActionBlock, ViewIDs: []int64{}}
|
||||||
|
} else {
|
||||||
|
r = st.BlockRules[op.ID]
|
||||||
|
if r == nil {
|
||||||
|
return errf(KindNotFound, "rule %d does not exist", op.ID)
|
||||||
|
}
|
||||||
|
if err := checkVersion(op, r.Version, "rule "+r.Name); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if in.Name != nil {
|
||||||
|
n, err := blocking.NormalizeName(*in.Name)
|
||||||
|
if err != nil {
|
||||||
|
return errf(KindBadRequest, "%v", err)
|
||||||
|
}
|
||||||
|
r.Name = n
|
||||||
|
}
|
||||||
|
if in.Action != nil {
|
||||||
|
a := strings.ToLower(strings.TrimSpace(*in.Action))
|
||||||
|
if a != model.BlockActionBlock && a != model.BlockActionAllow {
|
||||||
|
return errf(KindBadRequest, "a rule's action is block or allow, not %q", *in.Action)
|
||||||
|
}
|
||||||
|
r.Action = a
|
||||||
|
}
|
||||||
|
if in.Exact != nil {
|
||||||
|
r.Exact = *in.Exact
|
||||||
|
}
|
||||||
|
ids, err := viewIDs(st, in.Views, in.ViewIDs)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if ids != nil {
|
||||||
|
r.ViewIDs = ids
|
||||||
|
}
|
||||||
|
if in.Comment != nil {
|
||||||
|
r.Comment = *in.Comment
|
||||||
|
}
|
||||||
|
r.Touch(c.user(), c.now)
|
||||||
|
st.BlockRules[r.ID] = r
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func applyBlockRuleDelete(st *model.State, op model.Op, c *applyCtx) error {
|
||||||
|
if err := c.require(blockingAction()); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
r := st.BlockRules[op.ID]
|
||||||
|
if r == nil {
|
||||||
|
return errf(KindNotFound, "rule %d does not exist", op.ID)
|
||||||
|
}
|
||||||
|
if err := checkVersion(op, r.Version, "rule "+r.Name); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
delete(st.BlockRules, r.ID)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// blockingUsers lists the blocklists and rules that name a view.
|
||||||
|
func blockingUsers(st *model.State, viewID int64) []string {
|
||||||
|
var out []string
|
||||||
|
for _, b := range st.SortedBlocklists() {
|
||||||
|
if slices.Contains(b.ViewIDs, viewID) {
|
||||||
|
out = append(out, "blocklist "+b.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, r := range st.SortedBlockRules() {
|
||||||
|
if slices.Contains(r.ViewIDs, viewID) {
|
||||||
|
out = append(out, ruleLabel(r))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, g := range st.SortedClientGroups() {
|
||||||
|
if slices.Contains(g.ViewIDs, viewID) {
|
||||||
|
out = append(out, "client group "+g.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// ruleLabel describes a rule: "block ads.example (and below)".
|
||||||
|
func ruleLabel(r *model.BlockRule) string {
|
||||||
|
s := r.Action + " " + r.Name
|
||||||
|
if !r.Exact {
|
||||||
|
s += " (and below)"
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- expansion
|
||||||
|
|
||||||
|
func (x *expander) blocklistCreate(req Request) ([]model.Op, error) {
|
||||||
|
return x.simpleCreate(req, OpBlocklistCreate, func(data json.RawMessage) (string, error) {
|
||||||
|
var in BlocklistInput
|
||||||
|
if err := decode(data, &in); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if in.Name == nil || in.URL == nil {
|
||||||
|
return "", errf(KindBadRequest, "a blocklist needs a name and a URL")
|
||||||
|
}
|
||||||
|
return "add blocklist " + *in.Name + " (" + *in.URL + ")", nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *expander) blockRuleCreate(req Request) ([]model.Op, error) {
|
||||||
|
return x.simpleCreate(req, OpBlockRuleCreate, func(data json.RawMessage) (string, error) {
|
||||||
|
var in BlockRuleInput
|
||||||
|
if err := decode(data, &in); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if in.Name == nil {
|
||||||
|
return "", errf(KindBadRequest, "a rule needs a domain name")
|
||||||
|
}
|
||||||
|
r := &model.BlockRule{Name: strings.ToLower(strings.TrimSuffix(strings.TrimSpace(*in.Name), ".")), Action: model.BlockActionBlock}
|
||||||
|
if in.Action != nil {
|
||||||
|
r.Action = strings.ToLower(*in.Action)
|
||||||
|
}
|
||||||
|
if in.Exact != nil {
|
||||||
|
r.Exact = *in.Exact
|
||||||
|
}
|
||||||
|
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{}
|
||||||
|
}
|
||||||
@@ -0,0 +1,270 @@
|
|||||||
|
package engine
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/netip"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"jarl/internal/iprange"
|
||||||
|
"jarl/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ---- blocking steps and client groups (docs/blocking.md)
|
||||||
|
|
||||||
|
// stepRef resolves a step given by name or ID. ok is false when neither
|
||||||
|
// is given.
|
||||||
|
func stepRef(st *model.State, name *string, id *int64) (int64, bool, error) {
|
||||||
|
switch {
|
||||||
|
case name != nil:
|
||||||
|
s, found := st.StepByName(*name)
|
||||||
|
if !found {
|
||||||
|
return 0, true, errf(KindBadRequest, "blocking step %q does not exist (steps: %s)", *name, stepNames(st))
|
||||||
|
}
|
||||||
|
return s, true, nil
|
||||||
|
case id != nil:
|
||||||
|
if _, found := st.StepRank(*id); !found {
|
||||||
|
return 0, true, errf(KindBadRequest, "blocking step %d does not exist", *id)
|
||||||
|
}
|
||||||
|
return *id, true, nil
|
||||||
|
}
|
||||||
|
return 0, false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// stepNames lists the steps for messages: "off, normal, strict".
|
||||||
|
func stepNames(st *model.State) string {
|
||||||
|
names := []string{"off", "normal"}
|
||||||
|
for _, s := range st.OrderedBlockSteps() {
|
||||||
|
names = append(names, s.Name)
|
||||||
|
}
|
||||||
|
return strings.Join(names, ", ")
|
||||||
|
}
|
||||||
|
|
||||||
|
// NormalizeMember checks a client group member: an address, a prefix or
|
||||||
|
// an ACL name (an ACL's own elements are checked by validation). It
|
||||||
|
// returns the canonical form.
|
||||||
|
func NormalizeMember(st *model.State, m string) (string, error) {
|
||||||
|
m = strings.TrimSpace(m)
|
||||||
|
if a, err := netip.ParseAddr(m); err == nil {
|
||||||
|
if a.Zone() != "" {
|
||||||
|
return "", errf(KindBadRequest, "%q: an address with a zone can't be matched", m)
|
||||||
|
}
|
||||||
|
return a.Unmap().String(), nil
|
||||||
|
}
|
||||||
|
if p, err := netip.ParsePrefix(m); err == nil {
|
||||||
|
return p.Masked().String(), nil
|
||||||
|
}
|
||||||
|
if r, ok, err := iprange.Parse(m); ok {
|
||||||
|
if err != nil {
|
||||||
|
return "", errf(KindBadRequest, "%v", err)
|
||||||
|
}
|
||||||
|
return r.String(), nil
|
||||||
|
}
|
||||||
|
if a := st.ACLByName(m); a != nil {
|
||||||
|
return a.Name, nil
|
||||||
|
}
|
||||||
|
return "", errf(KindBadRequest, "%q is not an address, a prefix (10.10.0.0/24), a range (10.10.0.20-10.10.0.49) or an ACL", m)
|
||||||
|
}
|
||||||
|
|
||||||
|
func applyBlockStepPut(st *model.State, op model.Op, c *applyCtx) error {
|
||||||
|
if err := c.require(blockingAction()); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var in BlockStepInput
|
||||||
|
if err := decode(op.Data, &in); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var s *model.BlockStep
|
||||||
|
if op.Kind == OpBlockStepCreate {
|
||||||
|
if op.ID <= 0 || st.BlockSteps[op.ID] != nil {
|
||||||
|
return errf(KindConflict, "step ID %d is already in use", op.ID)
|
||||||
|
}
|
||||||
|
if in.Name == nil {
|
||||||
|
return errf(KindBadRequest, "a blocking step needs a name")
|
||||||
|
}
|
||||||
|
pos := 0
|
||||||
|
for _, o := range st.BlockSteps {
|
||||||
|
pos = max(pos, o.Position+1)
|
||||||
|
}
|
||||||
|
s = &model.BlockStep{ID: op.ID, Position: pos}
|
||||||
|
} else {
|
||||||
|
s = st.BlockSteps[op.ID]
|
||||||
|
if s == nil {
|
||||||
|
return errf(KindNotFound, "blocking step %d does not exist", op.ID)
|
||||||
|
}
|
||||||
|
if err := checkVersion(op, s.Version, "step "+s.Name); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if in.Name != nil {
|
||||||
|
name := strings.ToLower(strings.TrimSpace(*in.Name))
|
||||||
|
if !blocklistNameRe.MatchString(name) || name == "off" || name == "normal" {
|
||||||
|
return errf(KindBadRequest, "step name %q: use 1 to 40 lowercase letters, digits and hyphens, like strict (off and normal are built in)", *in.Name)
|
||||||
|
}
|
||||||
|
if id, found := st.StepByName(name); found && id != s.ID {
|
||||||
|
return errf(KindConflict, "step %s already exists", name)
|
||||||
|
}
|
||||||
|
s.Name = name
|
||||||
|
}
|
||||||
|
if in.Position != nil {
|
||||||
|
s.Position = *in.Position
|
||||||
|
}
|
||||||
|
if in.Comment != nil {
|
||||||
|
s.Comment = *in.Comment
|
||||||
|
}
|
||||||
|
s.Touch(c.user(), c.now)
|
||||||
|
st.BlockSteps[s.ID] = s
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func applyBlockStepDelete(st *model.State, op model.Op, c *applyCtx) error {
|
||||||
|
if err := c.require(blockingAction()); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
s := st.BlockSteps[op.ID]
|
||||||
|
if s == nil {
|
||||||
|
return errf(KindNotFound, "blocking step %d does not exist", op.ID)
|
||||||
|
}
|
||||||
|
if err := checkVersion(op, s.Version, "step "+s.Name); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if users := stepUsers(st, s.ID); len(users) > 0 {
|
||||||
|
return errf(KindConflict, "step %s is used by %s; move them to another step first", s.Name, strings.Join(users, ", "))
|
||||||
|
}
|
||||||
|
delete(st.BlockSteps, s.ID)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// stepUsers lists the blocklists and client groups at a step.
|
||||||
|
func stepUsers(st *model.State, id int64) []string {
|
||||||
|
var out []string
|
||||||
|
for _, b := range st.SortedBlocklists() {
|
||||||
|
if b.StepID == id {
|
||||||
|
out = append(out, "blocklist "+b.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, g := range st.SortedClientGroups() {
|
||||||
|
if g.StepID == id {
|
||||||
|
out = append(out, "client group "+g.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func applyClientGroupPut(st *model.State, op model.Op, c *applyCtx) error {
|
||||||
|
if err := c.require(blockingAction()); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var in ClientGroupInput
|
||||||
|
if err := decode(op.Data, &in); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var g *model.ClientGroup
|
||||||
|
if op.Kind == OpClientGroupCreate {
|
||||||
|
if op.ID <= 0 || st.ClientGroups[op.ID] != nil {
|
||||||
|
return errf(KindConflict, "client group ID %d is already in use", op.ID)
|
||||||
|
}
|
||||||
|
if in.Name == nil {
|
||||||
|
return errf(KindBadRequest, "a client group needs a name")
|
||||||
|
}
|
||||||
|
g = &model.ClientGroup{ID: op.ID, Members: []string{}, StepID: model.StepNormal, ViewIDs: []int64{}}
|
||||||
|
} else {
|
||||||
|
g = st.ClientGroups[op.ID]
|
||||||
|
if g == nil {
|
||||||
|
return errf(KindNotFound, "client group %d does not exist", op.ID)
|
||||||
|
}
|
||||||
|
if err := checkVersion(op, g.Version, "client group "+g.Name); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if in.Name != nil {
|
||||||
|
name := strings.ToLower(strings.TrimSpace(*in.Name))
|
||||||
|
if !blocklistNameRe.MatchString(name) {
|
||||||
|
return errf(KindBadRequest, "client group name %q: use 1 to 40 lowercase letters, digits and hyphens, like kids", *in.Name)
|
||||||
|
}
|
||||||
|
if other := st.ClientGroupByName(name); other != nil && other.ID != g.ID {
|
||||||
|
return errf(KindConflict, "client group %s already exists", name)
|
||||||
|
}
|
||||||
|
g.Name = name
|
||||||
|
}
|
||||||
|
if in.Members != nil {
|
||||||
|
members := []string{}
|
||||||
|
seen := map[string]bool{}
|
||||||
|
for _, m := range *in.Members {
|
||||||
|
if strings.TrimSpace(m) == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
n, err := NormalizeMember(st, m)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !seen[n] {
|
||||||
|
seen[n] = true
|
||||||
|
members = append(members, n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
g.Members = members
|
||||||
|
}
|
||||||
|
step, given, err := stepRef(st, in.Step, in.StepID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if given {
|
||||||
|
g.StepID = step
|
||||||
|
}
|
||||||
|
ids, err := viewIDs(st, in.Views, in.ViewIDs)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if ids != nil {
|
||||||
|
g.ViewIDs = ids
|
||||||
|
}
|
||||||
|
if in.Comment != nil {
|
||||||
|
g.Comment = *in.Comment
|
||||||
|
}
|
||||||
|
g.Touch(c.user(), c.now)
|
||||||
|
st.ClientGroups[g.ID] = g
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func applyClientGroupDelete(st *model.State, op model.Op, c *applyCtx) error {
|
||||||
|
if err := c.require(blockingAction()); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
g := st.ClientGroups[op.ID]
|
||||||
|
if g == nil {
|
||||||
|
return errf(KindNotFound, "client group %d does not exist", op.ID)
|
||||||
|
}
|
||||||
|
if err := checkVersion(op, g.Version, "client group "+g.Name); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
delete(st.ClientGroups, g.ID)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- expansion
|
||||||
|
|
||||||
|
func (x *expander) blockStepCreate(req Request) ([]model.Op, error) {
|
||||||
|
return x.simpleCreate(req, OpBlockStepCreate, func(data json.RawMessage) (string, error) {
|
||||||
|
var in BlockStepInput
|
||||||
|
if err := decode(data, &in); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if in.Name == nil {
|
||||||
|
return "", errf(KindBadRequest, "a blocking step needs a name")
|
||||||
|
}
|
||||||
|
return "add blocking step " + strings.ToLower(*in.Name), nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *expander) clientGroupCreate(req Request) ([]model.Op, error) {
|
||||||
|
return x.simpleCreate(req, OpClientGroupCreate, func(data json.RawMessage) (string, error) {
|
||||||
|
var in ClientGroupInput
|
||||||
|
if err := decode(data, &in); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if in.Name == nil {
|
||||||
|
return "", errf(KindBadRequest, "a client group needs a name")
|
||||||
|
}
|
||||||
|
return "add client group " + strings.ToLower(*in.Name), nil
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
package engine
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"jarl/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
func clientGroup(name, step string, members ...string) Request {
|
||||||
|
return NewRequest(OpClientGroupCreate, 0, ClientGroupInput{Name: &name, Step: &step, Members: &members})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClientGroups(t *testing.T) {
|
||||||
|
env := newEnv(t)
|
||||||
|
setupZone(env)
|
||||||
|
_, srv := newListServer(t, "||ads.example^\n")
|
||||||
|
env.submit(env.admin,
|
||||||
|
NewRequest(OpBlockStepCreate, 0, BlockStepInput{Name: model.Ptr("strict")}),
|
||||||
|
NewRequest(OpACLCreate, 0, ACLInput{Name: model.Ptr("tablets"), Elements: &[]string{"10.0.0.50", "10.0.0.51"}}),
|
||||||
|
)
|
||||||
|
env.submit(env.admin,
|
||||||
|
blocklist("ads", srv.URL+"/a.txt"),
|
||||||
|
NewRequest(OpBlocklistCreate, 0, BlocklistInput{Name: model.Ptr("social"), URL: model.Ptr(srv.URL + "/s.txt"), Step: model.Ptr("strict")}),
|
||||||
|
clientGroup("work", "off", "10.0.0.20"),
|
||||||
|
clientGroup("kids", "strict", "tablets", "2001:db8:1::/64"),
|
||||||
|
)
|
||||||
|
conf, _ := os.ReadFile(filepath.Join(env.dir, "etc/named.conf"))
|
||||||
|
c := string(conf)
|
||||||
|
// Off clients pass before the normal lists, everyone below strict
|
||||||
|
// before the strict ones; gates aren't logged.
|
||||||
|
want := "response-policy {\n" +
|
||||||
|
"\t\tzone \"off.gate.jarl.invalid\" min-update-interval 0 log no;\n" +
|
||||||
|
"\t\tzone \"ads.blocklist.jarl.invalid\" min-update-interval 0 log no;\n" +
|
||||||
|
"\t\tzone \"strict.gate.jarl.invalid\" min-update-interval 0 log no;\n" +
|
||||||
|
"\t\tzone \"social.blocklist.jarl.invalid\" min-update-interval 0 log no;\n"
|
||||||
|
if !strings.Contains(c, want) {
|
||||||
|
t.Errorf("response-policy:\n%s", c)
|
||||||
|
}
|
||||||
|
gate := func(name string) string {
|
||||||
|
b, err := os.ReadFile(filepath.Join(env.dir, "zones/_default", name+".gate.jarl.invalid.rpz"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return string(b)
|
||||||
|
}
|
||||||
|
if g := gate("off"); !strings.Contains(g, "32.20.0.0.10.rpz-client-ip\tCNAME\trpz-passthru.\n") || strings.Count(g, "rpz-client-ip") != 1 {
|
||||||
|
t.Errorf("off gate:\n%s", g)
|
||||||
|
}
|
||||||
|
// Everyone but the kids' tablets (via the ACL) and network passes.
|
||||||
|
sg := gate("strict")
|
||||||
|
for _, notIn := range []string{"32.50.0.0.10.rpz-client-ip", "32.51.0.0.10.rpz-client-ip", "64.zz.1.db8.2001.rpz-client-ip"} {
|
||||||
|
if strings.Contains(sg, notIn) {
|
||||||
|
t.Errorf("strict gate lets %s through:\n%s", notIn, sg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 10.0.0.0/27 holds the work laptop, 10.0.0.52/30 the next addresses
|
||||||
|
// after the tablets, 128.0.0.0/1 the upper half of IPv4.
|
||||||
|
for _, in := range []string{"27.0.0.0.10.rpz-client-ip", "30.52.0.0.10.rpz-client-ip", "1.0.0.0.128.rpz-client-ip"} {
|
||||||
|
if !strings.Contains(sg, in) {
|
||||||
|
t.Errorf("strict gate lacks %s:\n%s", in, sg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
st := env.state()
|
||||||
|
strict, _ := st.StepByName("strict")
|
||||||
|
// A step that lists or groups use can't be deleted.
|
||||||
|
if e := env.submitErr(env.admin, NewRequest(OpBlockStepDelete, strict, nil)); e.Kind != KindConflict || !strings.Contains(e.Message, "blocklist social") {
|
||||||
|
t.Errorf("deleting a used step: %+v", e)
|
||||||
|
}
|
||||||
|
// One client, one group; ACLs must hold addresses only.
|
||||||
|
if e := env.submitErr(env.admin, clientGroup("dup", "normal", "10.0.0.20")); e.Kind != KindValidation || !strings.Contains(e.Message, "10.0.0.20/32 is in client groups") {
|
||||||
|
t.Errorf("an address in two groups: %+v", e)
|
||||||
|
}
|
||||||
|
env.submit(env.admin, NewRequest(OpACLCreate, 0, ACLInput{Name: model.Ptr("keyed"), Elements: &[]string{"localnets"}}))
|
||||||
|
if e := env.submitErr(env.admin, clientGroup("bad", "normal", "keyed")); e.Kind != KindValidation || !strings.Contains(e.Message, "isn't an address") {
|
||||||
|
t.Errorf("an ACL with a key: %+v", e)
|
||||||
|
}
|
||||||
|
for _, req := range []Request{clientGroup("x", "nope", "10.1.1.1"), clientGroup("y", "normal", "not-an-acl"), clientGroup("Bad Name", "normal")} {
|
||||||
|
if e := env.submitErr(env.admin, req); e.Kind != KindBadRequest {
|
||||||
|
t.Errorf("%s: %+v", req.Data, e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// A more specific address in another group is fine: it wins.
|
||||||
|
env.submit(env.admin, clientGroup("guest", "normal", "2001:db8:1::9"))
|
||||||
|
if sg := gate("strict"); !strings.Contains(sg, "128.9.zz.1.db8.2001.rpz-client-ip") {
|
||||||
|
t.Errorf("the guest inside the kids' network passes the strict gate:\n%s", sg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The kids' group gone: the strict list blocks nobody (a warning).
|
||||||
|
kids := env.state().ClientGroupByName("kids")
|
||||||
|
res := env.submit(env.admin, NewRequest(OpClientGroupDelete, kids.ID, nil))
|
||||||
|
found := false
|
||||||
|
for _, p := range res.Problems {
|
||||||
|
found = found || p.Code == "blocklist_step_unused"
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Errorf("no warning for a step no group reaches: %+v", res.Problems)
|
||||||
|
}
|
||||||
|
// Reverting brings it back.
|
||||||
|
rev, err := env.e.Revert(context.Background(), res.ChangesetID, env.admin)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(rev.Ops) != 1 {
|
||||||
|
t.Errorf("revert ops: %+v (only the deleted group should come back)", rev.Ops)
|
||||||
|
}
|
||||||
|
if _, err := env.e.Apply(context.Background(), rev.ID, env.admin, ApplyOptions{}); err != nil || env.state().ClientGroupByName("kids") == nil {
|
||||||
|
t.Fatalf("revert: %v", err)
|
||||||
|
}
|
||||||
|
// Renaming the ACL renames the member; deleting it is refused.
|
||||||
|
tablets := env.state().ACLByName("tablets")
|
||||||
|
env.submit(env.admin, NewRequest(OpACLUpdate, tablets.ID, ACLInput{Name: model.Ptr("pads")}))
|
||||||
|
if g := env.state().ClientGroupByName("kids"); g.Members[0] != "pads" {
|
||||||
|
t.Errorf("ACL rename: members %v", g.Members)
|
||||||
|
}
|
||||||
|
if e := env.submitErr(env.admin, NewRequest(OpACLDelete, tablets.ID, nil)); e.Kind != KindConflict || !strings.Contains(e.Message, "client group kids") {
|
||||||
|
t.Errorf("deleting an ACL a group uses: %+v", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -183,6 +183,48 @@ func (d *Deployer) writeAtomic(path string, content []byte, mode fs.FileMode) er
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// writeNew creates a file that doesn't exist yet (fs.ErrExist if it
|
||||||
|
// does), with the deploy group.
|
||||||
|
func (d *Deployer) writeNew(path string, content []byte, mode fs.FileMode) error {
|
||||||
|
dir := filepath.Dir(path)
|
||||||
|
if err := d.EnsureDir(dir, d.dirMode(dir)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, mode)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err = f.Write(content)
|
||||||
|
if gid, gerr := d.groupID(); err == nil && gerr == nil && gid >= 0 {
|
||||||
|
err = f.Chown(-1, gid)
|
||||||
|
}
|
||||||
|
if cerr := f.Close(); err == nil {
|
||||||
|
err = cerr
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
os.Remove(path)
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// finish gives a temporary file written next to path its mode and the
|
||||||
|
// deploy group, and moves it into place.
|
||||||
|
func (d *Deployer) finish(tmp, path string, mode fs.FileMode) error {
|
||||||
|
if err := os.Chmod(tmp, mode); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
gid, err := d.groupID()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if gid >= 0 {
|
||||||
|
if err := os.Chown(tmp, -1, gid); err != nil {
|
||||||
|
return fmt.Errorf("chgrp %s: %w", path, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return os.Rename(tmp, path)
|
||||||
|
}
|
||||||
|
|
||||||
// Install writes files and removes paths, returning a backup that undoes
|
// Install writes files and removes paths, returning a backup that undoes
|
||||||
// the changes. On error the files already written are restored.
|
// the changes. On error the files already written are restored.
|
||||||
func (d *Deployer) Install(files []*render.File, remove []string, dirs []string) (*Backup, error) {
|
func (d *Deployer) Install(files []*render.File, remove []string, dirs []string) (*Backup, error) {
|
||||||
@@ -293,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}
|
||||||
}
|
}
|
||||||
@@ -101,7 +104,64 @@ func DiffOps(from, to *model.State) []model.Op {
|
|||||||
add(OpRecordUpdate, k, o.Version, in, fmt.Sprintf("restore %s %s %s", n.Name, n.Type, n.Data))
|
add(OpRecordUpdate, k, o.Version, in, fmt.Sprintf("restore %s %s %s", n.Name, n.Type, n.Data))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Steps before the lists and groups that use them.
|
||||||
|
for _, k := range sortedKeys(to.BlockSteps) {
|
||||||
|
n := to.BlockSteps[k]
|
||||||
|
in := BlockStepInput{Name: &n.Name, Position: model.Ptr(n.Position), Comment: &n.Comment}
|
||||||
|
if o, ok := from.BlockSteps[k]; !ok {
|
||||||
|
add(OpBlockStepCreate, k, 0, in, "add blocking step "+n.Name)
|
||||||
|
} else if !sameObject(o, n) {
|
||||||
|
add(OpBlockStepUpdate, k, o.Version, in, "restore blocking step "+n.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, k := range sortedKeys(to.Blocklists) {
|
||||||
|
n := to.Blocklists[k]
|
||||||
|
in := BlocklistInput{Name: &n.Name, URL: &n.URL, ViewIDs: model.Ptr(append([]int64{}, n.ViewIDs...)), Disabled: &n.Disabled, Comment: &n.Comment, StepID: model.Ptr(n.StepID)}
|
||||||
|
if o, ok := from.Blocklists[k]; !ok {
|
||||||
|
add(OpBlocklistCreate, k, 0, in, "add blocklist "+n.Name)
|
||||||
|
} else if !sameObject(o, n) {
|
||||||
|
add(OpBlocklistUpdate, k, o.Version, in, "restore blocklist "+n.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, k := range sortedKeys(to.BlockRules) {
|
||||||
|
n := to.BlockRules[k]
|
||||||
|
in := BlockRuleInput{Name: &n.Name, Action: &n.Action, Exact: &n.Exact, ViewIDs: model.Ptr(append([]int64{}, n.ViewIDs...)), Comment: &n.Comment}
|
||||||
|
if o, ok := from.BlockRules[k]; !ok {
|
||||||
|
add(OpBlockRuleCreate, k, 0, in, "add rule: "+ruleLabel(n))
|
||||||
|
} else if !sameObject(o, n) {
|
||||||
|
add(OpBlockRuleUpdate, k, o.Version, in, "restore rule: "+ruleLabel(n))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, k := range sortedKeys(to.ClientGroups) {
|
||||||
|
n := to.ClientGroups[k]
|
||||||
|
in := ClientGroupInput{Name: &n.Name, Members: model.Ptr(append([]string{}, n.Members...)), StepID: model.Ptr(n.StepID), ViewIDs: model.Ptr(append([]int64{}, n.ViewIDs...)), Comment: &n.Comment}
|
||||||
|
if o, ok := from.ClientGroups[k]; !ok {
|
||||||
|
add(OpClientGroupCreate, k, 0, in, "add client group "+n.Name)
|
||||||
|
} else if !sameObject(o, n) {
|
||||||
|
add(OpClientGroupUpdate, k, o.Version, in, "restore client group "+n.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
// Deletions, most dependent objects first.
|
// Deletions, most dependent objects first.
|
||||||
|
for _, k := range sortedKeys(from.ClientGroups) {
|
||||||
|
if _, ok := to.ClientGroups[k]; !ok {
|
||||||
|
add(OpClientGroupDelete, k, from.ClientGroups[k].Version, nil, "delete client group "+from.ClientGroups[k].Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, k := range sortedKeys(from.BlockRules) {
|
||||||
|
if _, ok := to.BlockRules[k]; !ok {
|
||||||
|
add(OpBlockRuleDelete, k, from.BlockRules[k].Version, nil, "delete rule: "+ruleLabel(from.BlockRules[k]))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, k := range sortedKeys(from.Blocklists) {
|
||||||
|
if _, ok := to.Blocklists[k]; !ok {
|
||||||
|
add(OpBlocklistDelete, k, from.Blocklists[k].Version, nil, "delete blocklist "+from.Blocklists[k].Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, k := range sortedKeys(from.BlockSteps) {
|
||||||
|
if _, ok := to.BlockSteps[k]; !ok {
|
||||||
|
add(OpBlockStepDelete, k, from.BlockSteps[k].Version, nil, "delete blocking step "+from.BlockSteps[k].Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
for _, k := range sortedKeys(from.Records) {
|
for _, k := range sortedKeys(from.Records) {
|
||||||
if _, ok := to.Records[k]; !ok {
|
if _, ok := to.Records[k]; !ok {
|
||||||
o := from.Records[k]
|
o := from.Records[k]
|
||||||
@@ -156,6 +216,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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,8 +14,10 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"slices"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
@@ -23,6 +25,7 @@ import (
|
|||||||
|
|
||||||
"jarl/internal/auth"
|
"jarl/internal/auth"
|
||||||
"jarl/internal/bind"
|
"jarl/internal/bind"
|
||||||
|
"jarl/internal/blocking"
|
||||||
"jarl/internal/dnsname"
|
"jarl/internal/dnsname"
|
||||||
"jarl/internal/model"
|
"jarl/internal/model"
|
||||||
"jarl/internal/namedconf"
|
"jarl/internal/namedconf"
|
||||||
@@ -64,6 +67,17 @@ type Engine struct {
|
|||||||
deploy *Deployer
|
deploy *Deployer
|
||||||
mu sync.Mutex // serializes applies
|
mu sync.Mutex // serializes applies
|
||||||
Now func() time.Time
|
Now func() time.Time
|
||||||
|
|
||||||
|
// Blocklist downloads (blocklists.go).
|
||||||
|
blMu sync.Mutex // serializes downloads
|
||||||
|
blKick chan struct{} // a list was added or changed
|
||||||
|
HTTP *http.Client // for downloads; nil: a default client
|
||||||
|
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.
|
||||||
@@ -78,6 +92,8 @@ func New(st *store.Store, tools *bind.Tools, cfg Config, log *slog.Logger) *Engi
|
|||||||
store: st, tools: tools, cfg: cfg, log: log,
|
store: st, tools: tools, cfg: cfg, log: log,
|
||||||
deploy: &Deployer{Group: cfg.FileGroup, NamedWritable: []string{cfg.Paths.ZoneDir, cfg.Paths.CacheDir}},
|
deploy: &Deployer{Group: cfg.FileGroup, NamedWritable: []string{cfg.Paths.ZoneDir, cfg.Paths.CacheDir}},
|
||||||
Now: time.Now,
|
Now: time.Now,
|
||||||
|
blKick: make(chan struct{}, 1),
|
||||||
|
Limits: DefaultBlocklistLimits(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -383,6 +399,7 @@ type plan struct {
|
|||||||
drift []DriftEntry
|
drift []DriftEntry
|
||||||
configChanged bool
|
configChanged bool
|
||||||
reloadZones []*model.Zone
|
reloadZones []*model.Zone
|
||||||
|
reloadPolicy []render.PolicyZone // rules zones whose file changed
|
||||||
verify map[int64]uint32
|
verify map[int64]uint32
|
||||||
newZones bool
|
newZones bool
|
||||||
backup *Backup
|
backup *Backup
|
||||||
@@ -423,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
|
||||||
@@ -446,7 +463,7 @@ func (e *Engine) makePlan(ctx context.Context, base, work *model.State, user str
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
for path, df := range deployed {
|
for path, df := range deployed {
|
||||||
if pl.out.File(path) != nil || df.Kind != render.KindZone {
|
if pl.out.File(path) != nil || df.Kind != render.KindZone && df.Kind != render.KindPolicy {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
old, existed, _ := readFile(path)
|
old, existed, _ := readFile(path)
|
||||||
@@ -459,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
|
||||||
@@ -484,6 +501,18 @@ func (e *Engine) makePlan(ctx context.Context, base, work *model.State, user str
|
|||||||
if len(work.Zones) != len(base.Zones) {
|
if len(work.Zones) != len(base.Zones) {
|
||||||
pl.configChanged = true
|
pl.configChanged = true
|
||||||
}
|
}
|
||||||
|
// Rules zones and gates whose content changed. New ones come with a
|
||||||
|
// change to named.conf, and reconfig loads them.
|
||||||
|
for _, z := range pl.out.Policy {
|
||||||
|
if z.ListID != 0 || !changedPaths[z.File] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if slices.ContainsFunc(baseOut.Policy, func(o render.PolicyZone) bool {
|
||||||
|
return o.Name == z.Name && o.ViewID == z.ViewID && o.File == z.File
|
||||||
|
}) {
|
||||||
|
pl.reloadPolicy = append(pl.reloadPolicy, z)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if !e.cfg.SkipCheck {
|
if !e.cfg.SkipCheck {
|
||||||
if err := e.stagingCheck(ctx, pl); err != nil {
|
if err := e.stagingCheck(ctx, pl); err != nil {
|
||||||
@@ -505,7 +534,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
|
||||||
@@ -519,6 +548,16 @@ func (e *Engine) stagingCheck(ctx context.Context, pl *plan) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Blocklists as empty policy zones: their content is checked when
|
||||||
|
// they are downloaded.
|
||||||
|
for _, path := range sout.Blocklists {
|
||||||
|
if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(path, []byte(blocking.Placeholder()), 0o640); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
main, err := e.stagingMain(dir, sp.MainConfig())
|
main, err := e.stagingMain(dir, sp.MainConfig())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -796,6 +835,12 @@ func (e *Engine) applyOps(ctx context.Context, cs *model.Changeset, reqs []Reque
|
|||||||
res.ChangesetID = cs.ID
|
res.ChangesetID = cs.ID
|
||||||
res.Applied = true
|
res.Applied = true
|
||||||
e.removeDerived(pl)
|
e.removeDerived(pl)
|
||||||
|
for _, op := range cs.Ops {
|
||||||
|
if strings.HasPrefix(op.Kind, "blocklist.") || op.Kind == OpBlockingPause {
|
||||||
|
e.KickBlocklists()
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
e.log.Info("changeset applied", "changeset", cs.ID, "user", p.Name(), "ops", len(cs.Ops), "files", len(res.Files), "description", cs.Description)
|
e.log.Info("changeset applied", "changeset", cs.ID, "user", p.Name(), "ops", len(cs.Ops), "files", len(res.Files), "description", cs.Description)
|
||||||
return res, nil
|
return res, nil
|
||||||
}
|
}
|
||||||
@@ -829,6 +874,9 @@ func (e *Engine) deployAndReload(ctx context.Context, pl *plan, res *Result, rel
|
|||||||
return &Error{Kind: KindBIND, Message: "writing configuration files failed (nothing was changed): " + err.Error()}
|
return &Error{Kind: KindBIND, Message: "writing configuration files failed (nothing was changed): " + err.Error()}
|
||||||
}
|
}
|
||||||
pl.backup = backup
|
pl.backup = backup
|
||||||
|
if err := e.ensureBlocklistFiles(pl.out.Blocklists); err != nil {
|
||||||
|
e.log.Warn("creating a blocklist's empty zone file failed", "err", err)
|
||||||
|
}
|
||||||
pl.reloaded = reload
|
pl.reloaded = reload
|
||||||
if !reload {
|
if !reload {
|
||||||
return nil
|
return nil
|
||||||
@@ -860,6 +908,11 @@ func (e *Engine) rollback(ctx context.Context, pl *plan) {
|
|||||||
e.log.Error("rndc reload after restore failed", "zone", z.Name, "err", err)
|
e.log.Error("rndc reload after restore failed", "zone", z.Name, "err", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
for _, z := range pl.reloadPolicy {
|
||||||
|
if err := e.tools.ReloadZone(ctx, z.Name, z.View); err != nil {
|
||||||
|
e.log.Error("rndc reload after restore failed", "zone", z.Name, "err", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// namedView returns the view name to pass to rndc for a zone ("" without
|
// namedView returns the view name to pass to rndc for a zone ("" without
|
||||||
@@ -885,6 +938,16 @@ func (e *Engine) reload(ctx context.Context, pl *plan) ([]string, error) {
|
|||||||
}
|
}
|
||||||
done = append(done, "reload "+zoneDesc(pl.work, z))
|
done = append(done, "reload "+zoneDesc(pl.work, z))
|
||||||
}
|
}
|
||||||
|
for _, z := range pl.reloadPolicy {
|
||||||
|
label := "blocking rules"
|
||||||
|
if z.View != "" {
|
||||||
|
label += " [view " + z.View + "]"
|
||||||
|
}
|
||||||
|
if err := e.tools.ReloadZone(ctx, z.Name, z.View); err != nil {
|
||||||
|
return done, fmt.Errorf("reloading %s failed: %v", label, err)
|
||||||
|
}
|
||||||
|
done = append(done, "reload "+label)
|
||||||
|
}
|
||||||
// named reloads zones asynchronously and reports load errors only in
|
// named reloads zones asynchronously and reports load errors only in
|
||||||
// its log, so wait until it serves the new serials.
|
// its log, so wait until it serves the new serials.
|
||||||
deadline := time.Now().Add(e.cfg.ReloadTimeout)
|
deadline := time.Now().Add(e.cfg.ReloadTimeout)
|
||||||
@@ -998,11 +1061,17 @@ 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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
pl.reloadPolicy = nil
|
||||||
|
for _, z := range pl.out.Policy {
|
||||||
|
if z.ListID == 0 {
|
||||||
|
pl.reloadPolicy = append(pl.reloadPolicy, z)
|
||||||
|
}
|
||||||
|
}
|
||||||
reload := !e.cfg.NoReload && !opts.NoReload
|
reload := !e.cfg.NoReload && !opts.NoReload
|
||||||
if adopted, aerr := e.Adopted(); reload && (aerr != nil || !adopted) {
|
if adopted, aerr := e.Adopted(); reload && (aerr != nil || !adopted) {
|
||||||
reload = false
|
reload = false
|
||||||
@@ -1113,6 +1182,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)
|
||||||
@@ -1156,7 +1233,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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,12 +28,18 @@ type Error struct {
|
|||||||
Kind ErrorKind `json:"code"`
|
Kind ErrorKind `json:"code"`
|
||||||
Message string `json:"message"`
|
Message string `json:"message"`
|
||||||
OpSeq int `json:"op,omitempty"` // the operation that failed
|
OpSeq int `json:"op,omitempty"` // the operation that failed
|
||||||
|
// OpSummary describes that operation, so people can find it in the
|
||||||
|
// changeset (record IDs mean little to them).
|
||||||
|
OpSummary string `json:"op_summary,omitempty"`
|
||||||
Problems []validate.Problem `json:"problems,omitempty"`
|
Problems []validate.Problem `json:"problems,omitempty"`
|
||||||
Check []CheckMessage `json:"check,omitempty"`
|
Check []CheckMessage `json:"check,omitempty"`
|
||||||
Diff string `json:"diff,omitempty"`
|
Diff string `json:"diff,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *Error) Error() string {
|
func (e *Error) Error() string {
|
||||||
|
if e.OpSeq > 0 && e.OpSummary != "" {
|
||||||
|
return fmt.Sprintf("operation %d (%s): %s", e.OpSeq, e.OpSummary, e.Message)
|
||||||
|
}
|
||||||
if e.OpSeq > 0 {
|
if e.OpSeq > 0 {
|
||||||
return fmt.Sprintf("operation %d: %s", e.OpSeq, e.Message)
|
return fmt.Sprintf("operation %d: %s", e.OpSeq, e.Message)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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:
|
||||||
@@ -222,6 +228,66 @@ func (x *expander) expand(req Request) ([]model.Op, error) {
|
|||||||
return []model.Op{x.versioned(req, o.Version, summary)}, nil
|
return []model.Op{x.versioned(req, o.Version, summary)}, nil
|
||||||
case OpSettingsUpdate:
|
case OpSettingsUpdate:
|
||||||
return []model.Op{newOpRaw(OpSettingsUpdate, 0, 0, req.Data, "update settings")}, nil
|
return []model.Op{newOpRaw(OpSettingsUpdate, 0, 0, req.Data, "update settings")}, nil
|
||||||
|
case OpBlocklistCreate:
|
||||||
|
return x.blocklistCreate(req)
|
||||||
|
case OpBlocklistUpdate, OpBlocklistDelete:
|
||||||
|
b := x.st.Blocklists[req.ID]
|
||||||
|
if b == nil {
|
||||||
|
return nil, errf(KindNotFound, "blocklist %d does not exist", req.ID)
|
||||||
|
}
|
||||||
|
verb := "change"
|
||||||
|
if req.Op == OpBlocklistDelete {
|
||||||
|
verb = "delete"
|
||||||
|
}
|
||||||
|
return []model.Op{x.versioned(req, b.Version, verb+" blocklist "+b.Name)}, nil
|
||||||
|
case OpBlockingPause:
|
||||||
|
var in BlockingPauseInput
|
||||||
|
if err := decode(req.Data, &in); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
summary := "resume blocking"
|
||||||
|
if in.Until != nil {
|
||||||
|
summary = "pause blocking until " + in.Until.Local().Format("2006-01-02 15:04")
|
||||||
|
} else if in.Indefinitely {
|
||||||
|
summary = "pause blocking until resumed"
|
||||||
|
}
|
||||||
|
return []model.Op{newOpRaw(OpBlockingPause, 0, 0, req.Data, summary)}, nil
|
||||||
|
case OpBlockRuleCreate:
|
||||||
|
return x.blockRuleCreate(req)
|
||||||
|
case OpBlockRuleUpdate, OpBlockRuleDelete:
|
||||||
|
r := x.st.BlockRules[req.ID]
|
||||||
|
if r == nil {
|
||||||
|
return nil, errf(KindNotFound, "rule %d does not exist", req.ID)
|
||||||
|
}
|
||||||
|
verb := "change rule: "
|
||||||
|
if req.Op == OpBlockRuleDelete {
|
||||||
|
verb = "delete rule: "
|
||||||
|
}
|
||||||
|
return []model.Op{x.versioned(req, r.Version, verb+ruleLabel(r))}, nil
|
||||||
|
case OpBlockStepCreate:
|
||||||
|
return x.blockStepCreate(req)
|
||||||
|
case OpBlockStepUpdate, OpBlockStepDelete:
|
||||||
|
s := x.st.BlockSteps[req.ID]
|
||||||
|
if s == nil {
|
||||||
|
return nil, errf(KindNotFound, "blocking step %d does not exist", req.ID)
|
||||||
|
}
|
||||||
|
verb := "change blocking step "
|
||||||
|
if req.Op == OpBlockStepDelete {
|
||||||
|
verb = "delete blocking step "
|
||||||
|
}
|
||||||
|
return []model.Op{x.versioned(req, s.Version, verb+s.Name)}, nil
|
||||||
|
case OpClientGroupCreate:
|
||||||
|
return x.clientGroupCreate(req)
|
||||||
|
case OpClientGroupUpdate, OpClientGroupDelete:
|
||||||
|
g := x.st.ClientGroups[req.ID]
|
||||||
|
if g == nil {
|
||||||
|
return nil, errf(KindNotFound, "client group %d does not exist", req.ID)
|
||||||
|
}
|
||||||
|
verb := "change client group "
|
||||||
|
if req.Op == OpClientGroupDelete {
|
||||||
|
verb = "delete client group "
|
||||||
|
}
|
||||||
|
return []model.Op{x.versioned(req, g.Version, verb+g.Name)}, nil
|
||||||
}
|
}
|
||||||
return nil, errf(KindBadRequest, "unknown operation %q", req.Op)
|
return nil, errf(KindBadRequest, "unknown operation %q", req.Op)
|
||||||
}
|
}
|
||||||
@@ -995,3 +1061,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"
|
||||||
)
|
)
|
||||||
@@ -30,6 +31,21 @@ const (
|
|||||||
OpOptionDelete = "option.delete"
|
OpOptionDelete = "option.delete"
|
||||||
OpSettingsUpdate = "settings.update"
|
OpSettingsUpdate = "settings.update"
|
||||||
|
|
||||||
|
OpBlocklistCreate = "blocklist.create"
|
||||||
|
OpBlocklistUpdate = "blocklist.update"
|
||||||
|
OpBlocklistDelete = "blocklist.delete"
|
||||||
|
OpBlockRuleCreate = "blockrule.create"
|
||||||
|
OpBlockRuleUpdate = "blockrule.update"
|
||||||
|
OpBlockRuleDelete = "blockrule.delete"
|
||||||
|
OpBlockingPause = "blocking.pause"
|
||||||
|
|
||||||
|
OpBlockStepCreate = "blockstep.create"
|
||||||
|
OpBlockStepUpdate = "blockstep.update"
|
||||||
|
OpBlockStepDelete = "blockstep.delete"
|
||||||
|
OpClientGroupCreate = "clientgroup.create"
|
||||||
|
OpClientGroupUpdate = "clientgroup.update"
|
||||||
|
OpClientGroupDelete = "clientgroup.delete"
|
||||||
|
|
||||||
// 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
|
||||||
// operations.
|
// operations.
|
||||||
@@ -40,7 +56,9 @@ const (
|
|||||||
// OpKinds lists all operation kinds.
|
// OpKinds lists all operation kinds.
|
||||||
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, OpBlockingPause,
|
||||||
|
OpBlockStepCreate, OpBlockStepUpdate, OpBlockStepDelete, OpClientGroupCreate, OpClientGroupUpdate, OpClientGroupDelete}
|
||||||
|
|
||||||
// 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"
|
||||||
@@ -98,6 +116,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
|
||||||
@@ -213,3 +233,62 @@ type SettingsInput struct {
|
|||||||
type DeleteInput struct {
|
type DeleteInput struct {
|
||||||
Cascade bool `json:"cascade,omitempty"`
|
Cascade bool `json:"cascade,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// BlocklistInput creates or updates a blocklist. Views are given by name
|
||||||
|
// (Views) or ID (ViewIDs); an empty list means every view that offers
|
||||||
|
// recursion.
|
||||||
|
type BlocklistInput struct {
|
||||||
|
Name *string `json:"name,omitempty"`
|
||||||
|
URL *string `json:"url,omitempty"`
|
||||||
|
Views *[]string `json:"views,omitempty"`
|
||||||
|
ViewIDs *[]int64 `json:"view_ids,omitempty"`
|
||||||
|
Disabled *bool `json:"disabled,omitempty"`
|
||||||
|
Comment *string `json:"comment,omitempty"`
|
||||||
|
// Step is the step the list starts at, by name ("normal" or a step
|
||||||
|
// above it) or ID (StepID; 0 is normal).
|
||||||
|
Step *string `json:"step,omitempty"`
|
||||||
|
StepID *int64 `json:"step_id,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// BlockStepInput creates or updates a blocking step above Normal.
|
||||||
|
// Position orders the steps (new steps go on top).
|
||||||
|
type BlockStepInput struct {
|
||||||
|
Name *string `json:"name,omitempty"`
|
||||||
|
Position *int `json:"position,omitempty"`
|
||||||
|
Comment *string `json:"comment,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClientGroupInput creates or updates a client group. Members are
|
||||||
|
// addresses, prefixes or ACL names; the step is given by name ("off",
|
||||||
|
// "normal" or a step) or ID; views as for BlocklistInput.
|
||||||
|
type ClientGroupInput struct {
|
||||||
|
Name *string `json:"name,omitempty"`
|
||||||
|
Members *[]string `json:"members,omitempty"`
|
||||||
|
Step *string `json:"step,omitempty"`
|
||||||
|
StepID *int64 `json:"step_id,omitempty"`
|
||||||
|
Views *[]string `json:"views,omitempty"`
|
||||||
|
ViewIDs *[]int64 `json:"view_ids,omitempty"`
|
||||||
|
Comment *string `json:"comment,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// BlockRuleInput creates or updates a block or allow rule. Views as for
|
||||||
|
// BlocklistInput.
|
||||||
|
type BlockRuleInput struct {
|
||||||
|
Name *string `json:"name,omitempty"`
|
||||||
|
Action *string `json:"action,omitempty"` // block (default) or allow
|
||||||
|
Exact *bool `json:"exact,omitempty"` // only the name, not the names below it
|
||||||
|
Views *[]string `json:"views,omitempty"`
|
||||||
|
ViewIDs *[]int64 `json:"view_ids,omitempty"`
|
||||||
|
Comment *string `json:"comment,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// BlockingPauseInput pauses blocking until a time, or resumes it (null).
|
||||||
|
type BlockingPauseInput struct {
|
||||||
|
Until *time.Time `json:"until"`
|
||||||
|
// 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
|
||||||
|
}
|
||||||
+68
-3
@@ -11,6 +11,7 @@ import (
|
|||||||
"jarl/internal/auth"
|
"jarl/internal/auth"
|
||||||
"jarl/internal/catalog"
|
"jarl/internal/catalog"
|
||||||
"jarl/internal/dnsname"
|
"jarl/internal/dnsname"
|
||||||
|
"jarl/internal/iprange"
|
||||||
"jarl/internal/model"
|
"jarl/internal/model"
|
||||||
"jarl/internal/namedconf"
|
"jarl/internal/namedconf"
|
||||||
"jarl/internal/rr"
|
"jarl/internal/rr"
|
||||||
@@ -98,11 +99,29 @@ func applyOp(st *model.State, op model.Op, c *applyCtx) error {
|
|||||||
err = applyOptionDelete(st, op, c)
|
err = applyOptionDelete(st, op, c)
|
||||||
case OpSettingsUpdate:
|
case OpSettingsUpdate:
|
||||||
err = applySettings(st, op, c)
|
err = applySettings(st, op, c)
|
||||||
|
case OpBlocklistCreate, OpBlocklistUpdate:
|
||||||
|
err = applyBlocklistPut(st, op, c)
|
||||||
|
case OpBlocklistDelete:
|
||||||
|
err = applyBlocklistDelete(st, op, c)
|
||||||
|
case OpBlockRuleCreate, OpBlockRuleUpdate:
|
||||||
|
err = applyBlockRulePut(st, op, c)
|
||||||
|
case OpBlockRuleDelete:
|
||||||
|
err = applyBlockRuleDelete(st, op, c)
|
||||||
|
case OpBlockStepCreate, OpBlockStepUpdate:
|
||||||
|
err = applyBlockStepPut(st, op, c)
|
||||||
|
case OpBlockStepDelete:
|
||||||
|
err = applyBlockStepDelete(st, op, c)
|
||||||
|
case OpClientGroupCreate, OpClientGroupUpdate:
|
||||||
|
err = applyClientGroupPut(st, op, c)
|
||||||
|
case OpClientGroupDelete:
|
||||||
|
err = applyClientGroupDelete(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)
|
||||||
}
|
}
|
||||||
if e, ok := AsError(err); ok && e.OpSeq == 0 {
|
if e, ok := AsError(err); ok && e.OpSeq == 0 {
|
||||||
e.OpSeq = op.Seq
|
e.OpSeq, e.OpSummary = op.Seq, op.Summary
|
||||||
}
|
}
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -230,6 +249,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) {
|
||||||
@@ -436,7 +458,7 @@ func applyRecordCreate(st *model.State, op model.Op, c *applyCtx) error {
|
|||||||
func applyRecordUpdate(st *model.State, op model.Op, c *applyCtx) error {
|
func applyRecordUpdate(st *model.State, op model.Op, c *applyCtx) error {
|
||||||
r := st.Records[op.ID]
|
r := st.Records[op.ID]
|
||||||
if r == nil {
|
if r == nil {
|
||||||
return errf(KindNotFound, "record %d does not exist", op.ID)
|
return errf(KindConflict, "the record (ID %d) no longer exists: it was deleted after this change was staged; remove this operation from the changeset", op.ID)
|
||||||
}
|
}
|
||||||
if err := checkVersion(op, r.Version, recordLabel(r)); err != nil {
|
if err := checkVersion(op, r.Version, recordLabel(r)); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -512,7 +534,10 @@ func applyRecordDelete(st *model.State, op model.Op, c *applyCtx) error {
|
|||||||
if op.ID != 0 {
|
if op.ID != 0 {
|
||||||
r := st.Records[op.ID]
|
r := st.Records[op.ID]
|
||||||
if r == nil {
|
if r == nil {
|
||||||
return errf(KindNotFound, "record %d does not exist", op.ID)
|
// Deleted since this was staged (by someone else, or the
|
||||||
|
// CLI): what the operation wanted is done.
|
||||||
|
c.warn("%s: the record was already deleted outside this changeset, so there was nothing to delete", strings.TrimPrefix(op.Summary, "delete "))
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
if err := checkVersion(op, r.Version, recordLabel(r)); err != nil {
|
if err := checkVersion(op, r.Version, recordLabel(r)); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -831,6 +856,9 @@ func applyViewDelete(st *model.State, op model.Op, c *applyCtx) error {
|
|||||||
if err := decode(op.Data, &in); err != nil {
|
if err := decode(op.Data, &in); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if users := blockingUsers(st, v.ID); len(users) > 0 {
|
||||||
|
return errf(KindConflict, "view %s is used by %s; change them to other views first", v.Name, strings.Join(users, ", "))
|
||||||
|
}
|
||||||
zones := st.ZonesInView(v.ID)
|
zones := st.ZonesInView(v.ID)
|
||||||
if len(zones) > 0 && !in.Cascade {
|
if len(zones) > 0 && !in.Cascade {
|
||||||
var names []string
|
var names []string
|
||||||
@@ -1111,13 +1139,50 @@ 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)
|
||||||
}
|
}
|
||||||
|
namedconf.JoinRanges(parsed)
|
||||||
|
if err := checkOptionRanges(parsed, strings.Contains(stmt.Grammar, "<address_match_element>")); err != nil {
|
||||||
|
return "", "", errf(KindBadRequest, "option %s: %v", n, err)
|
||||||
|
}
|
||||||
return n, namedconf.Compact(parsed.Items[1:]), nil
|
return n, namedconf.Compact(parsed.Items[1:]), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// checkOptionRanges checks the address ranges in an option's blocks: they
|
||||||
|
// must be valid, and only options with address match lists take them.
|
||||||
|
func checkOptionRanges(st *namedconf.Stmt, aml bool) error {
|
||||||
|
for _, it := range st.Items {
|
||||||
|
if it.Block == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, s := range it.Block.Stmts {
|
||||||
|
items := s.Items
|
||||||
|
if len(items) == 2 && items[0].Bang {
|
||||||
|
items = items[1:]
|
||||||
|
}
|
||||||
|
if len(items) == 1 && items[0].Value != nil && !items[0].Value.Quoted {
|
||||||
|
if _, ok, err := iprange.Parse(items[0].Value.Text); ok {
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !aml {
|
||||||
|
return fmt.Errorf("%s: address ranges only work in address match lists (such as allow-query or an ACL), not here", items[0].Value.Text)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := checkOptionRanges(s, aml); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func applyOptionPut(st *model.State, op model.Op, c *applyCtx) error {
|
func applyOptionPut(st *model.State, op model.Op, c *applyCtx) error {
|
||||||
var in OptionInput
|
var in OptionInput
|
||||||
if err := decode(op.Data, &in); err != nil {
|
if err := decode(op.Data, &in); err != nil {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
package engine
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"jarl/internal/model"
|
||||||
|
"jarl/internal/validate"
|
||||||
|
)
|
||||||
|
|
||||||
|
func hasProblem(ps []validate.Problem, code string) bool {
|
||||||
|
for _, p := range ps {
|
||||||
|
if p.Code == code {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func describeProblems(e *Error) string {
|
||||||
|
var out []string
|
||||||
|
for _, p := range e.Problems {
|
||||||
|
out = append(out, p.Message)
|
||||||
|
}
|
||||||
|
return strings.Join(out, "; ")
|
||||||
|
}
|
||||||
|
|
||||||
|
func optionReq(name, value string) Request {
|
||||||
|
return NewRequest(OpOptionCreate, 0, OptionInput{Scope: model.Ptr(model.ScopeOptions), Name: &name, Value: &value})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAddressRanges(t *testing.T) {
|
||||||
|
env := newEnv(t)
|
||||||
|
setupZone(env)
|
||||||
|
env.submit(env.admin,
|
||||||
|
NewRequest(OpACLCreate, 0, ACLInput{Name: model.Ptr("dhcp"), Elements: &[]string{"!10.0.10.150 - 10.0.10.160", "10.0.10.101-10.0.10.199", "2001:db8::100-2001:db8::1ff"}}),
|
||||||
|
optionReq("allow-query", "10.0.20.1 - 10.0.20.6; localhost"),
|
||||||
|
)
|
||||||
|
st := env.state()
|
||||||
|
if a := st.ACLByName("dhcp"); strings.Join(a.Elements, " ") != "!10.0.10.150-10.0.10.160 10.0.10.101-10.0.10.199 2001:db8::100-2001:db8::1ff" {
|
||||||
|
t.Errorf("stored elements = %q", a.Elements)
|
||||||
|
}
|
||||||
|
if o := st.FindOption(model.ScopeOptions, 0, "allow-query"); o == nil || o.Value != "{ 10.0.20.1-10.0.20.6; localhost; }" {
|
||||||
|
t.Errorf("stored option = %+v", o)
|
||||||
|
}
|
||||||
|
conf, _ := os.ReadFile(filepath.Join(env.dir, "etc/named.conf"))
|
||||||
|
c := string(conf)
|
||||||
|
for _, want := range []string{
|
||||||
|
"acl \"dhcp\" {\n\t// !10.0.10.150-10.0.10.160\n\t!10.0.10.150/31;\n\t!10.0.10.152/29;\n\t!10.0.10.160;\n\t// 10.0.10.101-10.0.10.199\n\t10.0.10.101;\n",
|
||||||
|
"\t10.0.10.192/29;\n\t// 2001:db8::100-2001:db8::1ff\n\t2001:db8::100/120;\n};\n",
|
||||||
|
"\tallow-query {\n\t\t// 10.0.20.1-10.0.20.6\n\t\t10.0.20.1;\n\t\t10.0.20.2/31;\n\t\t10.0.20.4/31;\n\t\t10.0.20.6;\n\t\tlocalhost;\n\t};\n",
|
||||||
|
} {
|
||||||
|
if !strings.Contains(c, want) {
|
||||||
|
t.Errorf("named.conf lacks %q:\n%s", want, c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, l := range strings.Split(c, "\n") {
|
||||||
|
if code, _, _ := strings.Cut(l, "//"); strings.Contains(code, "-10.0.") || strings.Contains(code, "-2001:") {
|
||||||
|
t.Errorf("a range reached named.conf outside a comment: %q", l)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mistakes are caught when they're entered.
|
||||||
|
for _, tc := range []struct {
|
||||||
|
req Request
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{NewRequest(OpACLCreate, 0, ACLInput{Name: model.Ptr("bad"), Elements: &[]string{"10.0.10.199-10.0.10.101"}}), "start is after the end"},
|
||||||
|
{NewRequest(OpACLCreate, 0, ACLInput{Name: model.Ptr("bad"), Elements: &[]string{"10.0.10.101-199"}}), "not a full IP address"},
|
||||||
|
{optionReq("forwarders", "{ 192.0.2.1-192.0.2.9; }"), "only work in address match lists"},
|
||||||
|
{optionReq("allow-recursion", "{ 10.0.0.9-10.0.0.1; }"), "start is after the end"},
|
||||||
|
{NewRequest(OpACLCreate, 0, ACLInput{Name: model.Ptr("10.0.0.1-10.0.0.9"), Elements: &[]string{"10.0.0.1"}}), "looks like an address range"},
|
||||||
|
} {
|
||||||
|
if e := env.submitErr(env.admin, tc.req); !strings.Contains(e.Error(), tc.want) && !strings.Contains(describeProblems(e), tc.want) {
|
||||||
|
t.Errorf("%s: %v %s, want %q", tc.req.Op, e, describeProblems(e), tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validation sees what a range covers: everything is "any".
|
||||||
|
res := env.submit(env.admin, optionReq("allow-recursion", "0.0.0.0-255.255.255.255"))
|
||||||
|
if !hasProblem(res.Problems, "security_open_resolver") {
|
||||||
|
t.Errorf("no open resolver warning for 0.0.0.0-255.255.255.255: %+v", res.Problems)
|
||||||
|
}
|
||||||
|
// IPv6 ranges in the IPv4-mapped space get a warning.
|
||||||
|
res = env.submit(env.admin, NewRequest(OpACLCreate, 0, ACLInput{Name: model.Ptr("mapped"), Elements: &[]string{"::ffff:10.0.0.1-::ffff:10.0.0.9"}}))
|
||||||
|
if !hasProblem(res.Problems, "range_mapped4") {
|
||||||
|
t.Errorf("no range_mapped4 warning: %+v", res.Problems)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClientGroupRanges(t *testing.T) {
|
||||||
|
env := newEnv(t)
|
||||||
|
setupZone(env)
|
||||||
|
_, srv := newListServer(t, "||ads.example^\n")
|
||||||
|
env.submit(env.admin,
|
||||||
|
NewRequest(OpACLCreate, 0, ACLInput{Name: model.Ptr("tablets"), Elements: &[]string{"10.0.0.50-10.0.0.51"}}),
|
||||||
|
)
|
||||||
|
env.submit(env.admin, blocklist("ads", srv.URL+"/a.txt"), clientGroup("kids", "off", "tablets", "10.0.0.20 - 10.0.0.23"))
|
||||||
|
if g := env.state().SortedClientGroups(); len(g) != 1 || strings.Join(g[0].Members, " ") != "tablets 10.0.0.20-10.0.0.23" {
|
||||||
|
t.Fatalf("groups = %+v", g)
|
||||||
|
}
|
||||||
|
b, err := os.ReadFile(filepath.Join(env.dir, "zones/_default/off.gate.jarl.invalid.rpz"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, want := range []string{"31.50.0.0.10.rpz-client-ip", "30.20.0.0.10.rpz-client-ip"} {
|
||||||
|
if !strings.Contains(string(b), want) {
|
||||||
|
t.Errorf("off gate lacks %s:\n%s", want, b)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -152,6 +152,11 @@ func FindRefs(st *model.State, kind refKind, name string) []string {
|
|||||||
out = append(out, "view "+v.Name)
|
out = append(out, "view "+v.Name)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
for _, g := range st.SortedClientGroups() {
|
||||||
|
if _, hit := rewriteElements(g.Members, kind, name, name); hit {
|
||||||
|
out = append(out, "client group "+g.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
var opts []string
|
var opts []string
|
||||||
for _, o := range st.Options {
|
for _, o := range st.Options {
|
||||||
stmt, err := namedconf.ParseOptionValue(o.Name, o.Value)
|
stmt, err := namedconf.ParseOptionValue(o.Name, o.Value)
|
||||||
@@ -188,6 +193,12 @@ func renameRefs(st *model.State, kind refKind, old, new string, c *applyCtx) {
|
|||||||
v.Touch(c.user(), c.now)
|
v.Touch(c.user(), c.now)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
for _, g := range st.ClientGroups {
|
||||||
|
if l, hit := rewriteElements(g.Members, kind, old, new); hit {
|
||||||
|
g.Members = l
|
||||||
|
g.Touch(c.user(), c.now)
|
||||||
|
}
|
||||||
|
}
|
||||||
for _, o := range st.Options {
|
for _, o := range st.Options {
|
||||||
stmt, err := namedconf.ParseOptionValue(o.Name, o.Value)
|
stmt, err := namedconf.ParseOptionValue(o.Name, o.Value)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -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,51 @@
|
|||||||
|
package engine
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"jarl/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Records deleted or changed after a change to them was staged (in the
|
||||||
|
// CLI while a web UI changeset is open, say).
|
||||||
|
func TestStaleChangeset(t *testing.T) {
|
||||||
|
env := newEnv(t)
|
||||||
|
setupZone(env)
|
||||||
|
env.submit(env.admin, rec("example.com", "one", "A", "192.0.2.51"), rec("example.com", "two", "A", "192.0.2.52"), rec("example.com", "three", "A", "192.0.2.53"))
|
||||||
|
st := env.state()
|
||||||
|
one, two, three := findRec(st, "one.example.com.", "A"), findRec(st, "two.example.com.", "A"), findRec(st, "three.example.com.", "A")
|
||||||
|
|
||||||
|
// Two deletes staged; one of the records is deleted elsewhere first.
|
||||||
|
cs, err := env.e.CreateChangeset(env.ctx, env.admin, "web", "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := env.e.Stage(env.ctx, cs.ID, []Request{NewRequest(OpRecordDelete, one.ID, nil), NewRequest(OpRecordDelete, two.ID, nil)}, env.admin); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
env.submit(env.admin, NewRequest(OpRecordDelete, one.ID, nil))
|
||||||
|
res, err := env.e.Apply(env.ctx, cs.ID, env.admin, ApplyOptions{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("apply after the record was deleted elsewhere: %v", err)
|
||||||
|
}
|
||||||
|
if len(res.Warnings) != 1 || !strings.Contains(res.Warnings[0], "one.example.com. A 192.0.2.51: the record was already deleted") {
|
||||||
|
t.Errorf("warnings = %q", res.Warnings)
|
||||||
|
}
|
||||||
|
if st := env.state(); st.Records[two.ID] != nil {
|
||||||
|
t.Error("two was not deleted")
|
||||||
|
}
|
||||||
|
|
||||||
|
// A staged edit of a record deleted elsewhere names the operation.
|
||||||
|
cs, _ = env.e.CreateChangeset(env.ctx, env.admin, "web", "")
|
||||||
|
env.e.Stage(env.ctx, cs.ID, []Request{NewRequest(OpRecordUpdate, three.ID, RecordInput{Data: model.Ptr("192.0.2.54")})}, env.admin)
|
||||||
|
env.submit(env.admin, NewRequest(OpRecordDelete, three.ID, nil))
|
||||||
|
p, err := env.e.Preview(env.ctx, cs.ID, env.admin)
|
||||||
|
if err != nil || p.Error == nil || p.Error.OpSeq != 1 || !strings.HasPrefix(p.Error.OpSummary, "change three.example.com. A 192.0.2.53") {
|
||||||
|
t.Fatalf("preview = %+v, %v", p, err)
|
||||||
|
}
|
||||||
|
_, err = env.e.Apply(env.ctx, cs.ID, env.admin, ApplyOptions{})
|
||||||
|
if ee, ok := AsError(err); !ok || ee.Kind != KindConflict || !strings.Contains(err.Error(), "operation 1 (change three.example.com. A") || !strings.Contains(err.Error(), "remove this operation") {
|
||||||
|
t.Errorf("apply = %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
// Package iprange handles address ranges such as 10.0.10.101-10.0.10.199
|
||||||
|
// or 2001:db8::100-2001:db8::1ff. BIND matches only addresses and
|
||||||
|
// prefixes, so Jarl keeps a range as it was written and turns it into the
|
||||||
|
// exact set of prefixes that covers it when it writes named.conf.
|
||||||
|
package iprange
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"math/big"
|
||||||
|
"net/netip"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Range is an inclusive range of addresses of one family.
|
||||||
|
type Range struct {
|
||||||
|
Start, End netip.Addr
|
||||||
|
}
|
||||||
|
|
||||||
|
// String returns the range as Jarl writes it: START-END.
|
||||||
|
func (r Range) String() string { return r.Start.String() + "-" + r.End.String() }
|
||||||
|
|
||||||
|
// Parse parses START-END, both full addresses of the same family. ok is
|
||||||
|
// false when s isn't meant as a range (no "-", or text before it that
|
||||||
|
// isn't an address, such as the ACL name "branch-office"); err is set when
|
||||||
|
// it is meant as one but is wrong.
|
||||||
|
func Parse(s string) (r Range, ok bool, err error) {
|
||||||
|
left, right, found := strings.Cut(s, "-")
|
||||||
|
if !found {
|
||||||
|
return Range{}, false, nil
|
||||||
|
}
|
||||||
|
left, right = strings.TrimSpace(left), strings.TrimSpace(right)
|
||||||
|
if !looksLikeAddr(left) {
|
||||||
|
return Range{}, false, nil
|
||||||
|
}
|
||||||
|
start, err := netip.ParseAddr(left)
|
||||||
|
if err != nil {
|
||||||
|
return Range{}, true, fmt.Errorf("range %q: %q is not a full IP address", s, left)
|
||||||
|
}
|
||||||
|
end, err := netip.ParseAddr(right)
|
||||||
|
if err != nil {
|
||||||
|
if right == "" {
|
||||||
|
return Range{}, true, fmt.Errorf("range %q has no end address", s)
|
||||||
|
}
|
||||||
|
return Range{}, true, fmt.Errorf("range %q: %q is not a full IP address (write both ends in full, e.g. 10.0.10.101-10.0.10.199)", s, right)
|
||||||
|
}
|
||||||
|
if start.Zone() != "" || end.Zone() != "" {
|
||||||
|
return Range{}, true, fmt.Errorf("range %q: addresses with a zone (%%interface) can't be matched", s)
|
||||||
|
}
|
||||||
|
if start.Is4() != end.Is4() {
|
||||||
|
return Range{}, true, fmt.Errorf("range %q mixes IPv4 and IPv6", s)
|
||||||
|
}
|
||||||
|
if end.Less(start) {
|
||||||
|
return Range{}, true, fmt.Errorf("range %q: the start is after the end", s)
|
||||||
|
}
|
||||||
|
return Range{Start: start, End: end}, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// looksLikeAddr reports whether s could only be meant as an address: it
|
||||||
|
// has a colon, or it is digits and dots with at least one dot.
|
||||||
|
func looksLikeAddr(s string) bool {
|
||||||
|
if strings.Contains(s, ":") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if !strings.Contains(s, ".") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for i := 0; i < len(s); i++ {
|
||||||
|
if c := s[i]; !(c >= '0' && c <= '9' || c == '.') {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prefixes returns the fewest prefixes that together cover exactly the
|
||||||
|
// range, in address order: 10.0.10.101-10.0.10.199 is 10.0.10.101/32,
|
||||||
|
// 10.0.10.102/31, 10.0.10.104/29, 10.0.10.112/28, 10.0.10.128/26 and
|
||||||
|
// 10.0.10.192/29. That is at most 62 prefixes for IPv4 and 254 for IPv6.
|
||||||
|
func (r Range) Prefixes() []netip.Prefix {
|
||||||
|
var out []netip.Prefix
|
||||||
|
a := r.Start
|
||||||
|
for {
|
||||||
|
// The largest prefix that starts at a and ends at or before End.
|
||||||
|
p := netip.PrefixFrom(a, a.BitLen())
|
||||||
|
for bits := a.BitLen() - 1; bits >= 0; bits-- {
|
||||||
|
q := netip.PrefixFrom(a, bits)
|
||||||
|
if q.Masked().Addr() != a || r.End.Less(last(q)) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
p = q
|
||||||
|
}
|
||||||
|
out = append(out, p)
|
||||||
|
end := last(p)
|
||||||
|
if end == r.End {
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
a = end.Next()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// last returns the last address of a prefix.
|
||||||
|
func last(p netip.Prefix) netip.Addr {
|
||||||
|
b := p.Addr().AsSlice()
|
||||||
|
for i := p.Bits(); i < len(b)*8; i++ {
|
||||||
|
b[i/8] |= 0x80 >> (i % 8)
|
||||||
|
}
|
||||||
|
a, _ := netip.AddrFromSlice(b)
|
||||||
|
return a
|
||||||
|
}
|
||||||
|
|
||||||
|
// Size returns the number of addresses in the range.
|
||||||
|
func (r Range) Size() *big.Int {
|
||||||
|
s := new(big.Int).SetBytes(r.Start.AsSlice())
|
||||||
|
e := new(big.Int).SetBytes(r.End.AsSlice())
|
||||||
|
return e.Sub(e, s).Add(e, big.NewInt(1))
|
||||||
|
}
|
||||||
|
|
||||||
|
// SizeText describes the size for people: "99 addresses", or a power of
|
||||||
|
// two for very large IPv6 ranges ("about 2^80 addresses").
|
||||||
|
func (r Range) SizeText() string {
|
||||||
|
n := r.Size()
|
||||||
|
if n.BitLen() <= 40 {
|
||||||
|
if n.Int64() == 1 {
|
||||||
|
return "1 address"
|
||||||
|
}
|
||||||
|
return n.String() + " addresses"
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("about 2^%d addresses", n.BitLen()-1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mapped4 is the IPv4-mapped IPv6 space; BIND can see IPv4 clients as
|
||||||
|
// addresses in it.
|
||||||
|
var Mapped4 = netip.MustParsePrefix("::ffff:0:0/96")
|
||||||
|
|
||||||
|
// TouchesMapped4 reports whether an IPv6 range overlaps ::ffff:0:0/96.
|
||||||
|
func (r Range) TouchesMapped4() bool {
|
||||||
|
if r.Start.Is4() {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
lo, hi := Mapped4.Addr(), last(Mapped4)
|
||||||
|
return !(r.End.Less(lo) || hi.Less(r.Start))
|
||||||
|
}
|
||||||
|
|
||||||
|
// FromPrefix returns the addresses of a prefix as a range.
|
||||||
|
func FromPrefix(p netip.Prefix) Range {
|
||||||
|
p = p.Masked()
|
||||||
|
return Range{Start: p.Addr(), End: last(p)}
|
||||||
|
}
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
package iprange
|
||||||
|
|
||||||
|
import (
|
||||||
|
"math/rand"
|
||||||
|
"net/netip"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestParse(t *testing.T) {
|
||||||
|
for _, tc := range []struct {
|
||||||
|
in, want, err string
|
||||||
|
ok bool
|
||||||
|
}{
|
||||||
|
{in: "10.0.10.101-10.0.10.199", want: "10.0.10.101-10.0.10.199", ok: true},
|
||||||
|
{in: "10.0.10.101 - 10.0.10.199", want: "10.0.10.101-10.0.10.199", ok: true},
|
||||||
|
{in: "2001:DB8::100-2001:db8::1ff", want: "2001:db8::100-2001:db8::1ff", ok: true},
|
||||||
|
{in: "10.0.0.5-10.0.0.5", want: "10.0.0.5-10.0.0.5", ok: true},
|
||||||
|
{in: "branch-office", ok: false},
|
||||||
|
{in: "10-net", ok: false},
|
||||||
|
{in: "10.0.0.0/24", ok: false},
|
||||||
|
{in: "10.0.10.101-199", ok: true, err: "not a full IP address"},
|
||||||
|
{in: "10.0.10.101-", ok: true, err: "no end address"},
|
||||||
|
{in: "2001:db8::100-1ff", ok: true, err: "not a full IP address"},
|
||||||
|
{in: "10.0.0.9-10.0.0.1", ok: true, err: "start is after the end"},
|
||||||
|
{in: "10.0.0.1-2001:db8::1", ok: true, err: "mixes IPv4 and IPv6"},
|
||||||
|
{in: "fe80::1%eth0-fe80::9%eth0", ok: true, err: "zone"},
|
||||||
|
{in: "10.0.300.1-10.0.300.9", ok: true, err: "not a full IP address"},
|
||||||
|
{in: "10.0.0.1-10.0.0.9-10.0.0.20", ok: true, err: "not a full IP address"},
|
||||||
|
} {
|
||||||
|
r, ok, err := Parse(tc.in)
|
||||||
|
if ok != tc.ok || (err == nil) != (tc.err == "") || err != nil && !strings.Contains(err.Error(), tc.err) {
|
||||||
|
t.Errorf("%s: ok=%v err=%v, want ok=%v err~%q", tc.in, ok, err, tc.ok, tc.err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err == nil && ok && r.String() != tc.want {
|
||||||
|
t.Errorf("%s = %s, want %s", tc.in, r, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPrefixes(t *testing.T) {
|
||||||
|
for in, want := range map[string]string{
|
||||||
|
"10.0.10.101-10.0.10.199": "10.0.10.101/32 10.0.10.102/31 10.0.10.104/29 10.0.10.112/28 10.0.10.128/26 10.0.10.192/29",
|
||||||
|
"10.0.0.0-10.0.0.255": "10.0.0.0/24",
|
||||||
|
"10.0.0.5-10.0.0.5": "10.0.0.5/32",
|
||||||
|
"0.0.0.0-255.255.255.255": "0.0.0.0/0",
|
||||||
|
"255.255.255.254-255.255.255.255": "255.255.255.254/31",
|
||||||
|
"2001:db8::100-2001:db8::1ff": "2001:db8::100/120",
|
||||||
|
"::-ffff:ffff:ffff:ffff:ffff:ffff:ffff:ffff": "::/0",
|
||||||
|
} {
|
||||||
|
r, _, err := Parse(in)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var got []string
|
||||||
|
for _, p := range r.Prefixes() {
|
||||||
|
got = append(got, p.String())
|
||||||
|
}
|
||||||
|
if strings.Join(got, " ") != want {
|
||||||
|
t.Errorf("%s = %s, want %s", in, strings.Join(got, " "), want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// The worst cases stay small.
|
||||||
|
if n := len(must(t, "0.0.0.1-255.255.255.254").Prefixes()); n != 62 {
|
||||||
|
t.Errorf("worst IPv4 case = %d prefixes, want 62", n)
|
||||||
|
}
|
||||||
|
if n := len(must(t, "::1-ffff:ffff:ffff:ffff:ffff:ffff:ffff:fffe").Prefixes()); n != 254 {
|
||||||
|
t.Errorf("worst IPv6 case = %d prefixes, want 254", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every address of a random range is covered once, and nothing outside it.
|
||||||
|
func TestPrefixesExact(t *testing.T) {
|
||||||
|
rnd := rand.New(rand.NewSource(1))
|
||||||
|
for i := 0; i < 300; i++ {
|
||||||
|
lo, hi := rnd.Intn(4096), rnd.Intn(4096)
|
||||||
|
if hi < lo {
|
||||||
|
lo, hi = hi, lo
|
||||||
|
}
|
||||||
|
base := []byte{10, 20, 0, 0}
|
||||||
|
addr := func(n int) netip.Addr {
|
||||||
|
return netip.AddrFrom4([4]byte{base[0], base[1], byte(n >> 8), byte(n)})
|
||||||
|
}
|
||||||
|
r := Range{Start: addr(lo), End: addr(hi)}
|
||||||
|
ps := r.Prefixes()
|
||||||
|
for n := max(lo-3, 0); n <= min(hi+3, 4095); n++ {
|
||||||
|
c := 0
|
||||||
|
for _, p := range ps {
|
||||||
|
if p.Contains(addr(n)) {
|
||||||
|
c++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if in := n >= lo && n <= hi; in && c != 1 || !in && c != 0 {
|
||||||
|
t.Fatalf("%s: %s covered %d times (%v)", r, addr(n), c, ps)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if got := r.Size().Int64(); got != int64(hi-lo+1) {
|
||||||
|
t.Fatalf("%s: size %d", r, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSizeAndMapped(t *testing.T) {
|
||||||
|
if s := must(t, "10.0.10.101-10.0.10.199").SizeText(); s != "99 addresses" {
|
||||||
|
t.Errorf("size = %s", s)
|
||||||
|
}
|
||||||
|
if s := must(t, "2001:db8::-2001:db8:0:ffff:ffff:ffff:ffff:ffff").SizeText(); s != "about 2^80 addresses" {
|
||||||
|
t.Errorf("size = %s", s)
|
||||||
|
}
|
||||||
|
if !must(t, "::ffff:10.0.0.1-::ffff:10.0.0.9").TouchesMapped4() || must(t, "2001:db8::1-2001:db8::9").TouchesMapped4() || must(t, "10.0.0.1-10.0.0.9").TouchesMapped4() {
|
||||||
|
t.Error("TouchesMapped4")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func must(t *testing.T, s string) Range {
|
||||||
|
t.Helper()
|
||||||
|
r, ok, err := Parse(s)
|
||||||
|
if !ok || err != nil {
|
||||||
|
t.Fatalf("%s: %v %v", s, ok, err)
|
||||||
|
}
|
||||||
|
return r
|
||||||
|
}
|
||||||
@@ -0,0 +1,283 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/netip"
|
||||||
|
"slices"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"jarl/internal/iprange"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Blocklist is a list of domains to block, downloaded from URL and kept up
|
||||||
|
// to date by jarld (docs/blocking.md).
|
||||||
|
type Blocklist struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Name string `json:"name"` // a DNS label: it names the list's policy zone
|
||||||
|
URL string `json:"url"`
|
||||||
|
// ViewIDs are the views the list applies to; empty means every view
|
||||||
|
// that offers recursion (see BlockingViews).
|
||||||
|
ViewIDs []int64 `json:"view_ids"`
|
||||||
|
Disabled bool `json:"disabled,omitempty"`
|
||||||
|
// StepID is the blocking step the list starts at: StepNormal for
|
||||||
|
// every client, or a BlockStep for the clients of groups at that step
|
||||||
|
// or above.
|
||||||
|
StepID int64 `json:"step_id"`
|
||||||
|
Comment string `json:"comment"`
|
||||||
|
Meta
|
||||||
|
}
|
||||||
|
|
||||||
|
// Built-in blocking steps (BlockStep IDs are positive).
|
||||||
|
const (
|
||||||
|
StepOff int64 = -1 // no blocklists (rules still apply)
|
||||||
|
StepNormal int64 = 0 // the default: every client not in a group
|
||||||
|
)
|
||||||
|
|
||||||
|
// BlockStep is a blocking step above Normal. Steps are ordered: each
|
||||||
|
// includes the lists of the steps below it, plus its own (docs/blocking.md).
|
||||||
|
type BlockStep struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Position int `json:"position"`
|
||||||
|
Comment string `json:"comment"`
|
||||||
|
Meta
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClientGroup gives the clients with these addresses a blocking step.
|
||||||
|
type ClientGroup struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
// Members are addresses, prefixes, or names of ACLs made of them.
|
||||||
|
Members []string `json:"members"`
|
||||||
|
StepID int64 `json:"step_id"` // StepOff, StepNormal or a BlockStep
|
||||||
|
ViewIDs []int64 `json:"view_ids"` // as for Blocklist
|
||||||
|
Comment string `json:"comment"`
|
||||||
|
Meta
|
||||||
|
}
|
||||||
|
|
||||||
|
// OrderedBlockSteps returns the steps above Normal, lowest first.
|
||||||
|
func (s *State) OrderedBlockSteps() []*BlockStep {
|
||||||
|
out := make([]*BlockStep, 0, len(s.BlockSteps))
|
||||||
|
for _, st := range s.BlockSteps {
|
||||||
|
out = append(out, st)
|
||||||
|
}
|
||||||
|
sort.Slice(out, func(i, j int) bool {
|
||||||
|
if out[i].Position != out[j].Position {
|
||||||
|
return out[i].Position < out[j].Position
|
||||||
|
}
|
||||||
|
return out[i].ID < out[j].ID
|
||||||
|
})
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// StepRank orders steps: -1 for Off, 0 for Normal, 1, 2, ... for the
|
||||||
|
// steps above it. ok is false for a step that doesn't exist.
|
||||||
|
func (s *State) StepRank(id int64) (rank int, ok bool) {
|
||||||
|
switch id {
|
||||||
|
case StepOff:
|
||||||
|
return -1, true
|
||||||
|
case StepNormal:
|
||||||
|
return 0, true
|
||||||
|
}
|
||||||
|
for i, st := range s.OrderedBlockSteps() {
|
||||||
|
if st.ID == id {
|
||||||
|
return i + 1, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// StepName returns a step's name ("off" and "normal" for the built-in
|
||||||
|
// ones).
|
||||||
|
func (s *State) StepName(id int64) string {
|
||||||
|
switch id {
|
||||||
|
case StepOff:
|
||||||
|
return "off"
|
||||||
|
case StepNormal:
|
||||||
|
return "normal"
|
||||||
|
}
|
||||||
|
if st := s.BlockSteps[id]; st != nil {
|
||||||
|
return st.Name
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("step %d", id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// StepByName finds a step by name, the built-in ones included.
|
||||||
|
func (s *State) StepByName(name string) (int64, bool) {
|
||||||
|
name = strings.ToLower(strings.TrimSpace(name))
|
||||||
|
switch name {
|
||||||
|
case "off":
|
||||||
|
return StepOff, true
|
||||||
|
case "normal":
|
||||||
|
return StepNormal, true
|
||||||
|
}
|
||||||
|
for _, st := range s.BlockSteps {
|
||||||
|
if strings.EqualFold(st.Name, name) {
|
||||||
|
return st.ID, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClientGroupByName finds a client group by name.
|
||||||
|
func (s *State) ClientGroupByName(name string) *ClientGroup {
|
||||||
|
for _, g := range s.ClientGroups {
|
||||||
|
if strings.EqualFold(g.Name, name) {
|
||||||
|
return g
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SortedClientGroups returns the client groups ordered by name.
|
||||||
|
func (s *State) SortedClientGroups() []*ClientGroup {
|
||||||
|
out := make([]*ClientGroup, 0, len(s.ClientGroups))
|
||||||
|
for _, g := range s.ClientGroups {
|
||||||
|
out = append(out, g)
|
||||||
|
}
|
||||||
|
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// Block rule actions.
|
||||||
|
const (
|
||||||
|
BlockActionBlock = "block"
|
||||||
|
BlockActionAllow = "allow"
|
||||||
|
)
|
||||||
|
|
||||||
|
// BlockRule blocks or allows a domain, and unless Exact the names below
|
||||||
|
// it. Rules take precedence over blocklists.
|
||||||
|
type BlockRule struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Name string `json:"name"` // lowercase, without the final dot
|
||||||
|
Action string `json:"action"` // block or allow
|
||||||
|
Exact bool `json:"exact,omitempty"`
|
||||||
|
ViewIDs []int64 `json:"view_ids"` // as for Blocklist
|
||||||
|
Comment string `json:"comment"`
|
||||||
|
Meta
|
||||||
|
}
|
||||||
|
|
||||||
|
// BlocklistStatus is what jarld knows about a list's downloads. It is not
|
||||||
|
// configuration: it is kept outside changesets.
|
||||||
|
type BlocklistStatus struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
URL string `json:"url"` // the URL last tried
|
||||||
|
ETag string `json:"-"`
|
||||||
|
LastModified string `json:"-"`
|
||||||
|
CheckedAt *time.Time `json:"checked_at,omitempty"` // last attempt
|
||||||
|
UpdatedAt *time.Time `json:"updated_at,omitempty"` // last time the content changed
|
||||||
|
Entries int `json:"entries"` // domains
|
||||||
|
Records int `json:"records"` // policy zone records
|
||||||
|
Skipped int `json:"skipped"` // lines that weren't usable
|
||||||
|
SHA256 string `json:"-"`
|
||||||
|
Error string `json:"error,omitempty"` // the last attempt failed
|
||||||
|
}
|
||||||
|
|
||||||
|
// BlocklistByName finds a blocklist by name.
|
||||||
|
func (s *State) BlocklistByName(name string) *Blocklist {
|
||||||
|
for _, b := range s.Blocklists {
|
||||||
|
if strings.EqualFold(b.Name, name) {
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SortedBlocklists returns the blocklists ordered by name.
|
||||||
|
func (s *State) SortedBlocklists() []*Blocklist {
|
||||||
|
out := make([]*Blocklist, 0, len(s.Blocklists))
|
||||||
|
for _, b := range s.Blocklists {
|
||||||
|
out = append(out, b)
|
||||||
|
}
|
||||||
|
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// SortedBlockRules returns the rules ordered by domain, parents first.
|
||||||
|
func (s *State) SortedBlockRules() []*BlockRule {
|
||||||
|
out := make([]*BlockRule, 0, len(s.BlockRules))
|
||||||
|
for _, r := range s.BlockRules {
|
||||||
|
out = append(out, r)
|
||||||
|
}
|
||||||
|
sort.Slice(out, func(i, j int) bool {
|
||||||
|
a, b := out[i], out[j]
|
||||||
|
if a.Name != b.Name {
|
||||||
|
return CanonicalLess(a.Name+".", b.Name+".")
|
||||||
|
}
|
||||||
|
if a.Exact != b.Exact {
|
||||||
|
return !a.Exact
|
||||||
|
}
|
||||||
|
return a.ID < b.ID
|
||||||
|
})
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// RecursionOn reports whether a view (0: the server without views) offers
|
||||||
|
// recursion: its recursion option, else the server's, else BIND's default
|
||||||
|
// (yes).
|
||||||
|
func (s *State) RecursionOn(viewID int64) bool {
|
||||||
|
o := s.FindOption(ScopeView, viewID, "recursion")
|
||||||
|
if viewID == 0 || o == nil {
|
||||||
|
if g := s.FindOption(ScopeOptions, 0, "recursion"); o == nil {
|
||||||
|
o = g
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if o == nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
switch strings.ToLower(strings.Trim(strings.TrimSpace(o.Value), `";`)) {
|
||||||
|
case "no", "false", "0":
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// BlockingViews returns the views a blocklist or rule with viewIDs applies
|
||||||
|
// to, in view order: the given views, or when none are given every view
|
||||||
|
// that offers recursion (blocking only affects recursive queries). Without
|
||||||
|
// views it returns []int64{0}, the whole server, if it offers recursion.
|
||||||
|
func (s *State) BlockingViews(viewIDs []int64) []int64 {
|
||||||
|
views := s.OrderedViews()
|
||||||
|
if len(views) == 0 {
|
||||||
|
if !s.RecursionOn(0) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return []int64{0}
|
||||||
|
}
|
||||||
|
var out []int64
|
||||||
|
for _, v := range views {
|
||||||
|
if len(viewIDs) == 0 && s.RecursionOn(v.ID) || slices.Contains(viewIDs, v.ID) {
|
||||||
|
out = append(out, v.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// GroupPrefixes returns the addresses of a client group as prefixes: its
|
||||||
|
// own, and those of the ACLs it names (and the ACLs they name). Elements
|
||||||
|
// that aren't addresses are left out (validation reports them).
|
||||||
|
func (s *State) GroupPrefixes(g *ClientGroup) []netip.Prefix {
|
||||||
|
var out []netip.Prefix
|
||||||
|
seen := map[string]bool{}
|
||||||
|
var add func(elems []string)
|
||||||
|
add = func(elems []string) {
|
||||||
|
for _, e := range elems {
|
||||||
|
e = strings.TrimSpace(e)
|
||||||
|
if a, err := netip.ParseAddr(e); err == nil {
|
||||||
|
a = a.Unmap()
|
||||||
|
out = append(out, netip.PrefixFrom(a, a.BitLen()))
|
||||||
|
} else if p, err := netip.ParsePrefix(e); err == nil {
|
||||||
|
out = append(out, p.Masked())
|
||||||
|
} else if r, ok, err := iprange.Parse(e); ok && err == nil {
|
||||||
|
out = append(out, r.Prefixes()...)
|
||||||
|
} else if acl := s.ACLByName(e); acl != nil && !seen[acl.Name] {
|
||||||
|
seen[acl.Name] = true
|
||||||
|
add(acl.Elements)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
add(g.Members)
|
||||||
|
return out
|
||||||
|
}
|
||||||
+68
-5
@@ -9,6 +9,7 @@ package model
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"slices"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -128,6 +129,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 +212,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{
|
||||||
@@ -232,6 +247,11 @@ type State struct {
|
|||||||
Zones map[int64]*Zone
|
Zones map[int64]*Zone
|
||||||
Records map[int64]*Record
|
Records map[int64]*Record
|
||||||
Options map[int64]*Option
|
Options map[int64]*Option
|
||||||
|
// Blocking (docs/blocking.md).
|
||||||
|
Blocklists map[int64]*Blocklist
|
||||||
|
BlockRules map[int64]*BlockRule
|
||||||
|
BlockSteps map[int64]*BlockStep
|
||||||
|
ClientGroups map[int64]*ClientGroup
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewState returns an empty state with default settings.
|
// NewState returns an empty state with default settings.
|
||||||
@@ -244,6 +264,11 @@ func NewState() *State {
|
|||||||
Zones: map[int64]*Zone{},
|
Zones: map[int64]*Zone{},
|
||||||
Records: map[int64]*Record{},
|
Records: map[int64]*Record{},
|
||||||
Options: map[int64]*Option{},
|
Options: map[int64]*Option{},
|
||||||
|
|
||||||
|
Blocklists: map[int64]*Blocklist{},
|
||||||
|
BlockRules: map[int64]*BlockRule{},
|
||||||
|
BlockSteps: map[int64]*BlockStep{},
|
||||||
|
ClientGroups: map[int64]*ClientGroup{},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -257,12 +282,17 @@ func (s *State) Clone() *State {
|
|||||||
Zones: make(map[int64]*Zone, len(s.Zones)),
|
Zones: make(map[int64]*Zone, len(s.Zones)),
|
||||||
Records: make(map[int64]*Record, len(s.Records)),
|
Records: make(map[int64]*Record, len(s.Records)),
|
||||||
Options: make(map[int64]*Option, len(s.Options)),
|
Options: make(map[int64]*Option, len(s.Options)),
|
||||||
|
|
||||||
|
Blocklists: make(map[int64]*Blocklist, len(s.Blocklists)),
|
||||||
|
BlockRules: make(map[int64]*BlockRule, len(s.BlockRules)),
|
||||||
|
BlockSteps: make(map[int64]*BlockStep, len(s.BlockSteps)),
|
||||||
|
ClientGroups: make(map[int64]*ClientGroup, len(s.ClientGroups)),
|
||||||
}
|
}
|
||||||
c.Settings.Nameservers = append([]string(nil), s.Settings.Nameservers...)
|
c.Settings.Nameservers = slices.Clone(s.Settings.Nameservers)
|
||||||
for id, v := range s.Views {
|
for id, v := range s.Views {
|
||||||
n := *v
|
n := *v
|
||||||
n.MatchClients = append([]string(nil), v.MatchClients...)
|
n.MatchClients = slices.Clone(v.MatchClients)
|
||||||
n.MatchDestinations = append([]string(nil), v.MatchDestinations...)
|
n.MatchDestinations = slices.Clone(v.MatchDestinations)
|
||||||
if v.MatchRecursiveOnly != nil {
|
if v.MatchRecursiveOnly != nil {
|
||||||
b := *v.MatchRecursiveOnly
|
b := *v.MatchRecursiveOnly
|
||||||
n.MatchRecursiveOnly = &b
|
n.MatchRecursiveOnly = &b
|
||||||
@@ -271,7 +301,7 @@ func (s *State) Clone() *State {
|
|||||||
}
|
}
|
||||||
for id, a := range s.ACLs {
|
for id, a := range s.ACLs {
|
||||||
n := *a
|
n := *a
|
||||||
n.Elements = append([]string(nil), a.Elements...)
|
n.Elements = slices.Clone(a.Elements)
|
||||||
c.ACLs[id] = &n
|
c.ACLs[id] = &n
|
||||||
}
|
}
|
||||||
for id, k := range s.Keys {
|
for id, k := range s.Keys {
|
||||||
@@ -294,6 +324,26 @@ func (s *State) Clone() *State {
|
|||||||
n := *o
|
n := *o
|
||||||
c.Options[id] = &n
|
c.Options[id] = &n
|
||||||
}
|
}
|
||||||
|
for id, b := range s.Blocklists {
|
||||||
|
n := *b
|
||||||
|
n.ViewIDs = slices.Clone(b.ViewIDs)
|
||||||
|
c.Blocklists[id] = &n
|
||||||
|
}
|
||||||
|
for id, r := range s.BlockRules {
|
||||||
|
n := *r
|
||||||
|
n.ViewIDs = slices.Clone(r.ViewIDs)
|
||||||
|
c.BlockRules[id] = &n
|
||||||
|
}
|
||||||
|
for id, st := range s.BlockSteps {
|
||||||
|
n := *st
|
||||||
|
c.BlockSteps[id] = &n
|
||||||
|
}
|
||||||
|
for id, g := range s.ClientGroups {
|
||||||
|
n := *g
|
||||||
|
n.Members = slices.Clone(g.Members)
|
||||||
|
n.ViewIDs = slices.Clone(g.ViewIDs)
|
||||||
|
c.ClientGroups[id] = &n
|
||||||
|
}
|
||||||
return c
|
return c
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -351,6 +401,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 {
|
||||||
@@ -481,7 +538,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 {
|
||||||
@@ -489,6 +548,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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+137
-4
@@ -6,6 +6,8 @@ import (
|
|||||||
"net/netip"
|
"net/netip"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"jarl/internal/iprange"
|
||||||
)
|
)
|
||||||
|
|
||||||
// AMKind is the kind of an address match list element.
|
// AMKind is the kind of an address match list element.
|
||||||
@@ -17,6 +19,9 @@ const (
|
|||||||
AMName // an ACL name (or built-in ACL)
|
AMName // an ACL name (or built-in ACL)
|
||||||
AMNested // { ... }
|
AMNested // { ... }
|
||||||
AMOther // anything else (e.g. geoip), passed through
|
AMOther // anything else (e.g. geoip), passed through
|
||||||
|
// AMRange is an address range, START-END: Jarl's own syntax, written
|
||||||
|
// to named.conf as the prefixes that cover it (ExpandRanges).
|
||||||
|
AMRange
|
||||||
)
|
)
|
||||||
|
|
||||||
// Builtin ACL names.
|
// Builtin ACL names.
|
||||||
@@ -30,6 +35,7 @@ type AMElement struct {
|
|||||||
Name string // AMKey, AMName
|
Name string // AMKey, AMName
|
||||||
Nested []AMElement // AMNested
|
Nested []AMElement // AMNested
|
||||||
Raw string // AMOther
|
Raw string // AMOther
|
||||||
|
Range iprange.Range // AMRange
|
||||||
}
|
}
|
||||||
|
|
||||||
// String returns the element in canonical named.conf syntax, without the
|
// String returns the element in canonical named.conf syntax, without the
|
||||||
@@ -55,6 +61,8 @@ func (e AMElement) String() string {
|
|||||||
s = "{ " + strings.Join(parts, " ") + " }"
|
s = "{ " + strings.Join(parts, " ") + " }"
|
||||||
case AMOther:
|
case AMOther:
|
||||||
s = e.Raw
|
s = e.Raw
|
||||||
|
case AMRange:
|
||||||
|
s = e.Range.String()
|
||||||
}
|
}
|
||||||
if e.Negated {
|
if e.Negated {
|
||||||
return "!" + s
|
return "!" + s
|
||||||
@@ -137,6 +145,7 @@ func parseAMItems(items []Item) (AMElement, error) {
|
|||||||
if len(items) == 0 {
|
if len(items) == 0 {
|
||||||
return e, errors.New("empty address match element")
|
return e, errors.New("empty address match element")
|
||||||
}
|
}
|
||||||
|
items = joinRange(items)
|
||||||
first := items[0]
|
first := items[0]
|
||||||
if first.Block != nil {
|
if first.Block != nil {
|
||||||
if len(items) != 1 {
|
if len(items) != 1 {
|
||||||
@@ -172,6 +181,16 @@ func parseAMItems(items []Item) (AMElement, error) {
|
|||||||
}
|
}
|
||||||
return e, fmt.Errorf("unexpected text after %q", word)
|
return e, fmt.Errorf("unexpected text after %q", word)
|
||||||
}
|
}
|
||||||
|
if !first.Value.Quoted {
|
||||||
|
if r, ok, err := iprange.Parse(word); ok {
|
||||||
|
if err != nil {
|
||||||
|
return e, err
|
||||||
|
}
|
||||||
|
e.Kind = AMRange
|
||||||
|
e.Range = r
|
||||||
|
return e, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
if !first.Value.Quoted || BuiltinACLs[lw] {
|
if !first.Value.Quoted || BuiltinACLs[lw] {
|
||||||
if p, ok, err := parsePrefix(word); ok {
|
if p, ok, err := parsePrefix(word); ok {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -215,6 +234,9 @@ func CheckACLName(name string) error {
|
|||||||
return fmt.Errorf("ACL name %q looks like an address", name)
|
return fmt.Errorf("ACL name %q looks like an address", name)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if _, ok, _ := iprange.Parse(name); ok {
|
||||||
|
return fmt.Errorf("ACL name %q looks like an address range", name)
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -318,18 +340,18 @@ func AMRefs(elems []AMElement) (acls, keys []string) {
|
|||||||
func Shadowed(elems []AMElement) []string {
|
func Shadowed(elems []AMElement) []string {
|
||||||
var warns []string
|
var warns []string
|
||||||
for i, e := range elems {
|
for i, e := range elems {
|
||||||
if e.Kind != AMPrefix && e.Kind != AMName {
|
if e.Kind != AMPrefix && e.Kind != AMRange && e.Kind != AMName {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
er, eok := e.addrRange()
|
||||||
for j := 0; j < i; j++ {
|
for j := 0; j < i; j++ {
|
||||||
prev := elems[j]
|
prev := elems[j]
|
||||||
if prev.Kind == AMName && prev.Name == "any" {
|
if prev.Kind == AMName && prev.Name == "any" {
|
||||||
warns = append(warns, fmt.Sprintf("element %q never matches: %q before it already matches every address", e.String(), prev.String()))
|
warns = append(warns, fmt.Sprintf("element %q never matches: %q before it already matches every address", e.String(), prev.String()))
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
if prev.Kind == AMPrefix && e.Kind == AMPrefix &&
|
if pr, ok := prev.addrRange(); ok && eok && pr.Start.Is4() == er.Start.Is4() &&
|
||||||
prev.Prefix.Addr().Is4() == e.Prefix.Addr().Is4() &&
|
!er.Start.Less(pr.Start) && !pr.End.Less(er.End) {
|
||||||
prev.Prefix.Bits() <= e.Prefix.Bits() && prev.Prefix.Contains(e.Prefix.Addr()) {
|
|
||||||
warns = append(warns, fmt.Sprintf("element %q never matches: %q before it already covers it (first match wins; put the more specific element first)", e.String(), prev.String()))
|
warns = append(warns, fmt.Sprintf("element %q never matches: %q before it already covers it (first match wins; put the more specific element first)", e.String(), prev.String()))
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@@ -338,6 +360,117 @@ func Shadowed(elems []AMElement) []string {
|
|||||||
return warns
|
return warns
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// addrRange returns the addresses a prefix or range element covers.
|
||||||
|
func (e AMElement) addrRange() (iprange.Range, bool) {
|
||||||
|
switch e.Kind {
|
||||||
|
case AMRange:
|
||||||
|
return e.Range, true
|
||||||
|
case AMPrefix:
|
||||||
|
return iprange.FromPrefix(e.Prefix), true
|
||||||
|
}
|
||||||
|
return iprange.Range{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Expand returns the element as named.conf can take it: a range becomes
|
||||||
|
// the prefixes that cover it (each negated when the range is), nested
|
||||||
|
// lists are expanded too, and anything else is returned as it is.
|
||||||
|
func (e AMElement) Expand() []AMElement {
|
||||||
|
switch e.Kind {
|
||||||
|
case AMRange:
|
||||||
|
var out []AMElement
|
||||||
|
for _, p := range e.Range.Prefixes() {
|
||||||
|
out = append(out, AMElement{Negated: e.Negated, Kind: AMPrefix, Prefix: p})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
case AMNested:
|
||||||
|
n := e
|
||||||
|
n.Nested = nil
|
||||||
|
for _, in := range e.Nested {
|
||||||
|
n.Nested = append(n.Nested, in.Expand()...)
|
||||||
|
}
|
||||||
|
return []AMElement{n}
|
||||||
|
}
|
||||||
|
return []AMElement{e}
|
||||||
|
}
|
||||||
|
|
||||||
|
// joinRange turns a range written with spaces around the dash
|
||||||
|
// ("10.0.0.1 - 10.0.0.9", three words) into a single word.
|
||||||
|
func joinRange(items []Item) []Item {
|
||||||
|
if len(items) != 3 || items[0].Value == nil || items[1].Value == nil || items[2].Value == nil ||
|
||||||
|
items[0].Value.Quoted || items[1].Value.Quoted || items[2].Value.Quoted || items[1].Value.Text != "-" {
|
||||||
|
return items
|
||||||
|
}
|
||||||
|
text := items[0].Value.Text + "-" + items[2].Value.Text
|
||||||
|
if _, ok, _ := iprange.Parse(text); !ok {
|
||||||
|
return items
|
||||||
|
}
|
||||||
|
return []Item{{Value: &Value{Text: text, Pos: items[0].Value.Pos}}}
|
||||||
|
}
|
||||||
|
|
||||||
|
// JoinRanges rewrites ranges written with spaces around the dash, in st's
|
||||||
|
// blocks, as single words (the form Jarl stores).
|
||||||
|
func JoinRanges(st *Stmt) {
|
||||||
|
for _, it := range st.Items {
|
||||||
|
if it.Block == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, s := range it.Block.Stmts {
|
||||||
|
if len(s.Items) == 4 && s.Items[0].Bang {
|
||||||
|
s.Items = append(s.Items[:1], joinRange(s.Items[1:])...)
|
||||||
|
} else {
|
||||||
|
s.Items = joinRange(s.Items)
|
||||||
|
}
|
||||||
|
JoinRanges(s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExpandRanges replaces the address ranges in st's blocks (at any depth)
|
||||||
|
// by the prefixes that cover them, with the range as a comment, so named
|
||||||
|
// can read it. A range is a block statement that is a single word START-END,
|
||||||
|
// optionally negated; nothing else in named.conf's grammar looks like one.
|
||||||
|
func ExpandRanges(st *Stmt) {
|
||||||
|
for _, it := range st.Items {
|
||||||
|
if it.Block != nil {
|
||||||
|
expandBlock(it.Block)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func expandBlock(b *Block) {
|
||||||
|
var out []*Stmt
|
||||||
|
for _, s := range b.Stmts {
|
||||||
|
items, neg := s.Items, false
|
||||||
|
if len(items) == 2 && items[0].Bang {
|
||||||
|
items, neg = items[1:], true
|
||||||
|
}
|
||||||
|
if len(items) == 1 && items[0].Value != nil && !items[0].Value.Quoted {
|
||||||
|
if r, ok, err := iprange.Parse(items[0].Value.Text); ok && err == nil {
|
||||||
|
for i, p := range r.Prefixes() {
|
||||||
|
var ns Stmt
|
||||||
|
if neg {
|
||||||
|
ns.Items = append(ns.Items, Item{Bang: true})
|
||||||
|
}
|
||||||
|
text := p.String()
|
||||||
|
if p.IsSingleIP() {
|
||||||
|
text = p.Addr().String()
|
||||||
|
}
|
||||||
|
ns.Items = append(ns.Items, Item{Value: &Value{Text: text}})
|
||||||
|
ns.Pos = s.Pos
|
||||||
|
if i == 0 {
|
||||||
|
ns.Comment = AMElement{Negated: neg, Kind: AMRange, Range: r}.String()
|
||||||
|
}
|
||||||
|
out = append(out, &ns)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ExpandRanges(s)
|
||||||
|
out = append(out, s)
|
||||||
|
}
|
||||||
|
b.Stmts = out
|
||||||
|
}
|
||||||
|
|
||||||
// ContainsAny reports whether elems contain a non-negated "any" before any
|
// ContainsAny reports whether elems contain a non-negated "any" before any
|
||||||
// negated element would restrict it, which makes the list match everyone.
|
// negated element would restrict it, which makes the list match everyone.
|
||||||
// It is used for security lints (open resolvers, open transfers).
|
// It is used for security lints (open resolvers, open transfers).
|
||||||
|
|||||||
@@ -30,6 +30,9 @@ type Item struct {
|
|||||||
type Stmt struct {
|
type Stmt struct {
|
||||||
Items []Item
|
Items []Item
|
||||||
Pos Pos
|
Pos Pos
|
||||||
|
// Comment is written before the statement (only by Jarl: comments in
|
||||||
|
// parsed text are dropped). ExpandRanges notes the range there.
|
||||||
|
Comment string
|
||||||
}
|
}
|
||||||
|
|
||||||
// Keyword returns the statement's first word, lowercased, or "" if the
|
// Keyword returns the statement's first word, lowercased, or "" if the
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -59,6 +77,9 @@ func valueText(v *Value) string {
|
|||||||
|
|
||||||
func writeStmt(sb *strings.Builder, s *Stmt, indent int) {
|
func writeStmt(sb *strings.Builder, s *Stmt, indent int) {
|
||||||
tabs := strings.Repeat("\t", indent)
|
tabs := strings.Repeat("\t", indent)
|
||||||
|
if s.Comment != "" {
|
||||||
|
sb.WriteString(tabs + "// " + s.Comment + "\n")
|
||||||
|
}
|
||||||
sb.WriteString(tabs)
|
sb.WriteString(tabs)
|
||||||
inline := Compact(s.Items)
|
inline := Compact(s.Items)
|
||||||
if !hasNestedBlocks(s.Items) && len(inline)+indent*8 <= maxInline+8 {
|
if !hasNestedBlocks(s.Items) && len(inline)+indent*8 <= maxInline+8 {
|
||||||
@@ -149,6 +170,9 @@ func writeItemsInline(sb *strings.Builder, items []Item) {
|
|||||||
} else {
|
} else {
|
||||||
sb.WriteString("{ ")
|
sb.WriteString("{ ")
|
||||||
for _, s := range it.Block.Stmts {
|
for _, s := range it.Block.Stmts {
|
||||||
|
if s.Comment != "" {
|
||||||
|
sb.WriteString("/* " + s.Comment + " */ ")
|
||||||
|
}
|
||||||
writeItemsInline(sb, s.Items)
|
writeItemsInline(sb, s.Items)
|
||||||
sb.WriteString("; ")
|
sb.WriteString("; ")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,90 @@
|
|||||||
|
package namedconf
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRangeElements(t *testing.T) {
|
||||||
|
e, err := ParseAMElement("!10.0.10.150 - 10.0.10.160;")
|
||||||
|
if err != nil || e.Kind != AMRange || !e.Negated || e.String() != "!10.0.10.150-10.0.10.160" {
|
||||||
|
t.Fatalf("negated range = %+v, %v", e, err)
|
||||||
|
}
|
||||||
|
if e, err := ParseAMElement("2001:db8::100-2001:db8::1ff"); err != nil || e.Kind != AMRange {
|
||||||
|
t.Errorf("IPv6 range = %+v, %v", e, err)
|
||||||
|
}
|
||||||
|
// ACL names with dashes are still names.
|
||||||
|
if e, err := ParseAMElement("branch-office"); err != nil || e.Kind != AMName {
|
||||||
|
t.Errorf("name = %+v, %v", e, err)
|
||||||
|
}
|
||||||
|
for _, bad := range []string{"10.0.10.199-10.0.10.101", "10.0.10.101-199", "10.0.0.1-2001:db8::1"} {
|
||||||
|
if _, err := ParseAMElement(bad); err == nil {
|
||||||
|
t.Errorf("%s accepted", bad)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := CheckACLName("10.0.0.1-10.0.0.9"); err == nil {
|
||||||
|
t.Error("an ACL name that looks like a range was accepted")
|
||||||
|
}
|
||||||
|
var got []string
|
||||||
|
for _, x := range e.Expand() {
|
||||||
|
got = append(got, x.String())
|
||||||
|
}
|
||||||
|
if strings.Join(got, " ") != "!10.0.10.150/31 !10.0.10.152/29 !10.0.10.160" {
|
||||||
|
t.Errorf("expand = %v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExpandRanges(t *testing.T) {
|
||||||
|
st, err := ParseStatement(`acl "lan" { !10.0.10.150-10.0.10.160; 10.0.10.101-10.0.10.199; key xfer; branch-office; { 10.0.0.1-10.0.0.2; }; }`)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
ExpandRanges(st)
|
||||||
|
want := `acl "lan" {
|
||||||
|
// !10.0.10.150-10.0.10.160
|
||||||
|
!10.0.10.150/31;
|
||||||
|
!10.0.10.152/29;
|
||||||
|
!10.0.10.160;
|
||||||
|
// 10.0.10.101-10.0.10.199
|
||||||
|
10.0.10.101;
|
||||||
|
10.0.10.102/31;
|
||||||
|
10.0.10.104/29;
|
||||||
|
10.0.10.112/28;
|
||||||
|
10.0.10.128/26;
|
||||||
|
10.0.10.192/29;
|
||||||
|
key xfer;
|
||||||
|
branch-office;
|
||||||
|
{ /* 10.0.0.1-10.0.0.2 */ 10.0.0.1; 10.0.0.2; };
|
||||||
|
};
|
||||||
|
`
|
||||||
|
if got := FormatStmt(st, 0); got != want {
|
||||||
|
t.Errorf("got:\n%s\nwant:\n%s", got, want)
|
||||||
|
}
|
||||||
|
// Short lists stay on one line, with the range as a /* */ comment;
|
||||||
|
// both forms parse again.
|
||||||
|
st, _ = ParseOptionValue("allow-query", "{ 2001:db8::100-2001:db8::1ff; localhost; }")
|
||||||
|
ExpandRanges(st)
|
||||||
|
got := FormatStmt(st, 0)
|
||||||
|
if got != "allow-query { /* 2001:db8::100-2001:db8::1ff */ 2001:db8::100/120; localhost; };\n" {
|
||||||
|
t.Errorf("inline = %q", got)
|
||||||
|
}
|
||||||
|
if _, err := ParseStatement(strings.TrimSuffix(strings.TrimSpace(got), ";")); err != nil {
|
||||||
|
t.Errorf("output doesn't parse: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestShadowedRanges(t *testing.T) {
|
||||||
|
elems, _ := ParseAMListString("10.0.10.0/24; 10.0.10.101-10.0.10.199; 10.0.10.150-10.0.11.5; 10.0.11.1")
|
||||||
|
w := Shadowed(elems)
|
||||||
|
if len(w) != 2 || !strings.Contains(w[0], `"10.0.10.101-10.0.10.199" never matches`) || !strings.Contains(w[1], `"10.0.11.1" never matches: "10.0.10.150-10.0.11.5"`) {
|
||||||
|
t.Errorf("shadowed = %q", w)
|
||||||
|
}
|
||||||
|
elems, _ = ParseAMListString("10.0.10.101-10.0.10.199; 10.0.10.120")
|
||||||
|
if w := Shadowed(elems); len(w) != 1 || !strings.Contains(w[0], `"10.0.10.120" never matches`) {
|
||||||
|
t.Errorf("shadowed by a range = %q", w)
|
||||||
|
}
|
||||||
|
elems, _ = ParseAMListString("0.0.0.0-255.255.255.255")
|
||||||
|
if ContainsAny(elems) {
|
||||||
|
t.Error("ContainsAny should only see expanded elements") // callers expand first
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
|||||||
@@ -0,0 +1,239 @@
|
|||||||
|
package render
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/netip"
|
||||||
|
"path/filepath"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"jarl/internal/blocking"
|
||||||
|
"jarl/internal/model"
|
||||||
|
"jarl/internal/namedconf"
|
||||||
|
)
|
||||||
|
|
||||||
|
// KindPolicy is the kind of a rendered rules policy zone file.
|
||||||
|
const KindPolicy = "policy"
|
||||||
|
|
||||||
|
// PolicyZone is a response policy zone in named's configuration
|
||||||
|
// (docs/blocking.md).
|
||||||
|
type PolicyZone struct {
|
||||||
|
Name string // without the final dot
|
||||||
|
ViewID int64 // 0 without views
|
||||||
|
View string // "" without views
|
||||||
|
File string
|
||||||
|
ListID int64 // the blocklist; 0 for the rules zone and gates
|
||||||
|
// Gate, for a gate zone: the clients it lets through (past every zone
|
||||||
|
// after it), and the step whose lists it is in front of.
|
||||||
|
Gate []netip.Prefix
|
||||||
|
GateStep string
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsGate reports whether z is a gate zone.
|
||||||
|
func (z PolicyZone) IsGate() bool { return z.GateStep != "" }
|
||||||
|
|
||||||
|
// BlocklistsDir holds the zone files of downloaded blocklists, one per
|
||||||
|
// list, shared by the views that use it.
|
||||||
|
func (p Paths) BlocklistsDir() string { return filepath.Join(p.CacheDir, "blocklists") }
|
||||||
|
|
||||||
|
// BlocklistFile returns the zone file of a blocklist. It is named after
|
||||||
|
// the list's ID, so renaming the list doesn't move it.
|
||||||
|
func (p Paths) BlocklistFile(id int64) string {
|
||||||
|
return filepath.Join(p.BlocklistsDir(), fmt.Sprintf("list-%d.rpz", id))
|
||||||
|
}
|
||||||
|
|
||||||
|
// rulesFile returns a view's rules zone file.
|
||||||
|
func (p Paths) rulesFile(view string) string {
|
||||||
|
return filepath.Join(p.ZoneDir, ViewDir(view), blocking.RulesZone+".rpz")
|
||||||
|
}
|
||||||
|
|
||||||
|
// PolicyZones returns the policy zones named loads, per view in view
|
||||||
|
// order, each view's in the order named checks them: the rules zone first
|
||||||
|
// (rules win over lists), then for each step (normal, then the steps above
|
||||||
|
// it) a gate letting through the clients below that step, when there are
|
||||||
|
// any, and the step's enabled lists by name.
|
||||||
|
func PolicyZones(s *model.State, p Paths) []PolicyZone {
|
||||||
|
var out []PolicyZone
|
||||||
|
rules, lists := s.SortedBlockRules(), s.SortedBlocklists()
|
||||||
|
scopes := []int64{0}
|
||||||
|
if len(s.Views) > 0 {
|
||||||
|
scopes = nil
|
||||||
|
for _, v := range s.OrderedViews() {
|
||||||
|
scopes = append(scopes, v.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, vid := range scopes {
|
||||||
|
view := ""
|
||||||
|
if v := s.Views[vid]; v != nil {
|
||||||
|
view = v.Name
|
||||||
|
}
|
||||||
|
var zones []PolicyZone
|
||||||
|
hasRules := false
|
||||||
|
for _, r := range rules {
|
||||||
|
if slices.Contains(s.BlockingViews(r.ViewIDs), vid) {
|
||||||
|
hasRules = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assigns := ClientAssignments(s, vid)
|
||||||
|
for k, step := range append([]int64{model.StepNormal}, stepIDs(s)...) {
|
||||||
|
var tier []PolicyZone
|
||||||
|
for _, b := range lists {
|
||||||
|
if !b.Disabled && b.StepID == step && slices.Contains(s.BlockingViews(b.ViewIDs), vid) {
|
||||||
|
tier = append(tier, PolicyZone{Name: blocking.ListZone(b.Name), ViewID: vid, View: view, File: p.BlocklistFile(b.ID), ListID: b.ID})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(tier) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// The clients below this step pass, and never see its lists.
|
||||||
|
if pass := blocking.PassBelow(assigns, k); len(pass) > 0 {
|
||||||
|
name := "off"
|
||||||
|
if k > 0 {
|
||||||
|
name = s.StepName(step)
|
||||||
|
}
|
||||||
|
zones = append(zones, PolicyZone{Name: blocking.GateZone(name), ViewID: vid, View: view, File: p.gateFile(view, name), Gate: pass, GateStep: s.StepName(step)})
|
||||||
|
}
|
||||||
|
zones = append(zones, tier...)
|
||||||
|
}
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
|
||||||
|
// gateFile returns a view's gate zone file for a step.
|
||||||
|
func (p Paths) gateFile(view, name string) string {
|
||||||
|
return filepath.Join(p.ZoneDir, ViewDir(view), blocking.GateZone(name)+".rpz")
|
||||||
|
}
|
||||||
|
|
||||||
|
// stepIDs returns the IDs of the steps above normal, lowest first.
|
||||||
|
func stepIDs(s *model.State) []int64 {
|
||||||
|
var out []int64
|
||||||
|
for _, st := range s.OrderedBlockSteps() {
|
||||||
|
out = append(out, st.ID)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClientAssignments returns the step ranks the client groups of a view
|
||||||
|
// give addresses (blocking.PassBelow).
|
||||||
|
func ClientAssignments(s *model.State, viewID int64) []blocking.Assignment {
|
||||||
|
var out []blocking.Assignment
|
||||||
|
for _, g := range s.SortedClientGroups() {
|
||||||
|
if !slices.Contains(s.BlockingViews(g.ViewIDs), viewID) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
rank, ok := s.StepRank(g.StepID)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, p := range s.GroupPrefixes(g) {
|
||||||
|
out = append(out, blocking.Assignment{Prefix: p, Rank: rank})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// ViewRules returns the rules that apply in a view (0 without views).
|
||||||
|
func ViewRules(s *model.State, viewID int64) []blocking.Rule {
|
||||||
|
var out []blocking.Rule
|
||||||
|
for _, r := range s.SortedBlockRules() {
|
||||||
|
if slices.Contains(s.BlockingViews(r.ViewIDs), viewID) {
|
||||||
|
out = append(out, blocking.Rule{Name: r.Name, Action: r.Action, Exact: r.Exact})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeResponsePolicy writes the response-policy statement for a view's
|
||||||
|
// policy zones (or the server's, without views). Rewrites are logged to
|
||||||
|
// Jarl's response log when statistics read it, and not at all otherwise
|
||||||
|
// (they would go to named's default log).
|
||||||
|
func writeResponsePolicy(w *confWriter, zones []PolicyZone, logRewrites bool) {
|
||||||
|
if len(zones) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.begin("blocking", 0, "blocking (rules and blocklists)")
|
||||||
|
w.write("\t// Blocking (docs/blocking.md): rules first, then blocklists.\n\tresponse-policy {\n")
|
||||||
|
for _, z := range zones {
|
||||||
|
// Apply a new version at once: Jarl reloads a list only when it
|
||||||
|
// changed (at most every blocking.refresh), so named's default
|
||||||
|
// delay for batching frequent updates only adds a wait.
|
||||||
|
opts := []string{"min-update-interval 0"}
|
||||||
|
// A gate lets most queries through: logging that would double
|
||||||
|
// the response log, and it blocks nothing.
|
||||||
|
if !logRewrites || z.IsGate() {
|
||||||
|
opts = append(opts, "log no")
|
||||||
|
}
|
||||||
|
w.write(fmt.Sprintf("\t\tzone %s%s;\n", namedconf.QuoteString(z.Name), prefixSpace(strings.Join(opts, " "))))
|
||||||
|
}
|
||||||
|
// Block without waiting for the real answer, and also for clients
|
||||||
|
// that ask for DNSSEC data.
|
||||||
|
w.write("\t} qname-wait-recurse no break-dnssec yes;\n")
|
||||||
|
w.end()
|
||||||
|
}
|
||||||
|
|
||||||
|
func prefixSpace(s string) string {
|
||||||
|
if s == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return " " + s
|
||||||
|
}
|
||||||
|
|
||||||
|
// writePolicyZones writes the zone statements of policy zones and adds
|
||||||
|
// their files to the output: rules zone files are rendered, blocklist
|
||||||
|
// files only listed (jarld downloads them; see Output.Blocklists).
|
||||||
|
func writePolicyZones(w *confWriter, s *model.State, zones []PolicyZone, out *Output, indent int) {
|
||||||
|
tabs := strings.Repeat("\t", indent)
|
||||||
|
for _, z := range zones {
|
||||||
|
w.write("\n")
|
||||||
|
label := "blocking: your rules"
|
||||||
|
switch {
|
||||||
|
case z.ListID != 0:
|
||||||
|
label = "blocking: blocklist " + s.Blocklists[z.ListID].Name
|
||||||
|
case z.IsGate():
|
||||||
|
label = "blocking: clients below step " + z.GateStep
|
||||||
|
}
|
||||||
|
w.begin("blocking", z.ListID, label)
|
||||||
|
w.write(fmt.Sprintf("%szone %s {\n%s\ttype primary;\n%s\tfile %s;\n%s\tallow-query { none; };\n%s\tallow-transfer { none; };\n%s\tnotify no;\n%s};\n",
|
||||||
|
tabs, namedconf.QuoteString(z.Name), tabs, tabs, namedconf.QuoteString(z.File), tabs, tabs, tabs, tabs))
|
||||||
|
w.end()
|
||||||
|
if z.ListID != 0 {
|
||||||
|
out.Blocklists = appendUnique(out.Blocklists, z.File)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if z.IsGate() {
|
||||||
|
title := "Clients that skip the lists from step " + z.GateStep + " on"
|
||||||
|
if z.View != "" {
|
||||||
|
title += " in view " + z.View
|
||||||
|
}
|
||||||
|
out.Files = append(out.Files, &File{Path: z.File, Content: []byte(blocking.GateZoneFile(title, z.Gate)), Mode: 0o640, Kind: KindPolicy})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
title := "Blocking rules"
|
||||||
|
if z.View != "" {
|
||||||
|
title += " for view " + z.View
|
||||||
|
}
|
||||||
|
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})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// scopeZones returns the policy zones of one view (0 without views).
|
||||||
|
func scopeZones(all []PolicyZone, viewID int64) []PolicyZone {
|
||||||
|
var out []PolicyZone
|
||||||
|
for _, z := range all {
|
||||||
|
if z.ViewID == viewID {
|
||||||
|
out = append(out, z)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
+37
-10
@@ -69,6 +69,11 @@ type Output struct {
|
|||||||
Spans map[string][]Span
|
Spans map[string][]Span
|
||||||
// ZoneFiles maps zone IDs to the file named loads for the zone.
|
// ZoneFiles maps zone IDs to the file named loads for the zone.
|
||||||
ZoneFiles map[int64]string
|
ZoneFiles map[int64]string
|
||||||
|
// Policy lists the response policy zones (docs/blocking.md).
|
||||||
|
Policy []PolicyZone
|
||||||
|
// Blocklists are the zone files of blocklists named loads. jarld
|
||||||
|
// downloads them; until then they must exist, as empty policy zones.
|
||||||
|
Blocklists []string
|
||||||
}
|
}
|
||||||
|
|
||||||
// File returns the rendered file at path, or nil.
|
// File returns the rendered file at path, or nil.
|
||||||
@@ -144,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})
|
||||||
@@ -224,7 +229,7 @@ func optionText(o *model.Option, indent int) string {
|
|||||||
// named-checkconf reports the problem.
|
// named-checkconf reports the problem.
|
||||||
return prefix + strings.Repeat("\t", indent) + o.Text() + "\n"
|
return prefix + strings.Repeat("\t", indent) + o.Text() + "\n"
|
||||||
}
|
}
|
||||||
return prefix + namedconf.FormatStmt(st, indent)
|
return prefix + formatStmt(st, indent)
|
||||||
}
|
}
|
||||||
|
|
||||||
func indentLines(s string, indent int) string {
|
func indentLines(s string, indent int) string {
|
||||||
@@ -264,6 +269,9 @@ func renderConfig(s *model.State, p Paths, x Extras, out *Output) (string, []Spa
|
|||||||
|
|
||||||
st := x.Stats
|
st := x.Stats
|
||||||
logResponses := st != nil && st.LogFile != ""
|
logResponses := st != nil && st.LogFile != ""
|
||||||
|
pzs := PolicyZones(s, p)
|
||||||
|
out.Policy = pzs
|
||||||
|
logRewrites := logResponses && len(pzs) > 0
|
||||||
sawStats, sawLogging := false, false
|
sawStats, sawLogging := false, false
|
||||||
if top := s.ScopedOptions(model.ScopeTopLevel, 0); len(top) > 0 {
|
if top := s.ScopedOptions(model.ScopeTopLevel, 0); len(top) > 0 {
|
||||||
w.write("\n")
|
w.write("\n")
|
||||||
@@ -274,7 +282,7 @@ func renderConfig(s *model.State, p Paths, x Extras, out *Output) (string, []Spa
|
|||||||
w.write(st.statsStatement(o))
|
w.write(st.statsStatement(o))
|
||||||
sawStats = true
|
sawStats = true
|
||||||
case logResponses && o.Name == "logging" && !sawLogging:
|
case logResponses && o.Name == "logging" && !sawLogging:
|
||||||
w.write(st.loggingStatement(o))
|
w.write(st.loggingStatement(o, logRewrites))
|
||||||
sawLogging = true
|
sawLogging = true
|
||||||
default:
|
default:
|
||||||
w.write(optionText(o, 0))
|
w.write(optionText(o, 0))
|
||||||
@@ -289,7 +297,7 @@ func renderConfig(s *model.State, p Paths, x Extras, out *Output) (string, []Spa
|
|||||||
w.write(st.statsStatement(nil))
|
w.write(st.statsStatement(nil))
|
||||||
}
|
}
|
||||||
if logResponses && !sawLogging {
|
if logResponses && !sawLogging {
|
||||||
w.write(st.loggingStatement(nil))
|
w.write(st.loggingStatement(nil, logRewrites))
|
||||||
}
|
}
|
||||||
w.end()
|
w.end()
|
||||||
}
|
}
|
||||||
@@ -305,7 +313,7 @@ func renderConfig(s *model.State, p Paths, x Extras, out *Output) (string, []Spa
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
w.write("acl " + namedconf.QuoteString(a.Name) + " " + amlBlock(a.Elements) + ";\n")
|
w.write("acl " + namedconf.QuoteString(a.Name) + " " + amlBlock(a.Elements) + ";\n")
|
||||||
} else {
|
} else {
|
||||||
w.write(namedconf.FormatStmt(st, 0))
|
w.write(formatStmt(st, 0))
|
||||||
}
|
}
|
||||||
w.end()
|
w.end()
|
||||||
}
|
}
|
||||||
@@ -322,10 +330,14 @@ func renderConfig(s *model.State, p Paths, x Extras, out *Output) (string, []Spa
|
|||||||
w.write("\t// " + statsComment + "\n\tresponselog yes;\n")
|
w.write("\t// " + statsComment + "\n\tresponselog yes;\n")
|
||||||
w.end()
|
w.end()
|
||||||
}
|
}
|
||||||
w.write("};\n")
|
|
||||||
|
|
||||||
views := s.OrderedViews()
|
views := s.OrderedViews()
|
||||||
if len(views) == 0 {
|
if len(views) == 0 {
|
||||||
|
writeResponsePolicy(w, scopeZones(pzs, 0), logResponses)
|
||||||
|
}
|
||||||
|
w.write("};\n")
|
||||||
|
|
||||||
|
if len(views) == 0 {
|
||||||
|
writePolicyZones(w, s, scopeZones(pzs, 0), out, 0)
|
||||||
for _, z := range s.ZonesInView(0) {
|
for _, z := range s.ZonesInView(0) {
|
||||||
w.write("\n")
|
w.write("\n")
|
||||||
renderZone(w, s, z, p, out, 0)
|
renderZone(w, s, z, p, out, 0)
|
||||||
@@ -338,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)
|
||||||
@@ -352,6 +364,8 @@ func renderConfig(s *model.State, p Paths, x Extras, out *Output) (string, []Spa
|
|||||||
w.write(optionText(o, 1))
|
w.write(optionText(o, 1))
|
||||||
w.end()
|
w.end()
|
||||||
}
|
}
|
||||||
|
writeResponsePolicy(w, scopeZones(pzs, v.ID), logResponses)
|
||||||
|
writePolicyZones(w, s, scopeZones(pzs, v.ID), out, 1)
|
||||||
for _, z := range s.ZonesInView(v.ID) {
|
for _, z := range s.ZonesInView(v.ID) {
|
||||||
w.write("\n")
|
w.write("\n")
|
||||||
renderZone(w, s, z, p, out, 1)
|
renderZone(w, s, z, p, out, 1)
|
||||||
@@ -368,7 +382,7 @@ func writeAML(w *confWriter, name string, elems []string) {
|
|||||||
w.write("\t" + name + " " + amlBlock(elems) + ";\n")
|
w.write("\t" + name + " " + amlBlock(elems) + ";\n")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
w.write(namedconf.FormatStmt(st, 1))
|
w.write(formatStmt(st, 1))
|
||||||
}
|
}
|
||||||
|
|
||||||
func yesNo(b bool) string {
|
func yesNo(b bool) string {
|
||||||
@@ -384,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))
|
||||||
@@ -487,3 +507,10 @@ func ZoneContentHash(s *model.State, z *model.Zone) string {
|
|||||||
}
|
}
|
||||||
return hex.EncodeToString(h.Sum(nil))
|
return hex.EncodeToString(h.Sum(nil))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// formatStmt lays out a statement for named.conf, with address ranges
|
||||||
|
// (Jarl's own syntax) written as the prefixes that cover them.
|
||||||
|
func formatStmt(st *namedconf.Stmt, indent int) string {
|
||||||
|
namedconf.ExpandRanges(st)
|
||||||
|
return namedconf.FormatStmt(st, 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.
|
||||||
@@ -59,18 +62,19 @@ func (h *StatsHooks) statsStatement(user *model.Option) string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
b.Stmts = append(b.Stmts, h.statsInet())
|
b.Stmts = append(b.Stmts, h.statsInet())
|
||||||
return commentLines(user.Comment) + commentBlock("//", statsComment) + namedconf.FormatStmt(st, 0)
|
return commentLines(user.Comment) + commentBlock("//", statsComment) + formatStmt(st, 0)
|
||||||
}
|
}
|
||||||
// Unparsable: validation reports it; leave it alone.
|
// Unparsable: validation reports it; leave it alone.
|
||||||
return optionText(user, 0)
|
return optionText(user, 0)
|
||||||
}
|
}
|
||||||
st := namedconf.NewStmt("statistics-channels", namedconf.NewBlock(h.statsInet()))
|
st := namedconf.NewStmt("statistics-channels", namedconf.NewBlock(h.statsInet()))
|
||||||
return commentBlock("//", statsComment) + namedconf.FormatStmt(st, 0)
|
return commentBlock("//", statsComment) + formatStmt(st, 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
// loggingStatement returns the logging statement with Jarl's channel and
|
// loggingStatement returns the logging statement with Jarl's channel and
|
||||||
// the responses category, merged into the user's own if there is one.
|
// the responses category (and rpz, for blocking's rewrites), merged into
|
||||||
func (h *StatsHooks) loggingStatement(user *model.Option) string {
|
// the user's own if there is one.
|
||||||
|
func (h *StatsHooks) loggingStatement(user *model.Option, rpz bool) string {
|
||||||
var st *namedconf.Stmt
|
var st *namedconf.Stmt
|
||||||
if user != nil {
|
if user != nil {
|
||||||
var err error
|
var err error
|
||||||
@@ -84,21 +88,27 @@ func (h *StatsHooks) loggingStatement(user *model.Option) string {
|
|||||||
b := st.FirstBlock()
|
b := st.FirstBlock()
|
||||||
// Channels must be defined before the categories that use them.
|
// Channels must be defined before the categories that use them.
|
||||||
b.Stmts = append([]*namedconf.Stmt{h.logChannel()}, b.Stmts...)
|
b.Stmts = append([]*namedconf.Stmt{h.logChannel()}, b.Stmts...)
|
||||||
|
categories := []string{"responses"}
|
||||||
|
if rpz {
|
||||||
|
categories = append(categories, "rpz")
|
||||||
|
}
|
||||||
|
for _, cat := range categories {
|
||||||
merged := false
|
merged := false
|
||||||
for _, s := range b.Stmts {
|
for _, s := range b.Stmts {
|
||||||
if s.Keyword() == "category" && strings.EqualFold(s.Arg(0), "responses") && s.FirstBlock() != nil {
|
if s.Keyword() == "category" && strings.EqualFold(s.Arg(0), cat) && s.FirstBlock() != nil {
|
||||||
s.FirstBlock().Stmts = append(s.FirstBlock().Stmts, namedconf.NewStmt(StatsChannelName))
|
s.FirstBlock().Stmts = append(s.FirstBlock().Stmts, namedconf.NewStmt(StatsChannelName))
|
||||||
merged = true
|
merged = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !merged {
|
if !merged {
|
||||||
b.Stmts = append(b.Stmts, namedconf.NewStmt("category", "responses", namedconf.NewBlock(namedconf.NewStmt(StatsChannelName))))
|
b.Stmts = append(b.Stmts, namedconf.NewStmt("category", cat, namedconf.NewBlock(namedconf.NewStmt(StatsChannelName))))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
prefix := ""
|
prefix := ""
|
||||||
if user != nil {
|
if user != nil {
|
||||||
prefix = commentLines(user.Comment)
|
prefix = commentLines(user.Comment)
|
||||||
}
|
}
|
||||||
return prefix + commentBlock("//", statsComment) + namedconf.FormatStmt(st, 0)
|
return prefix + commentBlock("//", statsComment) + formatStmt(st, 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
func commentLines(c string) string {
|
func commentLines(c string) string {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestBlockingAPI(t *testing.T) {
|
||||||
|
e := newAPIEnv(t)
|
||||||
|
lists := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path == "/missing" {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Write([]byte("||ads.example^\n0.0.0.0 tracker.example\n"))
|
||||||
|
}))
|
||||||
|
defer lists.Close()
|
||||||
|
|
||||||
|
b := e.do("POST", "/blocking/lists", e.token, map[string]any{"name": "ads", "url": lists.URL + "/list.txt"}, 201)["object"].(map[string]any)
|
||||||
|
if b["name"] != "ads" || b["state"] != "waiting" {
|
||||||
|
t.Errorf("new list = %v", b)
|
||||||
|
}
|
||||||
|
got := e.do("POST", "/blocking/lists/ads/refresh", e.token, nil, 200)
|
||||||
|
st, _ := got["status"].(map[string]any)
|
||||||
|
if got["state"] != "ok" || st["entries"] != 2.0 || st["records"] != 3.0 {
|
||||||
|
t.Errorf("after refresh = %v", got)
|
||||||
|
}
|
||||||
|
e.do("POST", "/blocking/rules", e.token, map[string]any{"name": "Good.Ads.Example.", "action": "allow"}, 201)
|
||||||
|
r := e.do("POST", "/blocking/rules", e.token, map[string]any{"name": "more.example", "exact": true}, 201)["object"].(map[string]any)
|
||||||
|
if r["name"] != "more.example" || r["action"] != "block" || r["exact"] != true {
|
||||||
|
t.Errorf("new rule = %v", r)
|
||||||
|
}
|
||||||
|
for name, want := range map[string]string{"x.ads.example": "blocked", "a.good.ads.example": "allowed", "more.example": "blocked", "sub.more.example": "none"} {
|
||||||
|
m := e.list("/blocking/check?name="+name, e.token)
|
||||||
|
if len(m) != 1 || m[0]["result"] != want {
|
||||||
|
t.Errorf("check %s = %v, want %s", name, m, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, status := e.req("GET", "/blocking/check?name=com", e.token, nil); status != 400 {
|
||||||
|
t.Errorf("check of a TLD = %d", status)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Staged changes show as pending.
|
||||||
|
cs := itoa(int64(e.do("POST", "/changesets", e.token, map[string]any{"description": "more"}, 201)["id"].(float64)))
|
||||||
|
e.do("POST", "/blocking/rules?changeset="+cs, e.token, map[string]any{"name": "staged.example"}, 202)
|
||||||
|
e.do("PATCH", "/blocking/lists/ads?changeset="+cs, e.token, map[string]any{"disabled": true}, 202)
|
||||||
|
pending := map[string]any{}
|
||||||
|
for _, x := range e.list("/blocking/rules?changeset="+cs, e.token) {
|
||||||
|
pending[x["name"].(string)] = x["pending"]
|
||||||
|
}
|
||||||
|
if pending["staged.example"] != "add" || pending["more.example"] != nil {
|
||||||
|
t.Errorf("pending rules = %v", pending)
|
||||||
|
}
|
||||||
|
if l := e.list("/blocking/lists?changeset="+cs, e.token); len(l) != 1 || l[0]["pending"] != "change" || l[0]["state"] != "disabled" {
|
||||||
|
t.Errorf("pending list = %v", l)
|
||||||
|
}
|
||||||
|
e.do("POST", "/changesets/"+cs+"/discard", e.token, nil, 204)
|
||||||
|
|
||||||
|
// Delete rules by name; a missing one is a 404 unless missing_ok.
|
||||||
|
e.do("DELETE", "/blocking/rules?name=more.example", e.token, nil, 200)
|
||||||
|
if _, status := e.req("DELETE", "/blocking/rules?name=more.example", e.token, nil); status != 404 {
|
||||||
|
t.Errorf("deleting a missing rule = %d", status)
|
||||||
|
}
|
||||||
|
e.do("DELETE", "/blocking/rules?name=more.example&missing_ok=1", e.token, nil, 200)
|
||||||
|
|
||||||
|
// Readers see blocking but can't change it.
|
||||||
|
e.do("POST", "/users", e.token, map[string]any{"username": "reader", "password": "reader-password-123", "grant_strings": []string{"blocking:read"}}, 201)
|
||||||
|
var readerID int64
|
||||||
|
for _, u := range e.list("/users", e.token) {
|
||||||
|
if u["username"] == "reader" {
|
||||||
|
readerID = int64(u["id"].(float64))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rt := e.do("POST", "/users/"+itoa(readerID)+"/tokens", e.token, map[string]any{"name": "t"}, 201)["token"].(string)
|
||||||
|
if l := e.list("/blocking/lists", rt); len(l) != 1 {
|
||||||
|
t.Errorf("reader's lists = %v", l)
|
||||||
|
}
|
||||||
|
for _, c := range []struct{ method, path string }{{"POST", "/blocking/rules"}, {"POST", "/blocking/lists/ads/refresh"}, {"DELETE", "/blocking/lists/ads"}} {
|
||||||
|
if _, status := e.req(c.method, c.path, rt, map[string]any{"name": "x.example"}); status != 403 {
|
||||||
|
t.Errorf("reader %s %s = %d", c.method, c.path, status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Pausing.
|
||||||
|
if _, status := e.req("POST", "/blocking/pause", e.token, map[string]any{"minutes": 0}); status != 400 {
|
||||||
|
t.Errorf("a 0-minute pause = %d", status)
|
||||||
|
}
|
||||||
|
e.do("POST", "/blocking/pause", e.token, map[string]any{"minutes": 30}, 201)
|
||||||
|
if st := e.do("GET", "/blocking/status", e.token, nil, 200); st["paused_until"] == nil {
|
||||||
|
t.Errorf("status while paused = %v", st)
|
||||||
|
}
|
||||||
|
if st := e.do("GET", "/status", e.token, nil, 200); st["blocking_paused_until"] == nil {
|
||||||
|
t.Errorf("dashboard while paused = %v", st["blocking_paused_until"])
|
||||||
|
}
|
||||||
|
e.do("POST", "/blocking/resume", e.token, nil, 201)
|
||||||
|
if st := e.do("GET", "/blocking/status", e.token, nil, 200); st["paused_until"] != nil {
|
||||||
|
t.Errorf("status after resuming = %v", st)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A list that can't be downloaded shows on the dashboard.
|
||||||
|
e.do("POST", "/blocking/lists", e.token, map[string]any{"name": "gone", "url": lists.URL + "/missing"}, 201)
|
||||||
|
if l := e.do("POST", "/blocking/lists/gone/refresh", e.token, nil, 200); l["state"] != "error" {
|
||||||
|
t.Errorf("failed list = %v", l)
|
||||||
|
}
|
||||||
|
errs, _ := e.do("GET", "/status", e.token, nil, 200)["blocklist_errors"].([]any)
|
||||||
|
if len(errs) != 1 || errs[0].(map[string]any)["name"] != "gone" {
|
||||||
|
t.Errorf("status blocklist_errors = %v", errs)
|
||||||
|
}
|
||||||
|
e.do("DELETE", "/blocking/lists/gone", e.token, nil, 200)
|
||||||
|
e.do("DELETE", "/blocking/lists/ads", e.token, nil, 200)
|
||||||
|
}
|
||||||
@@ -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})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,409 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"slices"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"jarl/internal/api"
|
||||||
|
"jarl/internal/auth"
|
||||||
|
"jarl/internal/blocking"
|
||||||
|
"jarl/internal/engine"
|
||||||
|
"jarl/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ---- blocking (docs/blocking.md)
|
||||||
|
|
||||||
|
// bytesPerRecord is what named 9.20 needs per policy zone record and view,
|
||||||
|
// measured with lists of a few hundred thousand entries.
|
||||||
|
const bytesPerRecord = 630
|
||||||
|
|
||||||
|
func viewNames(st *model.State, ids []int64) []string {
|
||||||
|
out := []string{}
|
||||||
|
for _, id := range ids {
|
||||||
|
if v := st.Views[id]; v != nil {
|
||||||
|
out = append(out, v.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func presentBlocklist(st *model.State, b *model.Blocklist, status *model.BlocklistStatus) api.Blocklist {
|
||||||
|
views := st.BlockingViews(b.ViewIDs)
|
||||||
|
out := api.Blocklist{Blocklist: *b, Views: viewNames(st, views), AutoViews: len(b.ViewIDs) == 0 && len(st.Views) > 0, Status: status, Step: st.StepName(b.StepID)}
|
||||||
|
switch {
|
||||||
|
case b.Disabled:
|
||||||
|
out.State = "disabled"
|
||||||
|
case len(views) == 0 || !stepReached(st, b, views):
|
||||||
|
out.State = "idle"
|
||||||
|
case status != nil && status.Error != "":
|
||||||
|
out.State = "error"
|
||||||
|
case status == nil || status.UpdatedAt == nil:
|
||||||
|
out.State = "waiting"
|
||||||
|
default:
|
||||||
|
out.State = "ok"
|
||||||
|
}
|
||||||
|
if status != nil && !b.Disabled {
|
||||||
|
out.MemoryMB = int((int64(status.Records)*bytesPerRecord*int64(len(views)) + 1<<19) >> 20)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func presentBlockRule(st *model.State, r *model.BlockRule) api.BlockRule {
|
||||||
|
return api.BlockRule{BlockRule: *r, Views: viewNames(st, st.BlockingViews(r.ViewIDs)), AutoViews: len(r.ViewIDs) == 0 && len(st.Views) > 0}
|
||||||
|
}
|
||||||
|
|
||||||
|
func requireBlocking(p *auth.Principal) error {
|
||||||
|
return require(p, auth.Action{Resource: auth.ResBlocking})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) listBlocklists(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||||
|
if err := requireBlocking(p); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
st, base, err := s.states(r)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
statuses, err := s.store.BlocklistStatuses(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
out := []api.Blocklist{}
|
||||||
|
for _, b := range st.SortedBlocklists() {
|
||||||
|
o := presentBlocklist(st, b, statuses[b.ID])
|
||||||
|
o.Pending = pending(base, base != nil && base.Blocklists[b.ID] == nil, base != nil && base.Blocklists[b.ID] != nil && base.Blocklists[b.ID].Version != b.Version)
|
||||||
|
out = append(out, o)
|
||||||
|
}
|
||||||
|
if base != nil {
|
||||||
|
for _, b := range base.SortedBlocklists() {
|
||||||
|
if st.Blocklists[b.ID] == nil {
|
||||||
|
o := presentBlocklist(base, b, statuses[b.ID])
|
||||||
|
o.Pending = "delete"
|
||||||
|
out = append(out, o)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, out)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// pending returns the pending marker of an object listed with a changeset.
|
||||||
|
func pending(base *model.State, added, changed bool) string {
|
||||||
|
switch {
|
||||||
|
case base == nil:
|
||||||
|
return ""
|
||||||
|
case added:
|
||||||
|
return "add"
|
||||||
|
case changed:
|
||||||
|
return "change"
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) blocklistByPath(r *http.Request, st *model.State) (*model.Blocklist, error) {
|
||||||
|
ref := r.PathValue("id")
|
||||||
|
var b *model.Blocklist
|
||||||
|
if id, err := strconv.ParseInt(ref, 10, 64); err == nil {
|
||||||
|
b = st.Blocklists[id]
|
||||||
|
} else {
|
||||||
|
b = st.BlocklistByName(ref)
|
||||||
|
}
|
||||||
|
if b == nil {
|
||||||
|
return nil, notFound("blocklist %s not found", ref)
|
||||||
|
}
|
||||||
|
return b, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) blocklistObject(ctx context.Context) func(*model.State, int64) any {
|
||||||
|
return func(st *model.State, id int64) any {
|
||||||
|
b := st.Blocklists[id]
|
||||||
|
if b == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
statuses, _ := s.store.BlocklistStatuses(ctx)
|
||||||
|
return presentBlocklist(st, b, statuses[id])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) getBlocklist(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||||
|
if err := requireBlocking(p); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
st, err := s.state(r)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
b, err := s.blocklistByPath(r, st)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, s.blocklistObject(r.Context())(st, b.ID))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) createBlocklist(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||||
|
body, err := readBody(r)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return s.mutate(w, r, p, "", []engine.Request{request(engine.OpBlocklistCreate, 0, body)}, s.blocklistObject(r.Context()))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) updateBlocklist(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||||
|
st, err := s.state(r)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
b, err := s.blocklistByPath(r, st)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
body, err := readBody(r)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
id := b.ID
|
||||||
|
return s.mutate(w, r, p, "", []engine.Request{request(engine.OpBlocklistUpdate, id, body)}, func(st *model.State, _ int64) any { return s.blocklistObject(r.Context())(st, id) })
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) deleteBlocklist(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||||
|
st, err := s.state(r)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
b, err := s.blocklistByPath(r, st)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return s.mutate(w, r, p, "", []engine.Request{request(engine.OpBlocklistDelete, b.ID, nil)}, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// refreshBlocklist: POST /blocking/lists/{id}/refresh[?force=1] downloads
|
||||||
|
// the list now and waits for the result.
|
||||||
|
func (s *Server) refreshBlocklist(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||||
|
if err := require(p, auth.Action{Resource: auth.ResBlocking, Write: true}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
st, err := s.store.LoadState(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
b, err := s.blocklistByPath(r, st)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(r.Context(), 3*time.Minute)
|
||||||
|
defer cancel()
|
||||||
|
status, rerr := s.eng.RefreshBlocklist(ctx, b.ID, queryBool(r, "force"))
|
||||||
|
if status == nil {
|
||||||
|
return rerr
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, presentBlocklist(st, b, status))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) listBlockRules(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||||
|
if err := requireBlocking(p); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
st, base, err := s.states(r)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
out := []api.BlockRule{}
|
||||||
|
for _, rule := range st.SortedBlockRules() {
|
||||||
|
o := presentBlockRule(st, rule)
|
||||||
|
o.Pending = pending(base, base != nil && base.BlockRules[rule.ID] == nil, base != nil && base.BlockRules[rule.ID] != nil && base.BlockRules[rule.ID].Version != rule.Version)
|
||||||
|
out = append(out, o)
|
||||||
|
}
|
||||||
|
if base != nil {
|
||||||
|
for _, rule := range base.SortedBlockRules() {
|
||||||
|
if st.BlockRules[rule.ID] == nil {
|
||||||
|
o := presentBlockRule(base, rule)
|
||||||
|
o.Pending = "delete"
|
||||||
|
out = append(out, o)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, out)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func ruleObject(st *model.State, id int64) any {
|
||||||
|
if rule := st.BlockRules[id]; rule != nil {
|
||||||
|
return presentBlockRule(st, rule)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) ruleByPath(r *http.Request, st *model.State) (*model.BlockRule, error) {
|
||||||
|
id, _ := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||||||
|
rule := st.BlockRules[id]
|
||||||
|
if rule == nil {
|
||||||
|
return nil, notFound("rule %s not found", r.PathValue("id"))
|
||||||
|
}
|
||||||
|
return rule, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) createBlockRule(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||||
|
body, err := readBody(r)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return s.mutate(w, r, p, "", []engine.Request{request(engine.OpBlockRuleCreate, 0, body)}, ruleObject)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) updateBlockRule(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||||
|
st, err := s.state(r)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
rule, err := s.ruleByPath(r, st)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
body, err := readBody(r)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
id := rule.ID
|
||||||
|
return s.mutate(w, r, p, "", []engine.Request{request(engine.OpBlockRuleUpdate, id, body)}, func(st *model.State, _ int64) any { return ruleObject(st, id) })
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) deleteBlockRule(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||||
|
st, err := s.state(r)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
rule, err := s.ruleByPath(r, st)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return s.mutate(w, r, p, "", []engine.Request{request(engine.OpBlockRuleDelete, rule.ID, nil)}, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// deleteBlockRulesByName: DELETE /blocking/rules?name=NAME[&view=VIEW]
|
||||||
|
// deletes the rules for a domain (in one view, or all).
|
||||||
|
func (s *Server) deleteBlockRulesByName(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||||
|
st, err := s.state(r)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
q := r.URL.Query()
|
||||||
|
name := strings.TrimSuffix(strings.ToLower(strings.TrimSpace(q.Get("name"))), ".")
|
||||||
|
if name == "" {
|
||||||
|
return badRequest("give the domain of the rules to delete (name=)")
|
||||||
|
}
|
||||||
|
var viewID int64 = -1
|
||||||
|
if vn := q.Get("view"); vn != "" {
|
||||||
|
v := st.ViewByName(vn)
|
||||||
|
if v == nil {
|
||||||
|
return notFound("view %s not found", vn)
|
||||||
|
}
|
||||||
|
viewID = v.ID
|
||||||
|
}
|
||||||
|
var reqs []engine.Request
|
||||||
|
for _, rule := range st.SortedBlockRules() {
|
||||||
|
if rule.Name != name {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if viewID >= 0 && !slices.Contains(st.BlockingViews(rule.ViewIDs), viewID) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
reqs = append(reqs, request(engine.OpBlockRuleDelete, rule.ID, nil))
|
||||||
|
}
|
||||||
|
if len(reqs) == 0 {
|
||||||
|
if queryBool(r, "missing_ok") {
|
||||||
|
writeJSON(w, http.StatusOK, api.MutationResponse{})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return notFound("there is no rule for %s", name)
|
||||||
|
}
|
||||||
|
return s.mutate(w, r, p, "", reqs, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkBlocking: GET /blocking/check?name=NAME says what blocking does
|
||||||
|
// with a name in each view.
|
||||||
|
func (s *Server) checkBlocking(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
|
||||||
|
}
|
||||||
|
out, err := s.eng.CheckBlocking(st, r.URL.Query().Get("name"))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if out == nil {
|
||||||
|
out = []blocking.Match{}
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, out)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// blocklistErrors lists the enabled blocklists whose last update failed,
|
||||||
|
// for the dashboard.
|
||||||
|
func (s *Server) blocklistErrors(ctx context.Context) []api.BlocklistError {
|
||||||
|
st, err := s.store.LoadState(ctx)
|
||||||
|
if err != nil || len(st.Blocklists) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
statuses, err := s.store.BlocklistStatuses(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var out []api.BlocklistError
|
||||||
|
for _, b := range st.SortedBlocklists() {
|
||||||
|
if s := statuses[b.ID]; s != nil && s.Error != "" && !b.Disabled {
|
||||||
|
out = append(out, api.BlocklistError{Name: b.Name, Error: s.Error, UpdatedAt: s.UpdatedAt})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -279,6 +279,13 @@ func (s *Server) status(w http.ResponseWriter, r *http.Request, p *auth.Principa
|
|||||||
resp.Backup = s.backupStatus(r.Context(), now)
|
resp.Backup = s.backupStatus(r.Context(), now)
|
||||||
resp.TOTP = s.totpOverview(r.Context())
|
resp.TOTP = s.totpOverview(r.Context())
|
||||||
}
|
}
|
||||||
|
if p.Can(auth.Action{Resource: auth.ResBlocking}) {
|
||||||
|
resp.BlocklistErrors = s.blocklistErrors(r.Context())
|
||||||
|
if st, err := s.store.LoadState(r.Context()); err == nil {
|
||||||
|
resp.BlockingPausedUntil = st.Settings.BlockingPausedUntil
|
||||||
|
resp.BlockingPausedIndefinitely = st.Settings.BlockingPausedIndefinitely
|
||||||
|
}
|
||||||
|
}
|
||||||
writeJSON(w, http.StatusOK, resp)
|
writeJSON(w, http.StatusOK, resp)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,265 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"slices"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"jarl/internal/api"
|
||||||
|
"jarl/internal/auth"
|
||||||
|
"jarl/internal/engine"
|
||||||
|
"jarl/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Blocking steps and client groups (docs/blocking.md).
|
||||||
|
|
||||||
|
func presentBlockStep(st *model.State, s *model.BlockStep) api.BlockStep {
|
||||||
|
out := api.BlockStep{BlockStep: *s, Lists: []string{}, Groups: []string{}}
|
||||||
|
for _, b := range st.SortedBlocklists() {
|
||||||
|
if b.StepID == s.ID {
|
||||||
|
out.Lists = append(out.Lists, b.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, g := range st.SortedClientGroups() {
|
||||||
|
if g.StepID == s.ID {
|
||||||
|
out.Groups = append(out.Groups, g.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func presentClientGroup(st *model.State, g *model.ClientGroup) api.ClientGroup {
|
||||||
|
return api.ClientGroup{ClientGroup: *g, Step: st.StepName(g.StepID), Views: viewNames(st, st.BlockingViews(g.ViewIDs)), AutoViews: len(g.ViewIDs) == 0 && len(st.Views) > 0}
|
||||||
|
}
|
||||||
|
|
||||||
|
func stepObject(st *model.State, id int64) any {
|
||||||
|
if s := st.BlockSteps[id]; s != nil {
|
||||||
|
return presentBlockStep(st, s)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func groupObject(st *model.State, id int64) any {
|
||||||
|
if g := st.ClientGroups[id]; g != nil {
|
||||||
|
return presentClientGroup(st, g)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) listBlockSteps(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||||
|
if err := requireBlocking(p); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
st, base, err := s.states(r)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
out := []api.BlockStep{}
|
||||||
|
for _, x := range st.OrderedBlockSteps() {
|
||||||
|
o := presentBlockStep(st, x)
|
||||||
|
o.Pending = pending(base, base != nil && base.BlockSteps[x.ID] == nil, base != nil && base.BlockSteps[x.ID] != nil && base.BlockSteps[x.ID].Version != x.Version)
|
||||||
|
out = append(out, o)
|
||||||
|
}
|
||||||
|
if base != nil {
|
||||||
|
for _, x := range base.OrderedBlockSteps() {
|
||||||
|
if st.BlockSteps[x.ID] == nil {
|
||||||
|
o := presentBlockStep(base, x)
|
||||||
|
o.Pending = "delete"
|
||||||
|
out = append(out, o)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, out)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// stepByPath finds a step above normal by ID or name.
|
||||||
|
func stepByPath(r *http.Request, st *model.State) (*model.BlockStep, error) {
|
||||||
|
ref := r.PathValue("id")
|
||||||
|
if id, err := strconv.ParseInt(ref, 10, 64); err == nil {
|
||||||
|
if x := st.BlockSteps[id]; x != nil {
|
||||||
|
return x, nil
|
||||||
|
}
|
||||||
|
} else if id, ok := st.StepByName(ref); ok && id > 0 {
|
||||||
|
return st.BlockSteps[id], nil
|
||||||
|
}
|
||||||
|
return nil, notFound("blocking step %s not found (off and normal are built in)", ref)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) createBlockStep(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||||
|
body, err := readBody(r)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return s.mutate(w, r, p, "", []engine.Request{request(engine.OpBlockStepCreate, 0, body)}, stepObject)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) updateBlockStep(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||||
|
st, err := s.state(r)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
x, err := stepByPath(r, st)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
body, err := readBody(r)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
id := x.ID
|
||||||
|
return s.mutate(w, r, p, "", []engine.Request{request(engine.OpBlockStepUpdate, id, body)}, func(st *model.State, _ int64) any { return stepObject(st, id) })
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) deleteBlockStep(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||||
|
st, err := s.state(r)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
x, err := stepByPath(r, st)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return s.mutate(w, r, p, "", []engine.Request{request(engine.OpBlockStepDelete, x.ID, nil)}, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// orderBlockSteps: POST /blocking/steps/order {"steps": ["strict",
|
||||||
|
// "very-strict"]} (names or IDs as strings) sets the order of the steps
|
||||||
|
// above normal, lowest first.
|
||||||
|
func (s *Server) orderBlockSteps(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||||
|
st, err := s.state(r)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var in struct {
|
||||||
|
Steps []string `json:"steps"`
|
||||||
|
}
|
||||||
|
if err := decodeJSON(r, &in); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(in.Steps) != len(st.BlockSteps) {
|
||||||
|
return badRequest("give every step above normal once (%d)", len(st.BlockSteps))
|
||||||
|
}
|
||||||
|
var reqs []engine.Request
|
||||||
|
seen := map[int64]bool{}
|
||||||
|
for i, ref := range in.Steps {
|
||||||
|
var x *model.BlockStep
|
||||||
|
if id, err := strconv.ParseInt(ref, 10, 64); err == nil {
|
||||||
|
x = st.BlockSteps[id]
|
||||||
|
} else if id, ok := st.StepByName(ref); ok && id > 0 {
|
||||||
|
x = st.BlockSteps[id]
|
||||||
|
}
|
||||||
|
if x == nil || seen[x.ID] {
|
||||||
|
return badRequest("step %q doesn't exist or is given twice", ref)
|
||||||
|
}
|
||||||
|
seen[x.ID] = true
|
||||||
|
if x.Position != i {
|
||||||
|
data, _ := json.Marshal(engine.BlockStepInput{Position: model.Ptr(i)})
|
||||||
|
reqs = append(reqs, request(engine.OpBlockStepUpdate, x.ID, data))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(reqs) == 0 {
|
||||||
|
writeJSON(w, http.StatusOK, map[string]string{"message": "the steps are already in that order"})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return s.mutate(w, r, p, "", reqs, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) listClientGroups(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||||
|
if err := requireBlocking(p); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
st, base, err := s.states(r)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
out := []api.ClientGroup{}
|
||||||
|
for _, g := range st.SortedClientGroups() {
|
||||||
|
o := presentClientGroup(st, g)
|
||||||
|
o.Pending = pending(base, base != nil && base.ClientGroups[g.ID] == nil, base != nil && base.ClientGroups[g.ID] != nil && base.ClientGroups[g.ID].Version != g.Version)
|
||||||
|
out = append(out, o)
|
||||||
|
}
|
||||||
|
if base != nil {
|
||||||
|
for _, g := range base.SortedClientGroups() {
|
||||||
|
if st.ClientGroups[g.ID] == nil {
|
||||||
|
o := presentClientGroup(base, g)
|
||||||
|
o.Pending = "delete"
|
||||||
|
out = append(out, o)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, out)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func groupByPath(r *http.Request, st *model.State) (*model.ClientGroup, error) {
|
||||||
|
ref := r.PathValue("id")
|
||||||
|
if id, err := strconv.ParseInt(ref, 10, 64); err == nil {
|
||||||
|
if g := st.ClientGroups[id]; g != nil {
|
||||||
|
return g, nil
|
||||||
|
}
|
||||||
|
} else if g := st.ClientGroupByName(ref); g != nil {
|
||||||
|
return g, nil
|
||||||
|
}
|
||||||
|
return nil, notFound("client group %s not found", ref)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) createClientGroup(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||||
|
body, err := readBody(r)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return s.mutate(w, r, p, "", []engine.Request{request(engine.OpClientGroupCreate, 0, body)}, groupObject)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) updateClientGroup(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||||
|
st, err := s.state(r)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
g, err := groupByPath(r, st)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
body, err := readBody(r)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
id := g.ID
|
||||||
|
return s.mutate(w, r, p, "", []engine.Request{request(engine.OpClientGroupUpdate, id, body)}, func(st *model.State, _ int64) any { return groupObject(st, id) })
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) deleteClientGroup(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||||
|
st, err := s.state(r)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
g, err := groupByPath(r, st)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return s.mutate(w, r, p, "", []engine.Request{request(engine.OpClientGroupDelete, g.ID, nil)}, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// stepReached reports whether any client gets a list: always for a list at
|
||||||
|
// normal; for one above it, when a client group with members in one of its
|
||||||
|
// views is at its step or above.
|
||||||
|
func stepReached(st *model.State, b *model.Blocklist, views []int64) bool {
|
||||||
|
rank, _ := st.StepRank(b.StepID)
|
||||||
|
if rank <= 0 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
for _, g := range st.ClientGroups {
|
||||||
|
r, ok := st.StepRank(g.StepID)
|
||||||
|
if !ok || r < rank || len(st.GroupPrefixes(g)) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, v := range st.BlockingViews(g.ViewIDs) {
|
||||||
|
if slices.Contains(views, v) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
@@ -13,7 +13,9 @@ import (
|
|||||||
"jarl/internal/catalog"
|
"jarl/internal/catalog"
|
||||||
"jarl/internal/dnsname"
|
"jarl/internal/dnsname"
|
||||||
"jarl/internal/engine"
|
"jarl/internal/engine"
|
||||||
|
"jarl/internal/iprange"
|
||||||
"jarl/internal/model"
|
"jarl/internal/model"
|
||||||
|
"jarl/internal/render"
|
||||||
"jarl/internal/rr"
|
"jarl/internal/rr"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -92,6 +94,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 {
|
||||||
@@ -528,6 +555,42 @@ func (s *Server) catalogRRTypes(w http.ResponseWriter, r *http.Request, p *auth.
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// catalogRanges: GET /catalog/ranges?r=A-B,C-D shows the prefixes each
|
||||||
|
// address range is written to named.conf as (the web UI shows them while
|
||||||
|
// you type). Text that isn't a range is left out; a range with a mistake
|
||||||
|
// comes back with its error.
|
||||||
|
func (s *Server) catalogRanges(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||||
|
type rangeInfo struct {
|
||||||
|
Input string `json:"input"`
|
||||||
|
Range string `json:"range,omitempty"`
|
||||||
|
Prefixes []string `json:"prefixes,omitempty"`
|
||||||
|
Size string `json:"size,omitempty"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
}
|
||||||
|
out := []rangeInfo{}
|
||||||
|
for _, in := range strings.Split(r.URL.Query().Get("r"), ",") {
|
||||||
|
in = strings.TrimSpace(in)
|
||||||
|
if in == "" || len(out) >= 100 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
rg, ok, err := iprange.Parse(strings.TrimSpace(strings.TrimPrefix(in, "!")))
|
||||||
|
switch {
|
||||||
|
case !ok:
|
||||||
|
continue
|
||||||
|
case err != nil:
|
||||||
|
out = append(out, rangeInfo{Input: in, Error: err.Error()})
|
||||||
|
default:
|
||||||
|
ri := rangeInfo{Input: in, Range: rg.String(), Size: rg.SizeText()}
|
||||||
|
for _, pf := range rg.Prefixes() {
|
||||||
|
ri.Prefixes = append(ri.Prefixes, pf.String())
|
||||||
|
}
|
||||||
|
out = append(out, ri)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, out)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Server) catalogGrants(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
func (s *Server) catalogGrants(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||||
roles := map[string][]string{}
|
roles := map[string][]string{}
|
||||||
for name, gs := range auth.RoleTemplates {
|
for name, gs := range auth.RoleTemplates {
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -0,0 +1,474 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
|
"crypto/subtle"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"math"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/netip"
|
||||||
|
"slices"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"jarl/internal/dnssec"
|
||||||
|
"jarl/internal/engine"
|
||||||
|
"jarl/internal/model"
|
||||||
|
"jarl/internal/render"
|
||||||
|
"jarl/internal/stats"
|
||||||
|
"jarl/internal/validate"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Metrics configures the Prometheus endpoint /metrics (docs/metrics.md).
|
||||||
|
type Metrics struct {
|
||||||
|
Enabled bool
|
||||||
|
// Listen are extra addresses serving only /metrics (plain HTTP).
|
||||||
|
Listen []string
|
||||||
|
// Allow may scrape without a token; Token, when set, lets anyone who
|
||||||
|
// sends it scrape.
|
||||||
|
Allow []netip.Prefix
|
||||||
|
Token string
|
||||||
|
}
|
||||||
|
|
||||||
|
// metricsStateTTL is how long the Jarl state part of /metrics is reused:
|
||||||
|
// it runs rndc status, validation and the drift check, too much for every
|
||||||
|
// scrape when several Prometheus servers scrape often.
|
||||||
|
const metricsStateTTL = 15 * time.Second
|
||||||
|
|
||||||
|
// metricsCache holds the last Jarl state part of /metrics.
|
||||||
|
type metricsCache struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
at time.Time
|
||||||
|
body []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
const metricsContentType = "text/plain; version=0.0.4; charset=utf-8"
|
||||||
|
|
||||||
|
// metrics serves GET /metrics in Prometheus's text format.
|
||||||
|
func (s *Server) metrics(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !s.cfg.Metrics.Enabled {
|
||||||
|
http.Error(w, "metrics are disabled; set metrics.enabled: true in /etc/jarl/jarl.yaml and restart jarl (see docs/metrics.md)", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if code, msg := s.metricsAccess(r); code != 0 {
|
||||||
|
if code == http.StatusUnauthorized {
|
||||||
|
w.Header().Set("WWW-Authenticate", `Bearer realm="jarl metrics"`)
|
||||||
|
}
|
||||||
|
http.Error(w, msg, code)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var buf bytes.Buffer
|
||||||
|
buf.Write(s.stateMetrics(r.Context()))
|
||||||
|
if c := s.cfg.Stats; c != nil {
|
||||||
|
pw := &promWriter{}
|
||||||
|
statsMetrics(pw, c.Totals(), c.Server(), c.Status())
|
||||||
|
pw.writeTo(&buf)
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", metricsContentType)
|
||||||
|
w.Header().Set("Cache-Control", "no-store")
|
||||||
|
w.Write(buf.Bytes())
|
||||||
|
}
|
||||||
|
|
||||||
|
// metricsAccess decides whether a scrape is allowed: from an address in
|
||||||
|
// metrics.allow, with metrics.token, or on the local socket from root or
|
||||||
|
// jarld's own user. It returns 0 when allowed, or the status and message
|
||||||
|
// to refuse with.
|
||||||
|
func (s *Server) metricsAccess(r *http.Request) (int, string) {
|
||||||
|
m := s.cfg.Metrics
|
||||||
|
ip := s.clientIP(r)
|
||||||
|
if h := r.Header.Get("Authorization"); h != "" {
|
||||||
|
scheme, tok, _ := strings.Cut(h, " ")
|
||||||
|
if m.Token == "" || !strings.EqualFold(scheme, "Bearer") {
|
||||||
|
return http.StatusUnauthorized, "this server takes no token for /metrics (metrics.token is not set)"
|
||||||
|
}
|
||||||
|
if s.limiter.blocked("ip:"+ip, failsPerIP) {
|
||||||
|
return http.StatusTooManyRequests, "too many failed attempts; try again later"
|
||||||
|
}
|
||||||
|
got, want := sha256.Sum256([]byte(strings.TrimSpace(tok))), sha256.Sum256([]byte(m.Token))
|
||||||
|
if subtle.ConstantTimeCompare(got[:], want[:]) != 1 {
|
||||||
|
s.limiter.fail("ip:" + ip)
|
||||||
|
s.log.Warn("metrics: wrong token", "ip", ip)
|
||||||
|
return http.StatusUnauthorized, "wrong token for /metrics"
|
||||||
|
}
|
||||||
|
return 0, ""
|
||||||
|
}
|
||||||
|
if pr := peerOf(r.Context()); pr != nil {
|
||||||
|
if trustedUID(pr.UID) {
|
||||||
|
return 0, ""
|
||||||
|
}
|
||||||
|
return http.StatusForbidden, "on the local socket, only root and jarld's own user may read /metrics without metrics.token"
|
||||||
|
}
|
||||||
|
if a, err := netip.ParseAddr(ip); err == nil {
|
||||||
|
a = a.Unmap().WithZone("")
|
||||||
|
for _, p := range m.Allow {
|
||||||
|
if p.Contains(a) {
|
||||||
|
return 0, ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if m.Token != "" {
|
||||||
|
return http.StatusUnauthorized, "send metrics.token as 'Authorization: Bearer TOKEN', or add " + ip + " to metrics.allow in jarl.yaml"
|
||||||
|
}
|
||||||
|
return http.StatusForbidden, ip + " may not read /metrics: add it to metrics.allow in jarl.yaml (or set metrics.token)"
|
||||||
|
}
|
||||||
|
|
||||||
|
// serveMetricsListeners serves /metrics alone on metrics.listen.
|
||||||
|
func (s *Server) serveMetricsListeners(errc chan error) error {
|
||||||
|
if !s.cfg.Metrics.Enabled || len(s.cfg.Metrics.Listen) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
mux.HandleFunc("GET /metrics", s.metrics)
|
||||||
|
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
http.Error(w, "this address serves only /metrics", http.StatusNotFound)
|
||||||
|
})
|
||||||
|
h := s.recoverer(s.logRequests(mux))
|
||||||
|
for _, addr := range s.cfg.Metrics.Listen {
|
||||||
|
ln, err := net.Listen("tcp", addr)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("metrics.listen %s: %w", addr, err)
|
||||||
|
}
|
||||||
|
srv := &http.Server{Handler: h, ReadHeaderTimeout: 10 * time.Second, WriteTimeout: time.Minute, IdleTimeout: 2 * time.Minute,
|
||||||
|
ErrorLog: slog.NewLogLogger(s.log.Handler(), slog.LevelWarn)}
|
||||||
|
s.mu.Lock()
|
||||||
|
s.srvs = append(s.srvs, srv)
|
||||||
|
s.mu.Unlock()
|
||||||
|
s.log.Info("serving metrics", "url", "http://"+ln.Addr().String()+"/metrics")
|
||||||
|
go func() {
|
||||||
|
if err := srv.Serve(ln); !errors.Is(err, http.ErrServerClosed) {
|
||||||
|
errc <- err
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// stateMetrics renders Jarl's own state (cached for metricsStateTTL).
|
||||||
|
func (s *Server) stateMetrics(ctx context.Context) []byte {
|
||||||
|
c := &s.metricsCache
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
now := time.Now()
|
||||||
|
if c.body != nil && now.Sub(c.at) < metricsStateTTL {
|
||||||
|
return c.body
|
||||||
|
}
|
||||||
|
pw := &promWriter{}
|
||||||
|
s.collectState(ctx, pw, now)
|
||||||
|
var buf bytes.Buffer
|
||||||
|
pw.writeTo(&buf)
|
||||||
|
c.body, c.at = buf.Bytes(), now
|
||||||
|
return c.body
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) collectState(ctx context.Context, pw *promWriter, now time.Time) {
|
||||||
|
pw.gauge("jarl_build_info", "Jarl's version (always 1).", 1, "version", s.cfg.Version)
|
||||||
|
pw.gauge("jarl_start_time_seconds", "When jarld started (Unix time).", unix(s.started))
|
||||||
|
ok := 1.0
|
||||||
|
defer func() {
|
||||||
|
pw.gauge("jarl_metrics_state_ok", "1 when Jarl's state could be read for this scrape.", ok)
|
||||||
|
}()
|
||||||
|
|
||||||
|
st, err := s.eng.Status(ctx)
|
||||||
|
if err != nil {
|
||||||
|
s.log.Warn("metrics: reading the status failed", "err", err)
|
||||||
|
ok = 0
|
||||||
|
return
|
||||||
|
}
|
||||||
|
pw.gauge("jarl_adopted", "1 when named loads Jarl's configuration.", b2f(st.Adopted))
|
||||||
|
if st.Named != nil || st.NamedError != "" {
|
||||||
|
pw.gauge("jarl_named_up", "1 when named answers rndc status.", b2f(st.Named != nil && st.Named.Running))
|
||||||
|
}
|
||||||
|
if n := st.Named; n != nil {
|
||||||
|
if v := fractions(n.RecursiveClients); len(v) == 3 {
|
||||||
|
pw.gauge("jarl_named_recursive_clients", "Recursive clients named is serving now (rndc status).", v[0])
|
||||||
|
pw.gauge("jarl_named_recursive_clients_soft_limit", "Recursive clients at which named starts dropping the oldest queries.", v[1])
|
||||||
|
pw.gauge("jarl_named_recursive_clients_limit", "Most recursive clients named serves at once (recursive-clients).", v[2])
|
||||||
|
}
|
||||||
|
if v := fractions(n.TCPClients); len(v) == 2 {
|
||||||
|
pw.gauge("jarl_named_tcp_clients", "TCP clients named is serving now (rndc status).", v[0])
|
||||||
|
pw.gauge("jarl_named_tcp_clients_limit", "Most TCP clients named serves at once (tcp-clients).", v[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pw.gauge("jarl_open_changesets", "Changesets with staged changes that are not applied yet.", float64(st.OpenChangesets))
|
||||||
|
if st.LastApplied != nil && st.LastApplied.AppliedAt != nil {
|
||||||
|
pw.gauge("jarl_last_apply_timestamp_seconds", "When the last changeset was applied (Unix time).", unix(*st.LastApplied.AppliedAt))
|
||||||
|
}
|
||||||
|
sev := map[validate.Severity]int{validate.Error: 0, validate.Warning: 0, validate.Info: 0}
|
||||||
|
for _, p := range st.Problems {
|
||||||
|
sev[p.Severity]++
|
||||||
|
}
|
||||||
|
for _, k := range []validate.Severity{validate.Error, validate.Warning, validate.Info} {
|
||||||
|
pw.gauge("jarl_problems", "Validation problems in the current configuration, as on the dashboard.", float64(sev[k]), "severity", string(k))
|
||||||
|
}
|
||||||
|
pw.gauge("jarl_drifted_files", "Files Jarl manages that were changed or removed outside Jarl.", float64(len(st.Drift)))
|
||||||
|
for _, k := range sortedKeys(st.Counts) {
|
||||||
|
pw.gauge("jarl_objects", "Objects in Jarl's configuration.", float64(st.Counts[k]), "kind", k)
|
||||||
|
}
|
||||||
|
|
||||||
|
if s.cfg.BackupDir != "" {
|
||||||
|
bs := s.backupStatus(ctx, now)
|
||||||
|
if bs.LastAt != nil {
|
||||||
|
pw.gauge("jarl_backup_last_success_timestamp_seconds", "When the newest backup was made by 'jarld backup' (Unix time).", unix(*bs.LastAt))
|
||||||
|
pw.gauge("jarl_backup_last_size_bytes", "Size of the newest backup.", float64(bs.LastSize))
|
||||||
|
}
|
||||||
|
if bs.LastErrorAt != nil {
|
||||||
|
pw.gauge("jarl_backup_last_failure_timestamp_seconds", "When a backup last failed, if after the newest backup (Unix time).", unix(*bs.LastErrorAt))
|
||||||
|
}
|
||||||
|
if s.cfg.BackupWarnAfter > 0 {
|
||||||
|
pw.gauge("jarl_backup_stale", "1 when the newest backup is older than backup.warn_after.", b2f(bs.Stale))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
state, err := s.store.LoadState(ctx)
|
||||||
|
if err != nil {
|
||||||
|
ok = 0
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(state.Blocklists) > 0 || len(state.BlockRules) > 0 {
|
||||||
|
pw.gauge("jarl_blocking_paused", "1 while blocking is paused.", b2f(state.Settings.BlockingPaused()))
|
||||||
|
if t := state.Settings.BlockingPausedUntil; t != nil {
|
||||||
|
pw.gauge("jarl_blocking_paused_until_timestamp_seconds", "When paused blocking resumes (Unix time).", unix(*t))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
statuses, _ := s.store.BlocklistStatuses(ctx)
|
||||||
|
for _, b := range state.SortedBlocklists() {
|
||||||
|
pw.gauge("jarl_blocklist_enabled", "1 for blocklists that are switched on.", b2f(!b.Disabled), "list", b.Name)
|
||||||
|
bs := statuses[b.ID]
|
||||||
|
if bs == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
pw.gauge("jarl_blocklist_entries", "Domains in the blocklist's last good download.", float64(bs.Entries), "list", b.Name)
|
||||||
|
pw.gauge("jarl_blocklist_failing", "1 when the last download or check of an enabled blocklist failed.", b2f(bs.Error != "" && !b.Disabled), "list", b.Name)
|
||||||
|
if bs.CheckedAt != nil {
|
||||||
|
pw.gauge("jarl_blocklist_last_check_timestamp_seconds", "When the blocklist was last checked for changes (Unix time).", unix(*bs.CheckedAt), "list", b.Name)
|
||||||
|
}
|
||||||
|
if bs.UpdatedAt != nil {
|
||||||
|
pw.gauge("jarl_blocklist_last_change_timestamp_seconds", "When the blocklist's content last changed (Unix time).", unix(*bs.UpdatedAt), "list", b.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
zones := make([]*model.Zone, 0, len(state.Zones))
|
||||||
|
for _, z := range state.Zones {
|
||||||
|
if z.DNSSECPolicy != "" && !z.Disabled {
|
||||||
|
zones = append(zones, z)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
slices.SortFunc(zones, func(a, b *model.Zone) int {
|
||||||
|
return strings.Compare(a.Name+" "+state.ViewName(a), b.Name+" "+state.ViewName(b))
|
||||||
|
})
|
||||||
|
for _, z := range zones {
|
||||||
|
keys, err := dnssec.Keys(render.KeyDir(state, z, st.Paths), z.Name)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
ph := dnssec.Assess(z.DNSSECPolicy, keys)
|
||||||
|
pw.gauge("jarl_dnssec_phase", "The DNSSEC phase of each signed zone (always 1; see docs/dnssec.md). ready: add the DS at the parent; removing_ds: remove it.", 1,
|
||||||
|
"zone", engine.Display(z.Name), "view", state.ViewName(z), "phase", ph.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
if tokens, err := s.store.ListTokens(ctx, 0); err == nil {
|
||||||
|
for _, t := range tokens {
|
||||||
|
if t.ExpiresAt != nil && t.RevokedAt == nil {
|
||||||
|
pw.gauge("jarl_token_expiry_timestamp_seconds", "When each API token with an expiry date expires (Unix time).", unix(*t.ExpiresAt),
|
||||||
|
"id", strconv.FormatInt(t.ID, 10), "user", t.Username, "token", t.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// counterMetrics maps the statistics counters (docs/stats.md) to metrics:
|
||||||
|
// a key "PREFIX:VALUE" becomes metric{label="VALUE"}, a key without a
|
||||||
|
// colon a metric without labels.
|
||||||
|
var counterMetrics = map[string]struct{ name, label, help string }{
|
||||||
|
"queries": {"jarl_queries_total", "", "Queries named received (statistics channel)."},
|
||||||
|
"recursion": {"jarl_recursion_queries_total", "", "Queries that caused recursion."},
|
||||||
|
"dropped": {"jarl_dropped_queries_total", "", "Queries named dropped."},
|
||||||
|
"duplicate": {"jarl_duplicate_queries_total", "", "Duplicate queries named ignored."},
|
||||||
|
"rcode": {"jarl_responses_by_rcode_total", "rcode", "Responses by response code."},
|
||||||
|
"qtype": {"jarl_queries_by_type_total", "type", "Queries by record type."},
|
||||||
|
"answer": {"jarl_answers_total", "source", "Answers that were authoritative or not (recursive)."},
|
||||||
|
"transport": {"jarl_queries_by_transport_total", "transport", "Queries over UDP and TCP."},
|
||||||
|
"ratelimit": {"jarl_ratelimit_responses_total", "action", "Responses rate limiting dropped or slipped (truncated)."},
|
||||||
|
"rtt": {"jarl_upstream_responses_by_rtt_total", "rtt", "Responses from other servers during recursion, by round-trip time."},
|
||||||
|
"dnssec": {"jarl_dnssec_validations_total", "result", "DNSSEC validations of recursive answers by result."},
|
||||||
|
"cache": {"jarl_cache_lookups_total", "result", "Cache lookups that hit or missed."},
|
||||||
|
"responses": {"jarl_logged_responses_total", "", "Responses read from named's response log (without stats.exclude_clients)."},
|
||||||
|
"view": {"jarl_responses_by_view_total", "view", "Logged responses by view."},
|
||||||
|
"zone": {"jarl_responses_by_zone_total", "zone", "Logged responses by local zone (\"-\": names in no local zone)."},
|
||||||
|
"blocked": {"jarl_blocked_queries_total", "", "Queries blocking answered."},
|
||||||
|
"blocklist": {"jarl_blocked_by_list_total", "list", "Blocked queries by blocklist (\"rules\": Jarl's own rules)."},
|
||||||
|
}
|
||||||
|
|
||||||
|
// statsMetrics adds the query statistics: counters since jarld started,
|
||||||
|
// and named's process and server gauges.
|
||||||
|
func statsMetrics(pw *promWriter, totals map[string]int64, srv *stats.Server, st stats.Status) {
|
||||||
|
if _, ok := totals["queries"]; !ok {
|
||||||
|
totals["queries"] = 0
|
||||||
|
}
|
||||||
|
for _, k := range sortedKeys(totals) {
|
||||||
|
prefix, value, labeled := strings.Cut(k, ":")
|
||||||
|
m, ok := counterMetrics[prefix]
|
||||||
|
if !ok || labeled != (m.label != "") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if labeled {
|
||||||
|
pw.counter(m.name, m.help, float64(totals[k]), m.label, value)
|
||||||
|
} else {
|
||||||
|
pw.counter(m.name, m.help, float64(totals[k]))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pw.gauge("jarl_stats_poll_ok", "1 when the last poll of named's statistics channel worked.", b2f(st.PollError == ""))
|
||||||
|
if st.LastPoll != nil {
|
||||||
|
pw.gauge("jarl_stats_last_poll_timestamp_seconds", "When named's statistics channel was last read (Unix time).", unix(*st.LastPoll))
|
||||||
|
}
|
||||||
|
if st.QueryLog {
|
||||||
|
pw.counter("jarl_stats_log_lines_total", "Lines read from named's response log.", float64(st.LinesRead))
|
||||||
|
if st.LastLine != nil {
|
||||||
|
pw.gauge("jarl_stats_last_log_line_timestamp_seconds", "Time of the last response log line read (Unix time).", unix(*st.LastLine))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pw.gauge("jarl_stats_warnings", "Warnings about statistics collection, as on the Stats page.", float64(len(st.Warnings)))
|
||||||
|
if srv == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if t, err := time.Parse(time.RFC3339Nano, srv.BootTime); err == nil {
|
||||||
|
pw.gauge("jarl_named_start_time_seconds", "When named started (Unix time).", unix(t))
|
||||||
|
}
|
||||||
|
if t, err := time.Parse(time.RFC3339Nano, srv.ConfigTime); err == nil {
|
||||||
|
pw.gauge("jarl_named_config_time_seconds", "When named last loaded its configuration (Unix time).", unix(t))
|
||||||
|
}
|
||||||
|
if srv.PID != 0 {
|
||||||
|
pw.gauge("jarl_named_resident_memory_bytes", "named's resident memory (/proc).", float64(srv.RSS))
|
||||||
|
pw.gauge("jarl_named_threads", "named's threads (/proc).", float64(srv.Threads))
|
||||||
|
pw.counter("jarl_named_cpu_seconds_total", "CPU time named used since it started (/proc).", srv.CPUSeconds)
|
||||||
|
}
|
||||||
|
if srv.Version != "" {
|
||||||
|
pw.gauge("jarl_named_memory_in_use_bytes", "Memory named counts as in use (statistics channel).", float64(srv.MemInUse))
|
||||||
|
pw.gauge("jarl_named_cache_rrsets", "RRsets in named's caches, all views.", float64(srv.CacheRRsets))
|
||||||
|
pw.gauge("jarl_named_cache_memory_bytes", "Memory named's caches use, all views.", float64(srv.CacheMem))
|
||||||
|
pw.gauge("jarl_named_sockets", "Open sockets.", float64(srv.SocketsUDP), "proto", "udp")
|
||||||
|
pw.gauge("jarl_named_sockets", "Open sockets.", float64(srv.SocketsTCP), "proto", "tcp")
|
||||||
|
pw.gauge("jarl_named_recursive_clients_highwater", "Most recursive clients at once since named started.", float64(srv.RecursionHighwater))
|
||||||
|
pw.gauge("jarl_named_tcp_clients_highwater", "Most TCP clients at once since named started.", float64(srv.TCPHighwater))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// promWriter collects metrics and writes them in Prometheus's text format,
|
||||||
|
// each metric's samples together under one HELP and TYPE.
|
||||||
|
type promWriter struct {
|
||||||
|
order []string
|
||||||
|
fams map[string]*promFamily
|
||||||
|
}
|
||||||
|
|
||||||
|
type promFamily struct {
|
||||||
|
typ, help string
|
||||||
|
lines []string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *promWriter) gauge(name, help string, v float64, labels ...string) {
|
||||||
|
w.add(name, "gauge", help, v, labels)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *promWriter) counter(name, help string, v float64, labels ...string) {
|
||||||
|
w.add(name, "counter", help, v, labels)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *promWriter) add(name, typ, help string, v float64, labels []string) {
|
||||||
|
if w.fams == nil {
|
||||||
|
w.fams = map[string]*promFamily{}
|
||||||
|
}
|
||||||
|
f := w.fams[name]
|
||||||
|
if f == nil {
|
||||||
|
f = &promFamily{typ: typ, help: help}
|
||||||
|
w.fams[name] = f
|
||||||
|
w.order = append(w.order, name)
|
||||||
|
}
|
||||||
|
var b strings.Builder
|
||||||
|
b.WriteString(name)
|
||||||
|
if len(labels) > 0 {
|
||||||
|
b.WriteByte('{')
|
||||||
|
for i := 0; i+1 < len(labels); i += 2 {
|
||||||
|
if i > 0 {
|
||||||
|
b.WriteByte(',')
|
||||||
|
}
|
||||||
|
b.WriteString(labels[i])
|
||||||
|
b.WriteString(`="`)
|
||||||
|
b.WriteString(labelEscaper.Replace(labels[i+1]))
|
||||||
|
b.WriteByte('"')
|
||||||
|
}
|
||||||
|
b.WriteByte('}')
|
||||||
|
}
|
||||||
|
b.WriteByte(' ')
|
||||||
|
b.WriteString(formatFloat(v))
|
||||||
|
f.lines = append(f.lines, b.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *promWriter) writeTo(buf *bytes.Buffer) {
|
||||||
|
for _, name := range w.order {
|
||||||
|
f := w.fams[name]
|
||||||
|
fmt.Fprintf(buf, "# HELP %s %s\n# TYPE %s %s\n", name, helpEscaper.Replace(f.help), name, f.typ)
|
||||||
|
for _, l := range f.lines {
|
||||||
|
buf.WriteString(l)
|
||||||
|
buf.WriteByte('\n')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
labelEscaper = strings.NewReplacer(`\`, `\\`, `"`, `\"`, "\n", `\n`)
|
||||||
|
helpEscaper = strings.NewReplacer(`\`, `\\`, "\n", `\n`)
|
||||||
|
)
|
||||||
|
|
||||||
|
func formatFloat(v float64) string {
|
||||||
|
switch {
|
||||||
|
case math.IsInf(v, 1):
|
||||||
|
return "+Inf"
|
||||||
|
case math.IsInf(v, -1):
|
||||||
|
return "-Inf"
|
||||||
|
case math.IsNaN(v):
|
||||||
|
return "NaN"
|
||||||
|
}
|
||||||
|
return strconv.FormatFloat(v, 'f', -1, 64)
|
||||||
|
}
|
||||||
|
|
||||||
|
func unix(t time.Time) float64 { return float64(t.UnixMilli()) / 1000 }
|
||||||
|
|
||||||
|
func b2f(b bool) float64 {
|
||||||
|
if b {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// fractions parses rndc status values such as "0/900/1000".
|
||||||
|
func fractions(s string) []float64 {
|
||||||
|
if s == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var out []float64
|
||||||
|
for _, p := range strings.Split(s, "/") {
|
||||||
|
v, err := strconv.ParseFloat(strings.TrimSpace(p), 64)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out = append(out, v)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func sortedKeys[V any](m map[string]V) []string {
|
||||||
|
keys := make([]string, 0, len(m))
|
||||||
|
for k := range m {
|
||||||
|
keys = append(keys, k)
|
||||||
|
}
|
||||||
|
sort.Strings(keys)
|
||||||
|
return keys
|
||||||
|
}
|
||||||
@@ -0,0 +1,206 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"net/netip"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"jarl/internal/stats"
|
||||||
|
)
|
||||||
|
|
||||||
|
// scrape GETs /metrics with an optional bearer token.
|
||||||
|
func (e *apiEnv) scrape(token string) (string, int) {
|
||||||
|
e.t.Helper()
|
||||||
|
r, _ := http.NewRequest("GET", e.ts.URL+"/metrics", nil)
|
||||||
|
if token != "" {
|
||||||
|
r.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
}
|
||||||
|
resp, err := http.DefaultClient.Do(r)
|
||||||
|
if err != nil {
|
||||||
|
e.t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
b, _ := io.ReadAll(resp.Body)
|
||||||
|
return string(b), resp.StatusCode
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
promSampleRe = regexp.MustCompile(`^[a-zA-Z_:][a-zA-Z0-9_:]*(\{[a-zA-Z_][a-zA-Z0-9_]*="(?:[^"\\]|\\.)*"(,[a-zA-Z_][a-zA-Z0-9_]*="(?:[^"\\]|\\.)*")*\})? -?[0-9.eE+-]+$`)
|
||||||
|
promTypeRe = regexp.MustCompile(`^# TYPE ([a-zA-Z_:][a-zA-Z0-9_:]*) (gauge|counter)$`)
|
||||||
|
)
|
||||||
|
|
||||||
|
// checkExposition checks Prometheus's text format: valid lines, and each
|
||||||
|
// metric's samples together under a single TYPE.
|
||||||
|
func checkExposition(t *testing.T, body string) {
|
||||||
|
t.Helper()
|
||||||
|
typed := map[string]bool{}
|
||||||
|
cur := ""
|
||||||
|
for _, l := range strings.Split(strings.TrimSuffix(body, "\n"), "\n") {
|
||||||
|
switch {
|
||||||
|
case strings.HasPrefix(l, "# HELP "):
|
||||||
|
case strings.HasPrefix(l, "# TYPE "):
|
||||||
|
m := promTypeRe.FindStringSubmatch(l)
|
||||||
|
if m == nil {
|
||||||
|
t.Errorf("bad TYPE line %q", l)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if typed[m[1]] {
|
||||||
|
t.Errorf("metric %s appears twice", m[1])
|
||||||
|
}
|
||||||
|
typed[m[1]], cur = true, m[1]
|
||||||
|
default:
|
||||||
|
if !promSampleRe.MatchString(l) {
|
||||||
|
t.Errorf("bad sample line %q", l)
|
||||||
|
}
|
||||||
|
if name, _, _ := strings.Cut(strings.SplitN(l, " ", 2)[0], "{"); name != cur {
|
||||||
|
t.Errorf("sample %q is not under its TYPE (%s)", l, cur)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMetricsAccess(t *testing.T) {
|
||||||
|
loopback := []netip.Prefix{netip.MustParsePrefix("127.0.0.0/8"), netip.MustParsePrefix("::1/128")}
|
||||||
|
e := newAPIEnv(t)
|
||||||
|
if _, status := e.scrape(""); status != 404 {
|
||||||
|
t.Errorf("disabled = %d, want 404", status)
|
||||||
|
}
|
||||||
|
|
||||||
|
e = newAPIEnvConfig(t, Config{Version: "test", Metrics: Metrics{Enabled: true, Allow: loopback}})
|
||||||
|
body, status := e.scrape("")
|
||||||
|
if status != 200 {
|
||||||
|
t.Fatalf("allowed scrape = %d: %s", status, body)
|
||||||
|
}
|
||||||
|
checkExposition(t, body)
|
||||||
|
for _, want := range []string{`jarl_build_info{version="test"} 1`, `jarl_problems{severity="error"} 0`, `jarl_objects{kind="zones"} 0`, "jarl_open_changesets 0", "jarl_metrics_state_ok 1"} {
|
||||||
|
if !strings.Contains(body, want+"\n") {
|
||||||
|
t.Errorf("missing %q in:\n%s", want, body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.Contains(body, "jarl_queries_total") {
|
||||||
|
t.Error("query counters without statistics")
|
||||||
|
}
|
||||||
|
// No token is configured: one that is sent is refused.
|
||||||
|
if _, status := e.scrape("whatever-token-123"); status != 401 {
|
||||||
|
t.Errorf("token without metrics.token = %d, want 401", status)
|
||||||
|
}
|
||||||
|
|
||||||
|
e = newAPIEnvConfig(t, Config{Version: "test", Metrics: Metrics{Enabled: true, Allow: []netip.Prefix{netip.MustParsePrefix("192.0.2.0/24")}}})
|
||||||
|
if body, status := e.scrape(""); status != 403 || !strings.Contains(body, "metrics.allow") {
|
||||||
|
t.Errorf("not allowed = %d %s", status, body)
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = "0123456789abcdef-metrics"
|
||||||
|
e = newAPIEnvConfig(t, Config{Version: "test", Metrics: Metrics{Enabled: true, Token: token}})
|
||||||
|
if _, status := e.scrape(""); status != 401 {
|
||||||
|
t.Errorf("no token = %d, want 401", status)
|
||||||
|
}
|
||||||
|
if _, status := e.scrape("wrong-token-0123456789"); status != 401 {
|
||||||
|
t.Errorf("wrong token = %d, want 401", status)
|
||||||
|
}
|
||||||
|
if _, status := e.scrape(token); status != 200 {
|
||||||
|
t.Errorf("right token = %d, want 200", status)
|
||||||
|
}
|
||||||
|
// The API's tokens don't count for /metrics.
|
||||||
|
if _, status := e.scrape(e.token); status != 401 {
|
||||||
|
t.Errorf("API token = %d, want 401", status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMetricsState(t *testing.T) {
|
||||||
|
e := newAPIEnvConfig(t, Config{Version: "test", Metrics: Metrics{Enabled: true, Allow: []netip.Prefix{netip.MustParsePrefix("127.0.0.0/8")}}})
|
||||||
|
e.do("POST", "/zones", e.token, map[string]any{"name": "example.com", "records": []map[string]any{{"name": "ns1", "type": "A", "data": "192.0.2.1"}}}, 201)
|
||||||
|
e.do("POST", "/blocking/lists", e.token, map[string]any{"name": "ads", "url": "https://lists.example/ads.txt"}, 201)
|
||||||
|
e.srv.metricsCache.body = nil // skip the cache
|
||||||
|
body, _ := e.scrape("")
|
||||||
|
checkExposition(t, body)
|
||||||
|
for _, want := range []string{`jarl_objects{kind="zones"} 1`, `jarl_blocklist_enabled{list="ads"} 1`, "jarl_blocking_paused 0"} {
|
||||||
|
if !strings.Contains(body, want+"\n") {
|
||||||
|
t.Errorf("missing %q in:\n%s", want, body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Cached: a change shows after metricsStateTTL.
|
||||||
|
e.do("POST", "/zones", e.token, map[string]any{"name": "example.org", "records": []map[string]any{{"name": "ns1", "type": "A", "data": "192.0.2.1"}}}, 201)
|
||||||
|
if body, _ := e.scrape(""); !strings.Contains(body, `jarl_objects{kind="zones"} 1`+"\n") {
|
||||||
|
t.Error("state was not cached")
|
||||||
|
}
|
||||||
|
e.srv.metricsCache.at = time.Now().Add(-metricsStateTTL)
|
||||||
|
if body, _ := e.scrape(""); !strings.Contains(body, `jarl_objects{kind="zones"} 2`+"\n") {
|
||||||
|
t.Errorf("state not refreshed:\n%s", body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMetricsStats(t *testing.T) {
|
||||||
|
ss, err := stats.OpenStore(":memory:")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer ss.Close()
|
||||||
|
opts := stats.Options{QueryLog: true, LogFile: "/nonexistent", StatisticsChannel: "127.0.0.1:1", TopN: 10, RetainMinute: time.Hour, RetainHour: 24 * time.Hour, RetainDay: 24 * time.Hour}
|
||||||
|
c := stats.NewCollector(ss, opts, func() map[string][]string { return map[string][]string{"internal": {"example.com"}} }, slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||||
|
ts := time.Now().Format("02-Jan-2006 15:04:05.000")
|
||||||
|
c.HandleLine(ts + " client @0x7f0000000000 10.0.0.9#5353 (www.example.com): view internal: response: www.example.com IN A NOERROR 1 0 1 +E(0)K (10.10.0.2)")
|
||||||
|
c.HandleLine(ts + " client @0x7f0000000000 10.0.0.9#5354 (ads.example): view internal: rpz QNAME NXDOMAIN rewrite ads.example/A/IN via ads.example.hagezi.blocklist.jarl.invalid")
|
||||||
|
c.HandleLine(ts + " client @0x7f0000000000 10.0.0.9#5354 (ads.example): view internal: response: ads.example IN A NXDOMAIN 1 0 1 +E(0)K (10.10.0.2)")
|
||||||
|
|
||||||
|
e := newAPIEnvConfig(t, Config{Version: "test", Stats: c, Metrics: Metrics{Enabled: true, Allow: []netip.Prefix{netip.MustParsePrefix("127.0.0.0/8")}}})
|
||||||
|
body, status := e.scrape("")
|
||||||
|
if status != 200 {
|
||||||
|
t.Fatalf("scrape = %d", status)
|
||||||
|
}
|
||||||
|
checkExposition(t, body)
|
||||||
|
for _, want := range []string{
|
||||||
|
"jarl_queries_total 0", "jarl_logged_responses_total 2", `jarl_responses_by_view_total{view="internal"} 2`,
|
||||||
|
`jarl_responses_by_zone_total{zone="example.com"} 1`, `jarl_responses_by_zone_total{zone="-"} 1`,
|
||||||
|
"jarl_blocked_queries_total 1", `jarl_blocked_by_list_total{list="hagezi"} 1`, "jarl_stats_log_lines_total 2",
|
||||||
|
} {
|
||||||
|
if !strings.Contains(body, want+"\n") {
|
||||||
|
t.Errorf("missing %q in:\n%s", want, body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStatsMetricsMapping(t *testing.T) {
|
||||||
|
pw := &promWriter{}
|
||||||
|
totals := map[string]int64{"queries": 10, "rcode:NOERROR": 8, "rcode:NXDOMAIN": 2, "qtype:A": 10, "ratelimit:slipped": 3,
|
||||||
|
"cache:hits": 4, "rtt:<10ms": 5, "unknown:x": 1, "rcode": 7}
|
||||||
|
srv := &stats.Server{BootTime: "2026-10-07T20:00:00.000Z", Version: "9.20.4", PID: 42, RSS: 1 << 20, CPUSeconds: 1.5, SocketsUDP: 4}
|
||||||
|
statsMetrics(pw, totals, srv, stats.Status{})
|
||||||
|
var buf bytes.Buffer
|
||||||
|
pw.writeTo(&buf)
|
||||||
|
out := buf.String()
|
||||||
|
checkExposition(t, out)
|
||||||
|
for _, want := range []string{
|
||||||
|
"jarl_queries_total 10", `jarl_responses_by_rcode_total{rcode="NXDOMAIN"} 2`, `jarl_ratelimit_responses_total{action="slipped"} 3`,
|
||||||
|
`jarl_upstream_responses_by_rtt_total{rtt="<10ms"} 5`, `jarl_cache_lookups_total{result="hits"} 4`,
|
||||||
|
"jarl_named_start_time_seconds 1791403200", "jarl_named_cpu_seconds_total 1.5", `jarl_named_sockets{proto="udp"} 4`,
|
||||||
|
"jarl_named_resident_memory_bytes 1048576",
|
||||||
|
} {
|
||||||
|
if !strings.Contains(out, want+"\n") {
|
||||||
|
t.Errorf("missing %q in:\n%s", want, out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.Contains(out, "unknown") || strings.Contains(out, "jarl_responses_by_rcode_total 7") {
|
||||||
|
t.Errorf("unknown keys exported:\n%s", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPromWriterEscaping(t *testing.T) {
|
||||||
|
pw := &promWriter{}
|
||||||
|
pw.gauge("m", "a \\ help\nline", 1, "l", "a\"b\\c\nd")
|
||||||
|
var buf bytes.Buffer
|
||||||
|
pw.writeTo(&buf)
|
||||||
|
want := "# HELP m a \\\\ help\\nline\n# TYPE m gauge\nm{l=\"a\\\"b\\\\c\\nd\"} 1\n"
|
||||||
|
if buf.String() != want {
|
||||||
|
t.Errorf("got %q, want %q", buf.String(), want)
|
||||||
|
}
|
||||||
|
if got := formatFloat(1759867200.123); got != "1759867200.123" {
|
||||||
|
t.Errorf("formatFloat = %s", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCatalogRanges(t *testing.T) {
|
||||||
|
e := newAPIEnv(t)
|
||||||
|
b, status := e.req("GET", "/catalog/ranges?r=10.0.10.101-10.0.10.199,!2001:db8::100-2001:db8::1ff,branch-office,10.0.0.9-10.0.0.1", e.token, nil)
|
||||||
|
if status != 200 {
|
||||||
|
t.Fatalf("status %d: %s", status, b)
|
||||||
|
}
|
||||||
|
var out []struct {
|
||||||
|
Input, Range, Size, Error string
|
||||||
|
Prefixes []string
|
||||||
|
}
|
||||||
|
json.Unmarshal(b, &out)
|
||||||
|
if len(out) != 3 {
|
||||||
|
t.Fatalf("got %s", b)
|
||||||
|
}
|
||||||
|
if out[0].Range != "10.0.10.101-10.0.10.199" || len(out[0].Prefixes) != 6 || out[0].Size != "99 addresses" {
|
||||||
|
t.Errorf("IPv4 = %+v", out[0])
|
||||||
|
}
|
||||||
|
if out[1].Input != "!2001:db8::100-2001:db8::1ff" || len(out[1].Prefixes) != 1 || out[1].Prefixes[0] != "2001:db8::100/120" {
|
||||||
|
t.Errorf("IPv6 = %+v", out[1])
|
||||||
|
}
|
||||||
|
if out[2].Error == "" {
|
||||||
|
t.Errorf("reversed range = %+v", out[2])
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -22,6 +22,7 @@ func (s *Server) routes() {
|
|||||||
m.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) {
|
m.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) {
|
||||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "version": s.cfg.Version})
|
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "version": s.cfg.Version})
|
||||||
})
|
})
|
||||||
|
m.HandleFunc("GET /metrics", s.metrics)
|
||||||
// Authentication
|
// Authentication
|
||||||
m.HandleFunc("POST "+p+"/auth/login", s.public(s.login))
|
m.HandleFunc("POST "+p+"/auth/login", s.public(s.login))
|
||||||
m.HandleFunc("POST "+p+"/auth/login/totp", s.public(s.loginTOTP))
|
m.HandleFunc("POST "+p+"/auth/login/totp", s.public(s.loginTOTP))
|
||||||
@@ -52,10 +53,36 @@ 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))
|
||||||
// ACLs
|
// ACLs
|
||||||
|
// Blocking
|
||||||
|
m.HandleFunc("GET "+p+"/blocking/lists", s.authed(s.listBlocklists))
|
||||||
|
m.HandleFunc("POST "+p+"/blocking/lists", s.authed(s.createBlocklist))
|
||||||
|
m.HandleFunc("GET "+p+"/blocking/lists/{id}", s.authed(s.getBlocklist))
|
||||||
|
m.HandleFunc("PATCH "+p+"/blocking/lists/{id}", s.authed(s.updateBlocklist))
|
||||||
|
m.HandleFunc("DELETE "+p+"/blocking/lists/{id}", s.authed(s.deleteBlocklist))
|
||||||
|
m.HandleFunc("POST "+p+"/blocking/lists/{id}/refresh", s.authed(s.refreshBlocklist))
|
||||||
|
m.HandleFunc("GET "+p+"/blocking/rules", s.authed(s.listBlockRules))
|
||||||
|
m.HandleFunc("POST "+p+"/blocking/rules", s.authed(s.createBlockRule))
|
||||||
|
m.HandleFunc("DELETE "+p+"/blocking/rules", s.authed(s.deleteBlockRulesByName))
|
||||||
|
m.HandleFunc("PATCH "+p+"/blocking/rules/{id}", s.authed(s.updateBlockRule))
|
||||||
|
m.HandleFunc("DELETE "+p+"/blocking/rules/{id}", s.authed(s.deleteBlockRule))
|
||||||
|
m.HandleFunc("GET "+p+"/blocking/steps", s.authed(s.listBlockSteps))
|
||||||
|
m.HandleFunc("POST "+p+"/blocking/steps", s.authed(s.createBlockStep))
|
||||||
|
m.HandleFunc("POST "+p+"/blocking/steps/order", s.authed(s.orderBlockSteps))
|
||||||
|
m.HandleFunc("PATCH "+p+"/blocking/steps/{id}", s.authed(s.updateBlockStep))
|
||||||
|
m.HandleFunc("DELETE "+p+"/blocking/steps/{id}", s.authed(s.deleteBlockStep))
|
||||||
|
m.HandleFunc("GET "+p+"/blocking/groups", s.authed(s.listClientGroups))
|
||||||
|
m.HandleFunc("POST "+p+"/blocking/groups", s.authed(s.createClientGroup))
|
||||||
|
m.HandleFunc("PATCH "+p+"/blocking/groups/{id}", s.authed(s.updateClientGroup))
|
||||||
|
m.HandleFunc("DELETE "+p+"/blocking/groups/{id}", s.authed(s.deleteClientGroup))
|
||||||
|
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))
|
||||||
@@ -109,6 +136,7 @@ func (s *Server) routes() {
|
|||||||
m.HandleFunc("GET "+p+"/catalog/options", s.authed(s.catalogOptions))
|
m.HandleFunc("GET "+p+"/catalog/options", s.authed(s.catalogOptions))
|
||||||
m.HandleFunc("GET "+p+"/catalog/rrtypes", s.authed(s.catalogRRTypes))
|
m.HandleFunc("GET "+p+"/catalog/rrtypes", s.authed(s.catalogRRTypes))
|
||||||
m.HandleFunc("GET "+p+"/catalog/grants", s.authed(s.catalogGrants))
|
m.HandleFunc("GET "+p+"/catalog/grants", s.authed(s.catalogGrants))
|
||||||
|
m.HandleFunc("GET "+p+"/catalog/ranges", s.authed(s.catalogRanges))
|
||||||
m.HandleFunc("GET "+p+"/search", s.authed(s.search))
|
m.HandleFunc("GET "+p+"/search", s.authed(s.search))
|
||||||
// Changesets
|
// Changesets
|
||||||
m.HandleFunc("POST "+p+"/apply", s.authed(s.applyBatch))
|
m.HandleFunc("POST "+p+"/apply", s.authed(s.applyBatch))
|
||||||
@@ -140,6 +168,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)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -58,6 +58,8 @@ type Config struct {
|
|||||||
// name authenticator apps show (h_totp.go).
|
// name authenticator apps show (h_totp.go).
|
||||||
TOTPRequire string
|
TOTPRequire string
|
||||||
TOTPIssuer string
|
TOTPIssuer string
|
||||||
|
// Metrics configures /metrics (metrics.go).
|
||||||
|
Metrics Metrics
|
||||||
}
|
}
|
||||||
|
|
||||||
// Server is the HTTP server.
|
// Server is the HTTP server.
|
||||||
@@ -80,6 +82,10 @@ type Server struct {
|
|||||||
// nsChecker, when set, replaces the default name server checker
|
// nsChecker, when set, replaces the default name server checker
|
||||||
// (tests point it at local DNS servers).
|
// (tests point it at local DNS servers).
|
||||||
nsChecker *nscheck.Checker
|
nsChecker *nscheck.Checker
|
||||||
|
// started is when the server was created; metricsCache holds the Jarl
|
||||||
|
// state part of /metrics.
|
||||||
|
started time.Time
|
||||||
|
metricsCache metricsCache
|
||||||
|
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
srvs []*http.Server
|
srvs []*http.Server
|
||||||
@@ -108,7 +114,7 @@ func New(cfg Config, eng *engine.Engine, log *slog.Logger, web fs.FS) *Server {
|
|||||||
if log == nil {
|
if log == nil {
|
||||||
log = slog.Default()
|
log = slog.Default()
|
||||||
}
|
}
|
||||||
s := &Server{cfg: cfg, eng: eng, store: eng.Store(), log: log, mux: http.NewServeMux(), limiter: newRateLimiter(), web: web}
|
s := &Server{cfg: cfg, eng: eng, store: eng.Store(), log: log, mux: http.NewServeMux(), limiter: newRateLimiter(), web: web, started: time.Now()}
|
||||||
s.expiryWarn.every = 24 * time.Hour
|
s.expiryWarn.every = 24 * time.Hour
|
||||||
if cfg.Stats != nil {
|
if cfg.Stats != nil {
|
||||||
s.statsQ = stats.NewQuerier(cfg.Stats)
|
s.statsQ = stats.NewQuerier(cfg.Stats)
|
||||||
@@ -200,6 +206,10 @@ func (s *Server) Serve(ctx context.Context, ready func()) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if err := s.serveMetricsListeners(errc); err != nil {
|
||||||
|
s.shutdown()
|
||||||
|
return err
|
||||||
|
}
|
||||||
s.serveSocket(errc)
|
s.serveSocket(errc)
|
||||||
s.logHTTPReq(tlsCfg != nil)
|
s.logHTTPReq(tlsCfg != nil)
|
||||||
if ready != nil {
|
if ready != nil {
|
||||||
|
|||||||
@@ -0,0 +1,114 @@
|
|||||||
|
package stats
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"net/netip"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestParseRewrite(t *testing.T) {
|
||||||
|
loc := time.UTC
|
||||||
|
// Real lines from named 9.20.24.
|
||||||
|
rw, ok := ParseRewrite(`09-Oct-2026 23:49:13.089 client @0x7bd607320000 127.0.0.1#56905 (x.ads.example): view internal: rpz QNAME NXDOMAIN rewrite x.ads.example/A/IN via x.ads.example.rules.jarl.invalid`, loc)
|
||||||
|
if !ok || rw.Client.String() != "127.0.0.1" || rw.Port != "56905" || rw.View != "internal" || rw.Name != "x.ads.example" || rw.Action != "NXDOMAIN" || policySource(rw.Via) != "rules" {
|
||||||
|
t.Errorf("rules rewrite: %+v (%s)", rw, policySource(rw.Via))
|
||||||
|
}
|
||||||
|
rw, ok = ParseRewrite(`09-Oct-2026 23:49:13.378 client @0x7bd607320000 10.0.0.9#57347 (Tracker.Example): rpz QNAME NXDOMAIN rewrite tracker.example/AAAA/IN via tracker.example.hagezi-pro.blocklist.jarl.invalid`, loc)
|
||||||
|
if !ok || rw.View != "" || rw.Name != "tracker.example" || policySource(rw.Via) != "hagezi-pro" {
|
||||||
|
t.Errorf("list rewrite: %+v (%s)", rw, policySource(rw.Via))
|
||||||
|
}
|
||||||
|
if policySource("ads.example.my-own-rpz.") != "ads.example.my-own-rpz" {
|
||||||
|
t.Error("a policy zone that isn't Jarl's")
|
||||||
|
}
|
||||||
|
if _, ok := ParseRewrite(`09-Oct-2026 23:49:13.378 client @0x7bd607320000 10.0.0.9#57347 (x): view internal: response: x IN A NOERROR 1 0 1 + (10.0.0.2)`, loc); ok {
|
||||||
|
t.Error("a response line parsed as a rewrite")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCollectorBlocking(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
loc := time.UTC
|
||||||
|
dir := t.TempDir()
|
||||||
|
logFile := filepath.Join(dir, "responses.log")
|
||||||
|
store, err := OpenStore(filepath.Join(dir, "stats.db"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer store.Close()
|
||||||
|
named := newFakeNamed(t)
|
||||||
|
clk := &clock{time.Date(2026, 10, 5, 10, 0, 30, 0, loc)}
|
||||||
|
opts := Options{QueryLog: true, LogFile: logFile, StatisticsChannel: "unused", TopN: 5,
|
||||||
|
RetainMinute: 7 * 24 * time.Hour, RetainHour: 90 * 24 * time.Hour, RetainDay: 730 * 24 * time.Hour,
|
||||||
|
Exclude: []netip.Prefix{netip.MustParsePrefix("10.0.0.250/32")}, Location: loc}
|
||||||
|
c := NewCollector(store, opts, func() map[string][]string { return nil }, slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||||
|
c.poller.URL = named.srv.URL
|
||||||
|
c.now = clk.now
|
||||||
|
c.start(ctx)
|
||||||
|
|
||||||
|
at := time.Date(2026, 10, 5, 10, 0, 40, 0, loc).Format(logTimeLayout)
|
||||||
|
rewrite := func(client, port, name, action, via string) string {
|
||||||
|
return fmt.Sprintf("%s client @0x7f0000000000 %s#%s (%s): view internal: rpz QNAME %s rewrite %s/A/IN via %s\n", at, client, port, name, action, name, via)
|
||||||
|
}
|
||||||
|
response := func(client, port, name, rcode string) string {
|
||||||
|
return fmt.Sprintf("%s client @0x7f0000000000 %s#%s (%s): view internal: response: %s IN A %s 0 0 2 +E(0)K (10.0.0.2)\n", at, client, port, name, name, rcode)
|
||||||
|
}
|
||||||
|
f, _ := os.Create(logFile)
|
||||||
|
for _, l := range []string{
|
||||||
|
rewrite("10.0.0.5", "1001", "ads.example", "NXDOMAIN", "ads.example.hagezi.blocklist.jarl.invalid"),
|
||||||
|
response("10.0.0.5", "1001", "ads.example", "NXDOMAIN"),
|
||||||
|
rewrite("10.0.0.5", "1002", "x.ads.example", "NXDOMAIN", "x.ads.example.hagezi.blocklist.jarl.invalid"),
|
||||||
|
response("10.0.0.5", "1002", "x.ads.example", "NXDOMAIN"),
|
||||||
|
rewrite("10.0.0.6", "1003", "ads.example", "NXDOMAIN", "ads.example.rules.jarl.invalid"),
|
||||||
|
response("10.0.0.6", "1003", "ads.example", "NXDOMAIN"),
|
||||||
|
// An allow rule: answered normally, not blocked.
|
||||||
|
rewrite("10.0.0.6", "1004", "cdn.ads.example", "PASSTHRU", "cdn.ads.example.rules.jarl.invalid"),
|
||||||
|
response("10.0.0.6", "1004", "cdn.ads.example", "NOERROR"),
|
||||||
|
// A real NXDOMAIN.
|
||||||
|
response("10.0.0.6", "1005", "typo.example", "NXDOMAIN"),
|
||||||
|
// An excluded client.
|
||||||
|
rewrite("10.0.0.250", "1006", "ads.example", "NXDOMAIN", "ads.example.hagezi.blocklist.jarl.invalid"),
|
||||||
|
response("10.0.0.250", "1006", "ads.example", "NXDOMAIN"),
|
||||||
|
} {
|
||||||
|
f.WriteString(l)
|
||||||
|
}
|
||||||
|
f.Close()
|
||||||
|
c.readLog()
|
||||||
|
named.add(6, 4)
|
||||||
|
clk.t = time.Date(2026, 10, 5, 10, 1, 2, 0, loc)
|
||||||
|
c.flush(ctx, false)
|
||||||
|
|
||||||
|
m := time.Date(2026, 10, 5, 10, 0, 0, 0, loc).Unix()
|
||||||
|
got, _ := store.sums(ctx, ResMinute, m, m+60, "")
|
||||||
|
for k, v := range map[string]int64{"blocked": 3, "blocklist:hagezi": 2, "blocklist:rules": 1, "responses": 5} {
|
||||||
|
if got[k] != v {
|
||||||
|
t.Errorf("%s = %d, want %d (all: %v)", k, got[k], v, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if b, _ := store.TopsAt(ctx, ResHour, m, KindBlocked); len(b) != 2 || b[0] != (Entry{Key: "ads.example", Count: 2}) {
|
||||||
|
t.Errorf("blocked names = %v", b)
|
||||||
|
}
|
||||||
|
if bc, _ := store.TopsAt(ctx, ResHour, m, KindBlockedClient); len(bc) != 2 || bc[0] != (Entry{Key: "10.0.0.5", Count: 2}) {
|
||||||
|
t.Errorf("blocked clients = %v", bc)
|
||||||
|
}
|
||||||
|
if nx, _ := store.TopsAt(ctx, ResHour, m, KindNXDomain); len(nx) != 1 || nx[0].Key != "typo.example" {
|
||||||
|
t.Errorf("NXDOMAIN names should leave out blocked ones: %v", nx)
|
||||||
|
}
|
||||||
|
q := &Querier{Store: store, Opts: opts, Now: clk.now}
|
||||||
|
sum, err := q.Summary(ctx, time.Hour, 5)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// 6 queries, 4 NXDOMAIN of which 3 blocked.
|
||||||
|
if sum.Blocked != 3 || sum.BlockedRatio == nil || *sum.BlockedRatio != 0.5 || sum.NXDomainRatio == nil || *sum.NXDomainRatio != 1.0/6 {
|
||||||
|
t.Errorf("summary: blocked %d ratio %v nxdomain %v", sum.Blocked, sum.BlockedRatio, sum.NXDomainRatio)
|
||||||
|
}
|
||||||
|
if len(sum.Blocklists) != 2 || sum.Blocklists[0] != (Entry{Key: "hagezi", Count: 2}) || len(sum.Tops[KindBlocked]) != 2 {
|
||||||
|
t.Errorf("summary lists %v, tops %v", sum.Blocklists, sum.Tops[KindBlocked])
|
||||||
|
}
|
||||||
|
}
|
||||||
+102
-7
@@ -12,6 +12,8 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"jarl/internal/blocking"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Kinds of top lists.
|
// Kinds of top lists.
|
||||||
@@ -21,10 +23,13 @@ const (
|
|||||||
KindNXDomain = "nxdomain" // names answered with NXDOMAIN
|
KindNXDomain = "nxdomain" // names answered with NXDOMAIN
|
||||||
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)
|
||||||
|
// 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}
|
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).
|
||||||
@@ -42,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.
|
||||||
@@ -99,6 +107,16 @@ type Collector struct {
|
|||||||
recentL []int64 // response log lines per minute, last 10 minutes
|
recentL []int64 // response log lines per minute, last 10 minutes
|
||||||
linesMin int64
|
linesMin int64
|
||||||
ensureAt time.Time
|
ensureAt time.Time
|
||||||
|
// rewritten holds queries named blocked (client#port|name) until their
|
||||||
|
// response line arrives.
|
||||||
|
rewritten map[string]bool
|
||||||
|
// proc is the last sample of named's process, server the latest server
|
||||||
|
// statistics (server.go).
|
||||||
|
proc *namedProc
|
||||||
|
server *Server
|
||||||
|
// totals are the counters added up since jarld started (gauges left
|
||||||
|
// out), for Prometheus (Totals).
|
||||||
|
totals map[string]int64
|
||||||
}
|
}
|
||||||
|
|
||||||
// maxKeys bounds the per-hour maps; rarely seen keys are dropped first.
|
// maxKeys bounds the per-hour maps; rarely seen keys are dropped first.
|
||||||
@@ -110,7 +128,7 @@ func NewCollector(store *Store, opts Options, zones ZoneIndex, log *slog.Logger)
|
|||||||
opts.Location = time.Local
|
opts.Location = time.Local
|
||||||
}
|
}
|
||||||
c := &Collector{opts: opts, store: store, zones: zones, log: log, poller: NewPoller(opts.StatisticsChannel), now: time.Now,
|
c := &Collector{opts: opts, store: store, zones: zones, log: log, poller: NewPoller(opts.StatisticsChannel), now: time.Now,
|
||||||
minutes: map[int64]map[string]int64{}, hours: map[int64]map[string]map[string]int64{}, late: map[int64]map[string]map[string]int64{}}
|
minutes: map[int64]map[string]int64{}, totals: map[string]int64{}, hours: map[int64]map[string]map[string]int64{}, late: map[int64]map[string]map[string]int64{}}
|
||||||
if opts.QueryLog {
|
if opts.QueryLog {
|
||||||
c.tail = &Tailer{Path: opts.LogFile}
|
c.tail = &Tailer{Path: opts.LogFile}
|
||||||
}
|
}
|
||||||
@@ -227,13 +245,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)
|
||||||
}
|
}
|
||||||
@@ -267,6 +289,23 @@ func (c *Collector) count(minute int64, key string, n int64) {
|
|||||||
c.minutes[minute] = m
|
c.minutes[minute] = m
|
||||||
}
|
}
|
||||||
m[key] += n
|
m[key] += n
|
||||||
|
if !strings.HasPrefix(key, "g:") && !strings.HasPrefix(key, "gn:") {
|
||||||
|
c.totals[key] += n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Totals returns every counter added up since the collector started (the
|
||||||
|
// keys of docs/stats.md, without gauges). They only grow, so they suit
|
||||||
|
// Prometheus counters; named's counters move once a minute, when they are
|
||||||
|
// polled.
|
||||||
|
func (c *Collector) Totals() map[string]int64 {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
out := make(map[string]int64, len(c.totals))
|
||||||
|
for k, v := range c.totals {
|
||||||
|
out[k] = v
|
||||||
|
}
|
||||||
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
// readLog reads new response log lines.
|
// readLog reads new response log lines.
|
||||||
@@ -296,8 +335,15 @@ func (c *Collector) HandleLine(line string) {
|
|||||||
func (c *Collector) handleLine(line string) {
|
func (c *Collector) handleLine(line string) {
|
||||||
ev, ok := ParseResponse(line, c.opts.Location)
|
ev, ok := ParseResponse(line, c.opts.Location)
|
||||||
if !ok {
|
if !ok {
|
||||||
|
if rw, ok := ParseRewrite(line, c.opts.Location); ok {
|
||||||
|
c.handleRewrite(rw)
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
blocked := c.rewritten[ev.Client.String()+"#"+ev.Port+"|"+ev.Name]
|
||||||
|
if blocked {
|
||||||
|
delete(c.rewritten, ev.Client.String()+"#"+ev.Port+"|"+ev.Name)
|
||||||
|
}
|
||||||
c.status.LinesRead++
|
c.status.LinesRead++
|
||||||
c.linesMin++
|
c.linesMin++
|
||||||
t := ev.Time
|
t := ev.Time
|
||||||
@@ -324,12 +370,14 @@ func (c *Collector) handleLine(line string) {
|
|||||||
tops := hours[hour]
|
tops := hours[hour]
|
||||||
tops[KindName][ev.Name]++
|
tops[KindName][ev.Name]++
|
||||||
tops[KindClient][client]++
|
tops[KindClient][client]++
|
||||||
switch ev.RCode {
|
switch {
|
||||||
case "NXDOMAIN":
|
case blocked:
|
||||||
|
// Counted by handleRewrite; not a name that doesn't exist.
|
||||||
|
case ev.RCode == "NXDOMAIN":
|
||||||
tops[KindNXDomain][ev.Name]++
|
tops[KindNXDomain][ev.Name]++
|
||||||
case "SERVFAIL":
|
case ev.RCode == "SERVFAIL":
|
||||||
tops[KindServFail][ev.Name]++
|
tops[KindServFail][ev.Name]++
|
||||||
case "REFUSED":
|
case ev.RCode == "REFUSED":
|
||||||
tops[KindRefused][client]++
|
tops[KindRefused][client]++
|
||||||
}
|
}
|
||||||
for _, m := range tops {
|
for _, m := range tops {
|
||||||
@@ -339,6 +387,49 @@ func (c *Collector) handleLine(line string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// handleRewrite counts a query that blocking answered: "blocked", and
|
||||||
|
// "blocklist:NAME" for the list (or "rules") that matched.
|
||||||
|
func (c *Collector) handleRewrite(rw Rewrite) {
|
||||||
|
if rw.Action == "PASSTHRU" {
|
||||||
|
return // an allow rule: answered normally
|
||||||
|
}
|
||||||
|
for _, p := range c.opts.Exclude {
|
||||||
|
if p.Contains(rw.Client) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if c.rewritten == nil || len(c.rewritten) > 10_000 {
|
||||||
|
// Rewrites whose response line never came (named only logs
|
||||||
|
// responses while response logging is on) don't pile up.
|
||||||
|
c.rewritten = map[string]bool{}
|
||||||
|
}
|
||||||
|
c.rewritten[rw.Client.String()+"#"+rw.Port+"|"+rw.Name] = true
|
||||||
|
minute, hour := c.minuteOf(rw.Time), c.hourOf(rw.Time)
|
||||||
|
c.count(minute, "blocked", 1)
|
||||||
|
c.count(minute, "blocklist:"+policySource(rw.Via), 1)
|
||||||
|
hours := c.hours
|
||||||
|
if hour < c.liveHour {
|
||||||
|
hours = c.late
|
||||||
|
}
|
||||||
|
hours[hour] = c.hourMaps(hours, hour)
|
||||||
|
hours[hour][KindBlocked][rw.Name]++
|
||||||
|
hours[hour][KindBlockedClient][clientKey(rw.Client, c.opts.Anonymize)]++
|
||||||
|
}
|
||||||
|
|
||||||
|
// policySource names what a rewrite came from: a blocklist's name, "rules"
|
||||||
|
// for the rules entered in Jarl, or the policy zone for others.
|
||||||
|
func policySource(via string) string {
|
||||||
|
via = strings.TrimSuffix(via, ".")
|
||||||
|
if strings.HasSuffix(via, "."+blocking.RulesZone) {
|
||||||
|
return "rules"
|
||||||
|
}
|
||||||
|
if i := strings.LastIndex(via, ".blocklist.jarl.invalid"); i > 0 && i+len(".blocklist.jarl.invalid") == len(via) {
|
||||||
|
rest := via[:i]
|
||||||
|
return rest[strings.LastIndex(rest, ".")+1:]
|
||||||
|
}
|
||||||
|
return via
|
||||||
|
}
|
||||||
|
|
||||||
// shrink drops the rarest keys until the map is well under maxKeys.
|
// shrink drops the rarest keys until the map is well under maxKeys.
|
||||||
func shrink(m map[string]int64) {
|
func shrink(m map[string]int64) {
|
||||||
for limit := int64(1); len(m) > maxKeys*8/10; limit++ {
|
for limit := int64(1); len(m) > maxKeys*8/10; limit++ {
|
||||||
@@ -410,6 +501,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 {
|
||||||
|
|||||||
Loaded 100 of 132 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user