Compare commits

...
13 Commits
Author SHA1 Message Date
adamandClaude Opus 5.5 88965096bb Web UI: option values keep the editor's layout
A shown option value wrapped long lines back to the left edge, losing
their indentation, and had different padding and width from the editor,
so the text and column moved when Edit opened. Values now don't wrap (a
long line scrolls), with the editor's padding and the same width. The
"{ } around the list are optional" hint only shows for options that end
in a list, not rate-limit, which only has one inside.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 13:24:03 +01:00
adamandClaude Opus 5.5 bad360107e docs: every rate-limit setting, with defaults and when to change them
A Settings section in docs/exposure.md: the counting model, a table of all
15 rate-limit settings from the BIND 9.20 ARM, and what was checked
against real named (TCP never limited, NXDOMAIN falls back to
responses-per-second and is counted per zone across names, all-per-second
only drops, slipped answers are TC or BADCOOKIE depending on DNS cookies).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 13:18:17 +01:00
adamandClaude Opus 5.5 bdd7acfd3b Internet exposure checks and response rate limiting
Jarl works out which views are reachable from the internet (match-clients
ending in a catch-all, not private-looking addresses: an internal view of a
global IPv6 prefix is still yours; a server without views: open allow-query
and a non-private listen address), with a per-view override (schema 9), and
checks public ones: rate limiting, open resolver, minimal-any, cache
snooping, open NOTIFY and transfers, version and host name, management
interfaces. Fixes are staged like any change, including rate limiting from
ISC's example in log-only mode with the secondaries exempt, then enforcing.
named logs what rate limiting limits (or would, in log-only mode, where its
counters don't move) as query-errors; Jarl routes those lines to its log
and counts them (rrl breakdown, ratelimited top lists, Prometheus
jarl_ratelimit_logged_total), shown on the new Exposure page.

A change that newly opens a public view up must be confirmed (accept_risk,
--accept-risk, a checkbox on the Changes page; 409 confirm_risk
otherwise). Findings can be acknowledged with a reason and stay visible as
info. GET /exposure, POST /exposure/fix|ack|unack, jarl exposure,
docs/exposure.md.

Also fixes query statistics skipping TSIG-signed queries ("IP#port/key
NAME" in named's log).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 10:38:56 +01:00
adamandClaude Opus 5.5 cf550bd512 Release 0.4.1-rc.1
release / release (push) Successful in 1m58s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 23:53:31 +01:00
adamandClaude Opus 5.5 cee11e7c86 CLI: changeset commands default to JARL_CHANGESET, which is checked first
jarl changeset show/diff/apply/discard take the changeset from -C or
JARL_CHANGESET when no ID is given, and remove takes [ID] SEQ. A changeset
from JARL_CHANGESET is checked before anything uses it (staging included):
it must exist, be open and be the caller's own, so a stale export can't
stage into an applied changeset or into another user's draft that later
got its ID. Commands say on stderr which changeset they use, and apply and
discard remind to unset JARL_CHANGESET. -C and ID arguments are used as
given.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 23:53:26 +01:00
adamandClaude Opus 5.5 e2e5007f94 Address ranges in address match lists (IPv4 and IPv6)
ACLs, views' match lists, options with address match lists and blocking
client groups take ranges such as 10.0.10.101-10.0.10.199 or
2001:db8::100-2001:db8::1ff (negated, or with spaces around the dash, too).
Jarl stores the range as written and writes the fewest prefixes that cover
exactly that range to named.conf, with the range as a comment. New package
internal/iprange; namedconf gets an AMRange element (a range used to parse
as an ACL name), ExpandRanges and statement comments. Mistakes are refused
when entered, validation sees what ranges cover, and IPv6 ranges in the
IPv4-mapped space get a warning. The web UI shows what a range becomes
while typing (GET /catalog/ranges).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 23:17:14 +01:00
adamandClaude Opus 5.5 49b1c872c9 Changesets: staged deletes of gone records count as done, name stale ops
A web changeset with a staged record delete could not be applied after
the record was deleted elsewhere (e.g. with the CLI), and the error only
said "record N does not exist", so it was hard to find the operation to
remove. A delete of a record that is already gone is now a no-op with a
warning. Other operations that no longer apply fail with their number and
summary (op_summary in the API), and the Changes page marks that row and
offers to remove it from the error.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 22:59:19 +01:00
adamandClaude Opus 5.5 c37de31f4c Prometheus metrics at /metrics, configured in jarl.yaml
New metrics: section (enabled, listen, allow, token). /metrics is served on
the main listeners, the local socket (root and jarld's user) and optional
metrics-only addresses; a scrape needs an allowed address or the bearer
token. Exports health (named up, problems, drift, backups, blocklists,
DNSSEC phases, token expiry) and, with stats on, named's and Jarl's query
counters since jarld started plus named's process and cache gauges.
docs/metrics.md has example queries and alerts checked with promtool.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 22:33:41 +01:00
adamandClaude Opus 5.5 7e6e274a89 Release 0.4.0
release / release (push) Successful in 1m59s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 21:58:15 +01:00
adamandClaude Opus 5.5 11ed877c3c Release 0.4.0-rc.5
release / release (push) Successful in 1m56s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 21:33:22 +01:00
adamandClaude Opus 5.5 76aaaf3ef5 Different blocking for different devices: steps and client groups
Client groups put devices (addresses, networks, or ACLs of them) at a
blocking step: off (only your rules), normal (the default), or steps
added above it, each including the lists of the steps below. Lists
start at a step. In each view, a gate policy zone in front of a step's
lists lets the devices below that step through (rpz-client-ip triggers
with rpz-passthru), so every list is still loaded once. The IPv6 part
of a gate leaves the IPv4-mapped range out: named matches IPv4 clients
as ::ffff:a.b.c.d.

Web UI: a Devices panel on the Blocking page and a step on each list.
CLI: jarl block step, jarl block group, jarl blocklist --step. API:
/blocking/steps, /blocking/groups. Database schema 8.

Also: copying the state kept empty lists as empty (not nil), so
reverting a changeset no longer adds restore operations for unchanged
blocklists, rules and views.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 21:33:16 +01:00
adamandClaude Opus 5.5 5b8f681e2a Show blocked counts on the Blocking page, and reveal + CNAME on hover
The Blocking page shows how many queries each blocklist, and the rules
together, blocked in the last 24 hours (with statistics on). The + CNAME
button on A/AAAA rows shows when the row is hovered or focused, and
always on touch screens.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 20:26:12 +01:00
adamandClaude Opus 5.5 8f0a0cc233 Release 0.4.0-rc.4
release / release (push) Successful in 1m55s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 20:16:26 +01:00
94 changed files with 6645 additions and 203 deletions

No files matched your search

+135 -1
View File
@@ -6,8 +6,135 @@ All notable changes to Jarl are documented here. The format follows
## [Unreleased] ## [Unreleased]
### Upgrading from 0.4
- New exposure warnings (docs/exposure.md) appear for views reachable from
the internet, e.g. "no rate-limit" or "version visible" on an external
view: fix them from the Exposure page, or acknowledge them. A change
that newly opens a public view up (an open resolver, say) now needs
`--accept-risk`, or the checkbox on the Changes page.
- 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
- Option values in the web UI looked different from the editor: a long
line wrapped back to the left edge, losing its indentation, and the
column changed width when Edit opened. Values now keep their layout
(a long line scrolls), with the same width and padding as the editor.
The "{ } around the list are optional" hint only shows for options that
end in a list (not rate-limit, which only has one inside).
- Query statistics skipped every query signed with a TSIG key (named
writes "/key NAME" after the client's port), such as queries with a
view's key: they are counted now.
- 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 ### Added
- Internet exposure ([docs/exposure.md](docs/exposure.md)): Jarl works out
which views are reachable from the internet (match-clients ending in a
catch-all; for a server without views, open allow-query and a
non-private listen address), with a per-view override, and checks them:
response rate limiting, open resolvers, ANY answers (minimal-any), cache
snooping, open NOTIFY and zone transfers, BIND version and host name,
management interfaces. Fixes are staged like any change, including
response rate limiting from ISC's example in log-only mode, with your
secondaries exempt, and a later "start enforcing". With statistics on,
Jarl reads named's rate limiting log lines and shows what was (or would
have been) limited: counts, client networks and names (Exposure page,
`jarl stats breakdown rrl`, top lists `ratelimited`, Prometheus
`jarl_ratelimit_logged_total`). A change that newly opens a public view
up must be confirmed (checkbox on the Changes page, `--accept-risk`, API
`accept_risk`; 409 `confirm_risk` otherwise). Findings can be
acknowledged with a reason: they stay visible as info, with who, when
and why. Exposure page and dashboard card in the web UI, `jarl
exposure` (fix, ack, unack), `GET /exposure`.
- `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 - 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 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 address. Jarl signs its delegation check's queries to this server's own
@@ -75,7 +202,11 @@ All notable changes to Jarl are documented here. The format follows
reorder them; view links everywhere open the view's page. reorder them; view links everywhere open the view's page.
- Web UI: A and AAAA records have a **+ CNAME** button that fills in the - 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 add form with a CNAME pointing to the record's name, leaving only the
alias's name to type. 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 - 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 CLI: grouped by topic (files, listening, access control, recursion and
forwarding, DNSSEC, transfers and notify, then the rest), alphabetical forwarding, DNSSEC, transfers and notify, then the rest), alphabetical
@@ -119,6 +250,9 @@ All notable changes to Jarl are documented here. The format follows
### Fixed ### 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 - Under the shipped systemd unit, jarld couldn't list the host's
addresses (it needs a netlink socket, which `RestrictAddressFamilies` addresses (it needs a netlink socket, which `RestrictAddressFamilies`
didn't allow), so the name server checks didn't recognize this server's didn't allow), so the name server checks didn't recognize this server's
+23 -5
View File
@@ -71,6 +71,19 @@ 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.
* **Internet exposure checks**: Jarl works out which views are reachable
from the internet (a GUA-addressed internal network still counts as
yours) and checks them: response rate limiting (set up in log-only mode
first, with what it would limit shown before you enforce it), open
resolvers, ANY amplification, cache snooping, version and host name
leaks, open management interfaces. Fixes are staged for review, changes
that open a public view up must be confirmed, and deliberate exceptions
can be acknowledged with a reason.
* **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, * **Blocking** like Pi-hole, done by named itself: blocklists (Adblock,
hosts or plain domains) downloaded and kept up to date, your own block 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 and allow rules that beat every list, per view, with "why is this
@@ -120,6 +133,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/exposure.md](docs/exposure.md) | Internet exposure: which views are public, hardening checks, response rate limiting |
| [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/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 |
@@ -171,11 +186,14 @@ review step.
## Status ## Status
Version 0.3.0 is the current release, and 0.4.0 is in testing Version 0.4.0 is the current release, and 0.4.1 is in testing
(0.4.0-rc.3): it adds blocking (blocklists and your own rules, like (0.4.1-rc.1): it adds Prometheus metrics, address ranges in ACLs and
Pi-hole, done by named), disabling zones, server statistics and view keys. 0.3.0 added disabled records, script-friendly other address match lists, and safer changeset handling. 0.4.0 added blocking (blocklists and
CLI output and client names in the statistics, on top of 0.2.0's DNSSEC your own rules, like Pi-hole, done by named, with different blocking for
signing, Porkbun support, two-factor login and view colours. Jarl is feature-complete for one server's configuration. 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)).
+1 -1
View File
@@ -1 +1 @@
0.4.0-rc.3 0.4.1-rc.1
+14 -4
View File
@@ -43,7 +43,7 @@ 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 Hosts, plain domain and Adblock (||domain^) lists work. Your own block and
allow rules ('jarl block') take precedence over every list.`} allow rules ('jarl block') take precedence over every list.`}
var views []string var views []string
var comment string var comment, step string
list := &cobra.Command{ list := &cobra.Command{
Use: "list", Use: "list",
@@ -59,7 +59,7 @@ allow rules ('jarl block') take precedence over every list.`}
return err return err
} }
return output(lists, func(w io.Writer) { return output(lists, func(w io.Writer) {
fmt.Fprintln(w, "NAME\tSTATE\tDOMAINS\tVIEWS\tUPDATED\tMEMORY\tURL") fmt.Fprintln(w, "NAME\tSTATE\tSTEP\tDOMAINS\tVIEWS\tUPDATED\tMEMORY\tURL")
for _, b := range lists { for _, b := range lists {
domains, updated, mem := "-", "-", "-" domains, updated, mem := "-", "-", "-"
if s := b.Status; s != nil && s.UpdatedAt != nil { if s := b.Status; s != nil && s.UpdatedAt != nil {
@@ -68,7 +68,7 @@ allow rules ('jarl block') take precedence over every list.`}
if b.MemoryMB > 0 { if b.MemoryMB > 0 {
mem = fmt.Sprintf("~%d MB", b.MemoryMB) mem = fmt.Sprintf("~%d MB", b.MemoryMB)
} }
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\t%s\n", b.Name, b.State, domains, viewsText(b.Views, b.AutoViews), updated, mem, b.URL) 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 { for _, b := range lists {
if b.Status != nil && b.Status.Error != "" { if b.Status != nil && b.Status.Error != "" {
@@ -95,6 +95,9 @@ allow rules ('jarl block') take precedence over every list.`}
} }
body := map[string]any{"name": args[0], "url": args[1]} body := map[string]any{"name": args[0], "url": args[1]}
viewsBody(body, views) viewsBody(body, views)
if step != "" {
body["step"] = step
}
if comment != "" { if comment != "" {
body["comment"] = comment body["comment"] = comment
} }
@@ -111,6 +114,7 @@ allow rules ('jarl block') take precedence over every list.`}
} }
add.Flags().StringArrayVar(&views, "view", nil, "a view it applies to (repeat for several; default: every view that offers recursion)") 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(&comment, "comment", "", "comment")
add.Flags().StringVar(&step, "step", "", "the blocking step it starts at (default: normal; see 'jarl block step')")
var rename, newURL string var rename, newURL string
update := &cobra.Command{ update := &cobra.Command{
@@ -136,6 +140,9 @@ allow rules ('jarl block') take precedence over every list.`}
if f.Changed("comment") { if f.Changed("comment") {
body["comment"] = comment body["comment"] = comment
} }
if f.Changed("step") {
body["step"] = step
}
viewsBody(body, views) viewsBody(body, views)
if len(body) == 0 { if len(body) == 0 {
return fmt.Errorf("nothing to change (see --help)") return fmt.Errorf("nothing to change (see --help)")
@@ -151,6 +158,7 @@ allow rules ('jarl block') take precedence over every list.`}
update.Flags().StringVar(&newURL, "url", "", "new URL") 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().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(&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 { toggle := func(disable bool) *cobra.Command {
verb := map[bool]string{true: "disable", false: "enable"}[disable] verb := map[bool]string{true: "disable", false: "enable"}[disable]
@@ -394,6 +402,8 @@ names below it (docs/blocking.md).`}
switch { switch {
case m.Result == "none": case m.Result == "none":
fmt.Fprintf(w, "%snot blocked\n", where) 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 != "": case m.List != "":
fmt.Fprintf(w, "%sblocked by blocklist %s (entry %s)\n", where, m.List, m.Entry) fmt.Fprintf(w, "%sblocked by blocklist %s (entry %s)\n", where, m.List, m.Entry)
default: default:
@@ -473,7 +483,7 @@ at once. "indefinitely" pauses until 'jarl block resume'.
}, },
} }
cmd.AddCommand(addRule("block"), addRule("allow"), list, del, check, pause, resume) cmd.AddCommand(addRule("block"), addRule("allow"), list, del, check, pause, resume, blockStepCmd(), blockGroupCmd())
return cmd return cmd
} }
+68 -29
View File
@@ -120,13 +120,17 @@ 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)
} }
if err := c.Do(ctx(), "POST", "/apply", nil, api.BatchRequest{Description: description, Ops: reqs, DryRun: c.DryRun, Force: c.Force}, &resp); err != nil { if err := c.Do(ctx(), "POST", "/apply", nil, api.BatchRequest{Description: description, Ops: reqs, DryRun: c.DryRun, Force: c.Force, AcceptRisk: c.AcceptRisk}, &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, "accept_risk": c.AcceptRisk}, &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{
+271
View File
@@ -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
}
+10 -2
View File
@@ -36,7 +36,7 @@ func viewCmd() *cobra.Command {
}, },
} }
var clients, dests []string var clients, dests []string
var recursiveOnly, comment string var recursiveOnly, comment, exposure string
var adopt, cascade, yes bool var adopt, cascade, yes bool
viewBody := func(cmd *cobra.Command) map[string]any { viewBody := func(cmd *cobra.Command) map[string]any {
body := map[string]any{} body := map[string]any{}
@@ -60,6 +60,9 @@ func viewCmd() *cobra.Command {
if f.Changed("comment") { if f.Changed("comment") {
body["comment"] = comment body["comment"] = comment
} }
if f.Changed("exposure") {
body["exposure"] = exposure
}
return body return body
} }
addViewFlags := func(c *cobra.Command) { addViewFlags := func(c *cobra.Command) {
@@ -67,6 +70,7 @@ func viewCmd() *cobra.Command {
c.Flags().StringSliceVar(&dests, "match-destinations", nil, "match-destinations elements") c.Flags().StringSliceVar(&dests, "match-destinations", nil, "match-destinations elements")
c.Flags().StringVar(&recursiveOnly, "recursive-only", "", "match-recursive-only: yes, no or default") c.Flags().StringVar(&recursiveOnly, "recursive-only", "", "match-recursive-only: yes, no or default")
c.Flags().StringVar(&comment, "comment", "", "comment") c.Flags().StringVar(&comment, "comment", "", "comment")
c.Flags().StringVar(&exposure, "exposure", "", "reachable from the internet: auto (worked out), public or private (docs/exposure.md)")
} }
create := &cobra.Command{ create := &cobra.Command{
Use: "create NAME", Use: "create NAME",
@@ -756,7 +760,7 @@ func settingsCmd() *cobra.Command {
}, },
} }
var ns []string var ns []string
var contact, policy string var contact, policy, srvExposure string
var ttl, refresh, retry, expire, neg uint32 var ttl, refresh, retry, expire, neg uint32
set := &cobra.Command{ set := &cobra.Command{
Use: "set", Use: "set",
@@ -777,6 +781,9 @@ func settingsCmd() *cobra.Command {
if f.Changed("serial-policy") { if f.Changed("serial-policy") {
body["serial_policy"] = policy body["serial_policy"] = policy
} }
if f.Changed("exposure") {
body["exposure"] = srvExposure
}
for name, v := range map[string]uint32{"ttl": ttl, "refresh": refresh, "retry": retry, "expire": expire, "negative-ttl": neg} { for name, v := range map[string]uint32{"ttl": ttl, "refresh": refresh, "retry": retry, "expire": expire, "negative-ttl": neg} {
if f.Changed(name) { if f.Changed(name) {
key := map[string]string{"ttl": "default_ttl", "negative-ttl": "negative_ttl"}[name] key := map[string]string{"ttl": "default_ttl", "negative-ttl": "negative_ttl"}[name]
@@ -799,6 +806,7 @@ func settingsCmd() *cobra.Command {
set.Flags().StringSliceVar(&ns, "ns", nil, "default name servers") set.Flags().StringSliceVar(&ns, "ns", nil, "default name servers")
set.Flags().StringVar(&contact, "contact", "", "default SOA contact") set.Flags().StringVar(&contact, "contact", "", "default SOA contact")
set.Flags().StringVar(&policy, "serial-policy", "", "date, increment or unixtime") set.Flags().StringVar(&policy, "serial-policy", "", "date, increment or unixtime")
set.Flags().StringVar(&srvExposure, "exposure", "", "without views: is the server reachable from the internet? auto, public or private (docs/exposure.md)")
set.Flags().Uint32Var(&ttl, "ttl", 0, "default TTL") set.Flags().Uint32Var(&ttl, "ttl", 0, "default TTL")
set.Flags().Uint32Var(&refresh, "refresh", 0, "SOA refresh") set.Flags().Uint32Var(&refresh, "refresh", 0, "SOA refresh")
set.Flags().Uint32Var(&retry, "retry", 0, "SOA retry") set.Flags().Uint32Var(&retry, "retry", 0, "SOA retry")
+98
View File
@@ -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)
}
}
+191
View File
@@ -0,0 +1,191 @@
package main
import (
"fmt"
"io"
"strings"
"github.com/spf13/cobra"
)
type exposureFinding struct {
Severity string `json:"severity"`
Code string `json:"code"`
Message string `json:"message"`
View string `json:"view"`
Fix string `json:"fix"`
}
type exposureReport struct {
Scopes []struct {
ViewID int64 `json:"view_id"`
View string `json:"view"`
Public bool `json:"public"`
Override string `json:"override"`
Reason string `json:"reason"`
RateLimit string `json:"rate_limit"`
Findings []exposureFinding `json:"findings"`
} `json:"scopes"`
Server []exposureFinding `json:"server"`
RateLimiting *struct {
Since string `json:"since"`
Counts []struct {
Key string
Count int64
} `json:"counts"`
Networks []struct {
Key string
Count int64
} `json:"networks"`
Names []struct {
Key string
Count int64
} `json:"names"`
} `json:"rate_limiting"`
}
// exposureScope turns "server" (or "") into the server, anything else
// into a view name.
func exposureScope(s string) map[string]any {
if s == "" || strings.EqualFold(s, "server") {
return map[string]any{}
}
return map[string]any{"view": s}
}
func exposureCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "exposure",
Short: "What is reachable from the internet, and what to harden (docs/exposure.md)",
Long: `Shows which views (or the server, without views) are reachable from the
internet and why, their rate limiting, and the exposure findings with the
fix Jarl can stage for each.`,
Example: ` jarl exposure
jarl exposure fix external exposure_rate_limit # rate limiting, log-only first
jarl exposure fix external exposure_rate_limit_log_only # then start enforcing
jarl exposure fix server exposure_version
jarl exposure ack external security_open_resolver --reason "public resolver for the club"
jarl view update external --exposure public # override the classification`,
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
var r exposureReport
if err := c.Get(ctx(), "/exposure", nil, &r); err != nil {
return err
}
return output(r, func(w io.Writer) {
fmt.Fprintln(w, "VIEW\tEXPOSURE\tRATE LIMIT\tWHY")
for _, s := range r.Scopes {
name, exp := s.View, "private"
if name == "" {
name = "(server)"
}
if s.Public {
exp = "public"
}
if s.Override != "" {
exp += " (set)"
}
rl := s.RateLimit
if !s.Public && rl == "off" {
rl = "-"
}
fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", name, exp, rl, s.Reason)
}
var all []exposureFinding
for _, s := range r.Scopes {
all = append(all, s.Findings...)
}
all = append(all, r.Server...)
fmt.Fprintln(w)
if len(all) == 0 {
fmt.Fprintln(w, "No findings.")
} else {
fmt.Fprintln(w, "SEVERITY\tWHERE\tCODE\tFINDING")
for _, f := range all {
where := f.View
if where == "" {
where = "server"
}
fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", f.Severity, where, f.Code, f.Message)
if f.Fix != "" {
fmt.Fprintf(w, "\t\t\tfix: jarl exposure fix %s %s (%s)\n", where, f.Code, f.Fix)
}
}
}
if a := r.RateLimiting; a != nil && len(a.Counts) > 0 {
fmt.Fprintf(w, "\nRATE LIMITING, LAST %s\n", a.Since)
for _, e := range a.Counts {
fmt.Fprintf(w, "%s\t%d\n", e.Key, e.Count)
}
for _, e := range a.Networks {
fmt.Fprintf(w, "network %s\t%d\n", e.Key, e.Count)
}
for _, e := range a.Names {
fmt.Fprintf(w, "name %s\t%d\n", e.Key, e.Count)
}
}
})
},
}
fix := &cobra.Command{
Use: "fix VIEW|server CODE",
Short: "Stage (or apply) the fix Jarl suggests for a finding",
Args: cobra.ExactArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
body := exposureScope(args[0])
body["code"] = args[1]
resp, err := c.Mutate(ctx(), "POST", "/exposure/fix", nil, body)
if err != nil {
return err
}
return report(resp)
},
}
var reason string
ack := &cobra.Command{
Use: "ack VIEW|server CODE --reason TEXT",
Short: "Accept a finding on purpose: it stays, as info, with who and why",
Args: cobra.ExactArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
body := exposureScope(args[0])
body["code"], body["reason"] = args[1], reason
resp, err := c.Mutate(ctx(), "POST", "/exposure/ack", nil, body)
if err != nil {
return err
}
return report(resp)
},
}
ack.Flags().StringVar(&reason, "reason", "", "why the finding is accepted (required)")
unack := &cobra.Command{
Use: "unack VIEW|server CODE",
Short: "Take an acknowledgement back: the finding warns again",
Args: cobra.ExactArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
body := exposureScope(args[0])
body["code"] = args[1]
resp, err := c.Mutate(ctx(), "POST", "/exposure/unack", nil, body)
if err != nil {
return err
}
return report(resp)
},
}
cmd.AddCommand(fix, ack, unack)
return cmd
}
+15 -8
View File
@@ -13,7 +13,6 @@ import (
"os" "os"
"path/filepath" "path/filepath"
"slices" "slices"
"strconv"
"strings" "strings"
"sync" "sync"
"text/tabwriter" "text/tabwriter"
@@ -48,6 +47,7 @@ var (
flagChangeset int64 flagChangeset int64
flagDryRun bool flagDryRun bool
flagForce bool flagForce bool
flagRisk bool
flagOutput string flagOutput string
flagInsecure bool flagInsecure bool
flagCACert string flagCACert string
@@ -149,15 +149,21 @@ 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, err
return nil, fmt.Errorf("invalid JARL_CHANGESET %q", v) }
} 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, c.AcceptRisk = flagDryRun || c.Preflight, flagForce, flagRisk
if !c.Preflight { if !c.Preflight {
c.OnTokenExpiry = tokenExpiryWarning(t.Name) c.OnTokenExpiry = tokenExpiryWarning(t.Name)
} }
@@ -239,6 +245,7 @@ Exit codes: 0 ok, 1 error, 3 not authenticated or not allowed, 4 not found,
pf.Int64VarP(&flagChangeset, "changeset", "C", 0, "stage changes into this changeset instead of applying them (env JARL_CHANGESET)") pf.Int64VarP(&flagChangeset, "changeset", "C", 0, "stage changes into this changeset instead of applying them (env JARL_CHANGESET)")
pf.BoolVarP(&flagDryRun, "dry-run", "n", false, "validate and show the changes without applying them") pf.BoolVarP(&flagDryRun, "dry-run", "n", false, "validate and show the changes without applying them")
pf.BoolVar(&flagForce, "force", false, "overwrite BIND files that were changed outside Jarl") pf.BoolVar(&flagForce, "force", false, "overwrite BIND files that were changed outside Jarl")
pf.BoolVar(&flagRisk, "accept-risk", false, "confirm a change that opens a public view up, such as an open resolver (docs/exposure.md)")
pf.StringVarP(&flagOutput, "output", "o", "table", "output format: table, json, yaml, id (just the IDs) or field=NAME[,NAME...] (just those fields, e.g. field=view or field=soa.serial)") pf.StringVarP(&flagOutput, "output", "o", "table", "output format: table, json, yaml, id (just the IDs) or field=NAME[,NAME...] (just those fields, e.g. field=view or field=soa.serial)")
pf.BoolVar(&flagInsecure, "insecure", false, "don't verify the server's TLS certificate") pf.BoolVar(&flagInsecure, "insecure", false, "don't verify the server's TLS certificate")
pf.StringVar(&flagCACert, "ca-cert", "", "PEM file with a CA to trust for the server's certificate") pf.StringVar(&flagCACert, "ca-cert", "", "PEM file with a CA to trust for the server's certificate")
@@ -252,7 +259,7 @@ Exit codes: 0 ok, 1 error, 3 not authenticated or not allowed, 4 not found,
} }
add("dns", zoneCmd(), recordCmd(), viewCmd(), aclCmd(), tsigCmd(), optionCmd(), settingsCmd(), blockCmd(), blocklistCmd(), 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(), exposureCmd(), whoamiCmd(), userCmd(), tokenCmd(), auditCmd(), checkCmd(), resyncCmd(), configCmd())
setupCompletion(root) setupCompletion(root)
return root return root
} }
+1 -1
View File
@@ -90,7 +90,7 @@ func perView(c *client.Client, views []string, description string, do func(c *cl
return nil return nil
} }
var resp api.MutationResponse 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) err := c.Do(ctx(), "POST", fmt.Sprintf("/changesets/%d/apply", cs.ID), nil, map[string]any{"force": c.Force, "dry_run": c.DryRun, "accept_risk": c.AcceptRisk}, &resp)
if err != nil || c.DryRun { if err != nil || c.DryRun {
discard() discard()
} }
+6 -2
View File
@@ -128,14 +128,15 @@ export the data.`,
var limit int var limit int
top := &cobra.Command{ top := &cobra.Command{
Use: "top [names|clients|nxdomain|servfail|refused|blocked|blocked-clients]", Use: "top [names|clients|nxdomain|servfail|refused|blocked|blocked-clients|ratelimited|ratelimited-names]",
Short: "The most queried names, busiest clients, names with errors, refused clients, blocked names", Short: "The most queried names, busiest clients, names with errors, refused clients, blocked names",
Example: ` jarl stats top names --since 7d Example: ` jarl stats top names --since 7d
jarl stats top clients --limit 50 jarl stats top clients --limit 50
jarl stats top nxdomain # misconfigured hosts and typos jarl stats top nxdomain # misconfigured hosts and typos
jarl stats top refused # who is asking for what they may not have jarl stats top refused # who is asking for what they may not have
jarl stats top blocked # what blocking stops most (docs/blocking.md) jarl stats top blocked # what blocking stops most (docs/blocking.md)
jarl stats top blocked-clients # the devices blocking stops most`, jarl stats top blocked-clients # the devices blocking stops most
jarl stats top ratelimited # client networks rate limiting limits, or would (docs/exposure.md)`,
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
@@ -147,6 +148,9 @@ export the data.`,
if kind == "blocked-client" { if kind == "blocked-client" {
kind = stats.KindBlockedClient kind = stats.KindBlockedClient
} }
if kind == "ratelimited-name" {
kind = stats.KindRateLimitedName
}
} }
c, err := newClient() c, err := newClient()
if err != nil { if err != nil {
+9
View File
@@ -27,6 +27,7 @@ import (
"jarl/internal/config" "jarl/internal/config"
"jarl/internal/engine" "jarl/internal/engine"
"jarl/internal/model" "jarl/internal/model"
"jarl/internal/nscheck"
"jarl/internal/porkbun" "jarl/internal/porkbun"
"jarl/internal/render" "jarl/internal/render"
"jarl/internal/server" "jarl/internal/server"
@@ -122,6 +123,7 @@ func openApp(allowMissingConfig bool) (*app, error) {
ReloadTimeout: time.Duration(cfg.BIND.ReloadTimeout), ReloadTimeout: time.Duration(cfg.BIND.ReloadTimeout),
NoReload: cfg.BIND.NoReload, NoReload: cfg.BIND.NoReload,
Extras: renderExtras(cfg), Extras: renderExtras(cfg),
LocalAddrs: nscheck.LocalAddrs,
}, log) }, log)
eng.Limits = engine.BlocklistLimits{ eng.Limits = engine.BlocklistLimits{
Refresh: time.Duration(cfg.Blocking.Refresh), RetryAfter: time.Hour, Timeout: 2 * time.Minute, Refresh: time.Duration(cfg.Blocking.Refresh), RetryAfter: time.Hour, Timeout: 2 * time.Minute,
@@ -303,6 +305,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),
@@ -311,6 +318,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,
+20 -3
View File
@@ -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`
@@ -405,7 +415,7 @@ 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) |
@@ -420,14 +430,19 @@ Needs `blocking:read` (`blocking:write` to change). See
| Method | Path | Body / query | | 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) | | 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": ""}` (`views` empty or absent: automatic); jarld downloads it right after | | 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` | | 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` | | 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` | | 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) | | POST | `/blocking/rules` | `{"name": "doubleclick.net", "action": "block", "exact": false, "views": [], "comment": ""}` (`action` block or allow; `exact`: only the name, not the names below it) |
| PATCH / DELETE | `/blocking/rules/{id}` | | | PATCH / DELETE | `/blocking/rules/{id}` | |
| DELETE | `/blocking/rules` | `?name=DOMAIN[&view=V][&missing_ok=1]`: the rules for a domain | | DELETE | `/blocking/rules` | `?name=DOMAIN[&view=V][&missing_ok=1]`: the rules for a domain |
| GET | `/blocking/check` | `?name=DOMAIN`: per view with blocking, `{view, result: blocked\|allowed\|none, entry, rule_id, list}` (what applies when blocking isn't paused) | | 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) | | 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/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 | | POST | `/blocking/resume` | end a pause now |
@@ -456,6 +471,8 @@ written in Jarl's fixed order (docs/concepts.md).
| 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 | `/exposure` | which views are reachable from the internet, the exposure findings and their fixes ([exposure.md](exposure.md#api)); `POST /exposure/fix`, `/exposure/ack`, `/exposure/unack` |
| 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
+64
View File
@@ -147,6 +147,56 @@ A list's state is **ok** (in use), **waiting** (not downloaded yet),
**error** (the last update failed; the previous version stays in use), **error** (the last update failed; the previous version stays in use),
**disabled**, or **idle** (it applies to no view, so it does nothing). **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 ## Checking a name
`jarl block check NAME`, or the box at the top of the Blocking page, says `jarl block check NAME`, or the box at the top of the Blocking page, says
@@ -194,6 +244,10 @@ names, and the clients with the most blocked queries (`jarl stats summary`,
breakdown blocklist`). Blocked answers are left out of the NXDOMAIN figures, which breakdown blocklist`). Blocked answers are left out of the NXDOMAIN figures, which
are about names that really don't exist. are about names that really don't exist.
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. Without statistics named doesn't log what it blocks at all.
## Memory and performance ## Memory and performance
@@ -250,6 +304,16 @@ response-policy {
blocking in the same view: Jarl refuses the change and says so. 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 - Rewrites are logged to Jarl's response log when statistics read it, and
not at all otherwise. 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)). Lists and rules need the `blocking` permission ([permissions.md](permissions.md)).
+50 -4
View File
@@ -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
@@ -143,6 +143,7 @@ to stderr with `-o json` or `-o yaml`, so stdout holds just the documents.
| `-n, --dry-run` | Validate and show the diff without changing anything. | | `-n, --dry-run` | Validate and show the diff without changing anything. |
| `-C, --changeset ID` | Stage the change into an open changeset instead of applying it. | | `-C, --changeset ID` | Stage the change into an open changeset instead of applying it. |
| `--force` | Overwrite BIND files that were changed outside Jarl (drift). | | `--force` | Overwrite BIND files that were changed outside Jarl (drift). |
| `--accept-risk` | Confirm a change that opens a public view up, such as an open resolver ([exposure.md](exposure.md)). Without it such a change stops with exit 5. |
| `-q, --quiet` | Print only errors, or just the IDs: of what a command creates, or what `show` and `list` find (like `-o id`). | | `-q, --quiet` | Print only errors, or just the IDs: of what a command creates, or what `show` and `list` find (like `-o id`). |
## Exit codes ## Exit codes
@@ -345,9 +346,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]
@@ -357,8 +358,45 @@ 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.
## Internet exposure
```sh
jarl exposure # public views, findings, fixes, rate limiting
jarl exposure fix external exposure_rate_limit # rate limiting, log-only first
jarl exposure fix external exposure_rate_limit_log_only # then start enforcing
jarl exposure fix server exposure_version
jarl exposure ack external security_open_resolver --reason "public resolver, monitored"
jarl exposure unack external security_open_resolver
jarl view update external --exposure public|private|auto
```
See [exposure.md](exposure.md).
## Views, ACLs, TSIG keys, options, settings ## Views, ACLs, TSIG keys, options, settings
```sh ```sh
@@ -371,6 +409,7 @@ jarl view color internal green # web UI colour; "default" resets
jarl view key external [--named-conf] # the key that selects the view (dig -k), docs/views.md 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
@@ -386,6 +425,7 @@ 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 forwarders 1.1.1.1 9.9.9.9 # the same: braces are added to a list 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-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
@@ -476,6 +516,12 @@ jarl block delete DOMAIN [--view V] | --id ID
jarl block check DOMAIN # blocked? by which rule or list? jarl block check DOMAIN # blocked? by which rule or list?
jarl block pause [DURATION] # let everything through for a while (default 10m, at most 24h) jarl block pause [DURATION] # let everything through for a while (default 10m, at most 24h)
jarl block pause indefinitely # ... until 'jarl block resume' 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 jarl block resume
``` ```
+26
View File
@@ -167,6 +167,32 @@ RFC 1982 arithmetic.
alphabetical within a group. So the same option is in the same place in 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 every view and zone. Top-level statements stay in the order they were
added. 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.
+12
View File
@@ -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
+1
View File
@@ -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
+213
View File
@@ -0,0 +1,213 @@
# Internet exposure
When a view (or the whole server) can be reached from the internet, a few
things matter that don't on your own network: it shouldn't help attackers
flood others with DNS answers (amplification), recurse for strangers, or
tell scanners its exact version. Jarl works out which views are public,
checks them, and stages the fixes for you to review.
In the web UI: **Exposure** (and its card on the dashboard). In the CLI:
`jarl exposure`. In the API: `GET /api/v1/exposure`.
## Which views are public
A view is **public** when its `match-clients` let in anyone: after ACLs
and ranges are filled in, they end in a catch-all (`any`, `0.0.0.0/0`,
`::/0`, or a prefix wider than /8 for IPv4 or /16 for IPv6), or they are
empty, which named reads as "any". A typical external view
(`match-clients { any; }` or `{ !internal; any; }`) is public.
A view that lists the networks it serves is **private**, whatever kind of
addresses those are. An internal view for your global IPv6 prefix
(`2a02:8013:7f3d:10::/64`) is as private as one for `192.168.1.0/24`:
listing a network says "these are mine". Jarl doesn't look at whether
addresses are private (RFC 1918, ULA) for this, because on IPv6 your own
devices have global addresses.
A view whose `match-destinations` list only private addresses (it answers
only on a private interface) is private too.
**A server without views** has no match list, so Jarl looks at
`allow-query` and where named listens: it is public when anyone may query
it (`allow-query` unset, or with a catch-all) **and** named listens on an
address that isn't private (a public IPv4 address or a global IPv6
address of this host). This can't see your firewall, so it is a guess.
**When Jarl gets it wrong**, override it per view: `public`, `private` or
`auto`. For example, behind a NAT port forward named only ever sees
private addresses although it is reachable from the internet.
```sh
jarl view update external --exposure public
jarl settings set --exposure private # a server without views
```
The Exposure page shows each view's classification and the reason
("match-clients ends in any", "match-clients lists only homelab, no
catch-all"), with a selector for the override.
## The checks
They run on public views only, and are warnings (they don't stop you
applying) unless acknowledged.
| Code | What | Fix Jarl can stage |
|------|------|-------------------|
| `exposure_rate_limit` | No response rate limiting: the view can be used to flood others with its answers (DNS amplification) | `rate-limit { responses-per-second 10; log-only yes; exempt-clients { your secondaries }; }` |
| `exposure_rate_limit_log_only` (info) | Rate limiting only logs | The same block without `log-only`: start enforcing |
| `security_open_resolver` | Recursion for anyone: an open resolver | `recursion no` in that view |
| `exposure_minimal_any` | ANY queries get full answers, a favourite for amplification | `minimal-any yes` |
| `exposure_cache_snooping` | `allow-query-cache` lets anyone see what your clients look up | `allow-query-cache { none; }` |
| `security_open_transfer` | Zone transfers for anyone | none: allow your secondaries or a TSIG key |
| `exposure_open_notify` | `allow-notify` accepts NOTIFY from anyone | none: allow your primaries |
| `exposure_version` | `dig CH TXT version.bind` shows the exact BIND version | `version none` |
| `exposure_hostname` | `dig CH TXT hostname.bind` shows the host name | `hostname none` |
| `exposure_management` | `controls` or `statistics-channels` on an address that isn't loopback, open to anyone | none: use 127.0.0.1 or an allow list |
The last three are server-wide: they are checked when any view is public.
A **fix** goes into your pending changes like any edit (or is applied at
once in live mode), so you see the named.conf diff before it reaches named:
```sh
jarl exposure # classification, findings, fixes
jarl exposure fix external exposure_rate_limit
jarl exposure fix server exposure_version
```
## Response rate limiting
Response rate limiting (RRL, BIND's `rate-limit`) counts identical
responses sent to the same client network. Past the limit, named drops
some and "slips" others: it sends a short reply asking the client to retry
over TCP (or with a DNS cookie). A real client does; the spoofed victim of
an amplification attack never sees the answers it didn't ask for. ISC
recommends it for authoritative servers only, so Jarl only suggests it for
public views, never for your internal resolver.
Jarl starts from ISC's example, 10 identical responses per second per
client network, and exempts the servers your zones notify and allow
transfers to (`also-notify` and `allow-transfer` addresses, through ACLs
too): limiting your own secondaries is the classic RRL mistake.
**Rollout in two steps:**
1. The fix turns rate limiting on with `log-only yes`. named answers
everyone as before, but logs every response it *would* have limited.
With [query statistics](stats.md) on, Jarl reads those lines: the
Exposure page shows how many responses would have been limited, and
which client networks and names. Check that your secondaries,
monitoring, or a big shared resolver aren't among them (add them to
`exempt-clients` if they are).
2. When you're happy, **Start enforcing** (the fix of
`exposure_rate_limit_log_only`) removes `log-only`.
In log-only mode named's own counters don't move: what would be limited
is only in its log (category `query-errors`). That is why the activity
needs query statistics; without them, look at named's log for
`would rate limit`. When rate limiting is configured and statistics are
on, Jarl adds `query-errors` to its log channel (keeping named's syslog
destination for it).
The statistics also count it: the `rrl` breakdown (`drop`, `slip`,
`would-drop`, `would-slip`), the `ratelimited` and `ratelimited_name` top
lists, and in Prometheus `jarl_ratelimit_logged_total{action}` next to
named's own `jarl_ratelimit_responses_total` ([metrics.md](metrics.md)).
### Settings
You can tune the block like any option; `option set` replaces the whole
block, so copy the current value from `jarl option list --view external`
and edit it:
```sh
jarl option set rate-limit '{ responses-per-second 20; window 15; slip 2; exempt-clients { 192.0.2.53; }; log-only yes; }' --view external
```
named counts **identical responses** to the same **client network** (a
/24 for IPv4, a /56 for IPv6) over a **window**, and limits each kind of
response separately. These are the settings (BIND 9.20; the ARM has the
full text):
| Setting | Default | What it does | When to change it |
|---------|---------|--------------|-------------------|
| `responses-per-second` | 0 (off) | Answers with data for the same name and type, per client network. Wildcard answers count as the zone's apex. This is the main limit; Jarl's fix sets 10 | Raise it if a legitimate network shows up in log-only mode, e.g. a large shared resolver (mobile carriers, public resolvers) asking for your popular names |
| `nxdomains-per-second` | same as `responses-per-second` | NXDOMAIN answers (names that don't exist), counted together for a domain whatever the name asked | `0` turns NXDOMAIN limiting off, for zones that legitimately get many lookups of missing names |
| `nodata-per-second` | same as `responses-per-second` | Empty answers (the name exists, the type doesn't), counted together whatever the type | Rarely |
| `referrals-per-second` | same as `responses-per-second` | Referrals to child zones you delegate | Only if you delegate subdomains to other servers |
| `errors-per-second` | same as `responses-per-second` | Other errors (REFUSED, SERVFAIL, FORMERR), counted together whatever the name | Rarely. REFUSED and SERVFAIL can't be truncated, so slipped ones still go out at the slip rate |
| `all-per-second` | 0 (off) | All UDP answers to a client network, of any kind, like a firewall rate limit; over it, answers are only **dropped** (`slip` doesn't apply) | Usually leave off; if you use it, ISC says at least 4 times the other limits |
| `window` | 15 seconds (1–3600) | How far back named counts. A client over the limit stays limited until its rate over the window falls again | Longer windows remember attackers longer and also punish short legitimate bursts longer |
| `slip` | 2 (0–10) | Of the limited answers, every n-th "slips": instead of being dropped it gets a short reply asking the client to retry: truncated (TC) so it retries over TCP, or, when the query carried a DNS cookie, BADCOOKIE with a server cookie that lets it through on its retry. `0` drops all, `1` slips all | `1` if you'd rather every real client gets a retry hint; `0` drops all (spoofed victims see nothing, but real clients just time out) |
| `ipv4-prefix-length` | 24 | The size of the client network answers are counted for (IPv4) | `32` counts each address on its own: fairer to neighbours, more memory |
| `ipv6-prefix-length` | 56 | The same for IPv6 | `64` if one /56 holds many independent clients; `48` to treat a whole site as one |
| `exempt-clients` | none | Address match list (addresses, prefixes, [ranges](concepts.md#model-views-zones-records-options), ACLs) that is never limited. Jarl's fix puts your secondaries here | Add monitoring, other servers of yours, and anything legitimate that log-only mode shows |
| `log-only` | no | Count and log what would be limited, answer everyone normally | Jarl starts with `yes`; the "start enforcing" fix removes it |
| `qps-scale` | off | When the total query rate is above this, all limits shrink in proportion (at a rate of 1000 with `qps-scale 250`, a limit of 20 becomes 5) | Only on busy servers that see attacks spread over many networks |
| `max-table-size`, `min-table-size` | min 500 entries | The table of client networks named tracks (40–80 bytes per entry) | Only on busy servers |
What Jarl checked against real named: TCP answers are never limited;
`nxdomains-per-second` falls back to `responses-per-second` and `0`
turns it off; NXDOMAIN answers for different missing names in a zone
share one limit; `all-per-second` only drops; with `slip 1`, clients
without DNS cookies get truncated answers, and clients with cookies (dig
sends them by default) get through on their retry with the server's
cookie; `exempt-clients` is never limited; in log-only mode nothing is
limited and only the log shows what would be.
Rate limiting is per view: put it in the external view (`--view
external`). Set in the server options, it applies to every view that has
none of its own, your internal resolver included, which RRL isn't meant
for.
## Confirming a change that opens a view up
A change that *introduces* an open resolver, open zone transfers, cache
snooping, open NOTIFY or an open management interface on a public view
must be confirmed:
- **Web UI:** the Changes page shows the finding with a checkbox (*I
understand: …*); **Apply** stays disabled until it is ticked.
- **CLI:** the command stops (exit 5) and says why; run it again with
`--accept-risk`.
- **API:** `accept_risk: true` in the apply body, or `?accept_risk=1` on a
change applied at once; without it the answer is 409 `confirm_risk`.
Previews (`GET /changesets/{id}/preview`) list them as `risks`.
Only changes that newly open something up ask: an unrelated change to a
view that is already open, or taking back an acknowledgement, doesn't.
## Acknowledging a finding
Some findings are deliberate: a public resolver for a club's network, a
visible version string. Acknowledge them with a reason:
```sh
jarl exposure ack external security_open_resolver --reason "public resolver for the club, monitored"
jarl exposure ack server exposure_version --reason "version shown on purpose"
jarl exposure unack external security_open_resolver
```
The finding doesn't disappear: it stays visible as **info**, with who
accepted it, when and why (*open resolver … (acknowledged by adam on 12
Oct 2026: public resolver for the club, monitored)*), on the Exposure page,
the dashboard and in `jarl check`. An acknowledgement covers that one
finding on that one view (or the server); the same finding elsewhere
warns as usual. Acknowledging is a configuration change like any other,
so it is in the history and audit log, and can be reverted.
Jarl isn't designed to run an open resolver, and an acknowledgement makes
one deliberate and visible, not safe: a public resolver also needs abuse
monitoring, response size limits and usually a machine of its own.
## API
| Method | Path | Body |
|--------|------|------|
| GET | `/exposure` | `scopes` (per view, or the server: `public`, `override`, `reason`, `rate_limit` off/log-only/enforcing, `findings` with `fix` where Jarl has one), `server` (server-wide findings), `rate_limiting` (last 24h, with statistics: `counts`, `networks`, `names`) |
| POST | `/exposure/fix` | `{"view": "external", "code": "exposure_rate_limit"}` (`view_id` works too; no view: the server). Staged with `?changeset=` like any change |
| POST | `/exposure/ack` | `{"view": "external", "code": "security_open_resolver", "reason": "..."}` |
| POST | `/exposure/unack` | `{"view": "external", "code": "security_open_resolver"}` |
The override is a view's `exposure` field (`PATCH /views/{id}`), or the
settings' for a server without views (`PATCH /settings`).
+232
View File
@@ -0,0 +1,232 @@
# 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` (named's counters; they don't move in log-only mode). |
| `jarl_ratelimit_logged_total{action}` | Rate limited responses from named's log: `drop`, `slip`, and in log-only mode `would-drop`, `would-slip` ([exposure.md](exposure.md#response-rate-limiting)). |
| `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
```
+3
View File
@@ -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,
+6 -1
View File
@@ -103,6 +103,7 @@ lists, views, zones). named's own counters can't be filtered.
| `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) | | `blocked`, `blocklist:NAME` | response log | queries [blocking](blocking.md) answered, and by which list (`blocklist:rules` for your own rules) |
| `rrl:drop`, `rrl:slip`, `rrl:would-drop`, `rrl:would-slip` | response log | responses [rate limiting](exposure.md#response-rate-limiting) limited, or would have in log-only mode (named's own `ratelimit:*` counters don't move then) |
**Server** (sampled every minute; stored as gauges, `g:KEY` the sum of **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): the samples and `gn:KEY` their number, so a chart shows the average):
@@ -167,7 +168,8 @@ jarl stats top refused # who asks for what they may not have
jarl stats timeline --since 6h # sparkline; --table for every bucket 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 | rrl
jarl stats top ratelimited # client networks rate limiting limits (or would)
jarl stats server # memory, CPU, cache, clients, host, disks now 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 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)
@@ -175,6 +177,9 @@ 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.
+23 -2
View File
@@ -67,10 +67,11 @@ 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. Names starting with `jarl-view-` are reserved for the view keys (`key_name_reserved`) | | `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 |
@@ -115,14 +116,34 @@ From [blocking.md](blocking.md).
| `blocking_view_missing` | error | A list or rule names a view that doesn't exist | | `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_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_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
These and the exposure checks below only look at views that are
reachable from the internet ([exposure.md](exposure.md)); without views,
the open resolver and open transfer checks look at the server. Any of them
can be acknowledged (it then shows as info, with who accepted it and why),
and a change that newly introduces an open resolver, open transfers, cache
snooping, open NOTIFY or an open management interface on a public view
must be confirmed (`--accept-risk`).
| Code | Severity | What it catches | | Code | Severity | What it catches |
|------|----------|-----------------| |------|----------|-----------------|
| `security_open_resolver` | warning | A view (or the server) where recursion is on and reachable by any client. This includes the BIND trap where `allow-recursion` is unset and inherits `allow-query { any; }`. Open resolvers are abused for DDoS amplification. Set `allow-recursion` to your own networks | | `security_open_resolver` | warning | A view (or the server) where recursion is on and reachable by any client. This includes the BIND trap where `allow-recursion` is unset and inherits `allow-query { any; }`. Open resolvers are abused for DDoS amplification. Set `allow-recursion` to your own networks |
| `security_open_transfer` | warning | `allow-transfer` open to anyone. Restrict it to your secondaries or a TSIG key | | `security_open_transfer` | warning | `allow-transfer` open to anyone. Restrict it to your secondaries or a TSIG key |
| `dynamic_update_unsupported` | error | `allow-update` / `update-policy` on a Jarl zone. named would write to files that Jarl regenerates, losing the updates. Use the API (e.g. `jarl acme`) instead | | `dynamic_update_unsupported` | error | `allow-update` / `update-policy` on a Jarl zone. named would write to files that Jarl regenerates, losing the updates. Use the API (e.g. `jarl acme`) instead |
| `exposure_rate_limit` | warning | A public view without response rate limiting (`rate-limit`) |
| `exposure_rate_limit_log_only` | info | Rate limiting in log-only mode: check what it would limit, then enforce |
| `exposure_minimal_any` | warning | A public view answering ANY queries in full (`minimal-any` off) |
| `exposure_cache_snooping` | warning | A public view without recursion whose cache anyone may query (`allow-query-cache`) |
| `exposure_open_notify` | warning | A public view accepting NOTIFY from anyone (`allow-notify`) |
| `exposure_version`, `exposure_hostname` | warning | The server is public and shows its BIND version or host name to anyone (`version none`, `hostname none`) |
| `exposure_management` | warning | `controls` or `statistics-channels` on an address that isn't loopback, open to anyone, while the server is public |
## What only BIND checks ## What only BIND checks
+5
View File
@@ -81,6 +81,11 @@ avoids named-checkconf's warning that `allow-recursion` is still active in a
view with recursion off. Jarl warns (`security_open_resolver`) if a view view with recursion off. Jarl warns (`security_open_resolver`) if a view
ever offers recursion to everyone. ever offers recursion to everyone.
Jarl counts the external view as reachable from the internet (its
match-clients end in `any`) and checks it: rate limiting, ANY queries,
the version string and more. See the **Exposure** page or `jarl exposure`,
and [exposure.md](exposure.md) to set up response rate limiting.
### 4. Give the external view its zones ### 4. Give the external view its zones
**Different data per view (split DNS).** Copy the zone, then change what **Different data per view (split DNS).** Copy the zone, then change what
+24
View File
@@ -208,6 +208,9 @@ type BatchRequest struct {
Ops []ChangeRequest `json:"ops"` Ops []ChangeRequest `json:"ops"`
DryRun bool `json:"dry_run,omitempty"` DryRun bool `json:"dry_run,omitempty"`
Force bool `json:"force,omitempty"` Force bool `json:"force,omitempty"`
// AcceptRisk confirms a change that opens the server up
// (docs/exposure.md).
AcceptRisk bool `json:"accept_risk,omitempty"`
// Apply (for POST /changesets) applies the new changeset at once. // Apply (for POST /changesets) applies the new changeset at once.
Apply bool `json:"apply,omitempty"` Apply bool `json:"apply,omitempty"`
} }
@@ -506,6 +509,27 @@ type Blocklist struct {
// MemoryMB estimates what named needs for the list, in all its views. // MemoryMB estimates what named needs for the list, in all its views.
MemoryMB int `json:"memory_mb,omitempty"` MemoryMB int `json:"memory_mb,omitempty"`
Pending string `json:"pending,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. // BlockRule is a block or allow rule as returned by the API.
+180
View File
@@ -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()
}
+96
View File
@@ -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)
}
}
+3
View File
@@ -40,6 +40,9 @@ type Match struct {
Entry string `json:"entry,omitempty"` // the matching entry: the name, or *.parent Entry string `json:"entry,omitempty"` // the matching entry: the name, or *.parent
RuleID int64 `json:"rule_id,omitempty"` RuleID int64 `json:"rule_id,omitempty"`
List string `json:"list,omitempty"` // the blocklist, when a list matched 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. // Actions of rules.
+7
View File
@@ -493,6 +493,13 @@ func spaceElements(fields []string) []string {
f += " " + fields[i+1] f += " " + fields[i+1]
i++ 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": case "port", "tls":
if len(out) > 0 && i+1 < len(fields) { if len(out) > 0 && i+1 < len(fields) {
out[len(out)-1] += " " + f + " " + fields[i+1] out[len(out)-1] += " " + f + " " + fields[i+1]
+34 -5
View File
@@ -30,8 +30,17 @@ 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
DryRun bool // CheckChangeset, if set, runs once before Changeset is first used,
Force bool // 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
Force bool
// AcceptRisk confirms changes that open the server up
// (docs/exposure.md).
AcceptRisk bool
// Preflight lets only requests through that can't change anything: // Preflight lets only requests through that can't change anything:
// reads and dry runs. Running a command on several servers uses it to // reads and dry runs. Running a command on several servers uses it to
// check the command everywhere before running it anywhere. // check the command everywhere before running it anywhere.
@@ -243,6 +252,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 +272,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")
@@ -259,8 +285,11 @@ func (c *Client) Mutate(ctx context.Context, method, path string, query url.Valu
if c.Force { if c.Force {
q.Set("force", "1") q.Set("force", "1")
} }
if c.AcceptRisk {
q.Set("accept_risk", "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
} }
+74
View File
@@ -4,6 +4,7 @@ package config
import ( import (
"bytes" "bytes"
"fmt" "fmt"
"net"
"net/netip" "net/netip"
"net/url" "net/url"
"os" "os"
@@ -38,6 +39,56 @@ type Config struct {
ACME ACME `yaml:"acme"` ACME ACME `yaml:"acme"`
Porkbun Porkbun `yaml:"porkbun"` Porkbun Porkbun `yaml:"porkbun"`
Blocking Blocking `yaml:"blocking"` 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 // Blocking configures blocklist downloads (docs/blocking.md). The lists
@@ -323,6 +374,7 @@ func Default() *Config {
}, },
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}, 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)},
} }
} }
@@ -394,6 +446,9 @@ func (c *Config) check() error {
if err := c.Blocking.check(); err != nil { if err := c.Blocking.check(); err != nil {
return fmt.Errorf("blocking: %w", err) 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:
@@ -633,4 +688,23 @@ blocking:
refresh: 24h # check each list for changes this often (at least 1h) refresh: 24h # check each list for changes this often (at least 1h)
max_download_mb: 64 # refuse larger downloads max_download_mb: 64 # refuse larger downloads
max_entries: 1000000 # refuse lists with more domains 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: ""
` `
+28
View File
@@ -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)
}
}
}
+10 -4
View File
@@ -335,7 +335,9 @@ func (l *limitedReader) Read(p []byte) (int, error) {
// CheckBlocking reports, for every view with blocking, whether name is // CheckBlocking reports, for every view with blocking, whether name is
// blocked or allowed there, and by which rule or list. It looks names up // 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 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) { func (e *Engine) CheckBlocking(st *model.State, name string) ([]blocking.Match, error) {
n, err := blocking.NormalizeName(name) n, err := blocking.NormalizeName(name)
if err != nil { if err != nil {
@@ -349,8 +351,8 @@ func (e *Engine) CheckBlocking(st *model.State, name string) ([]blocking.Match,
out = append(out, blocking.Match{View: z.View, Result: "none"}) out = append(out, blocking.Match{View: z.View, Result: "none"})
cur = &out[len(out)-1] cur = &out[len(out)-1]
} }
if cur.Result != "none" { if cur.Result != "none" || z.IsGate() {
continue // an earlier zone decided continue // an earlier zone decided, or a gate (it never blocks)
} }
if z.ListID == 0 { if z.ListID == 0 {
owner, act, ok := blocking.MatchRules(blocking.RuleEntries(render.ViewRules(st, z.ViewID)), n) owner, act, ok := blocking.MatchRules(blocking.RuleEntries(render.ViewRules(st, z.ViewID)), n)
@@ -372,7 +374,11 @@ func (e *Engine) CheckBlocking(st *model.State, name string) ([]blocking.Match,
feeds[z.File] = entry feeds[z.File] = entry
} }
if entry != "" { if entry != "" {
cur.Result, cur.Entry, cur.List = "blocked", entry, st.Blocklists[z.ListID].Name 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 return out, nil
+15
View File
@@ -117,6 +117,16 @@ func applyBlocklistPut(st *model.State, op model.Op, c *applyCtx) error {
if in.Disabled != nil { if in.Disabled != nil {
b.Disabled = *in.Disabled 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 { if in.Comment != nil {
b.Comment = *in.Comment b.Comment = *in.Comment
} }
@@ -226,6 +236,11 @@ func blockingUsers(st *model.State, viewID int64) []string {
out = append(out, ruleLabel(r)) 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 return out
} }
+270
View File
@@ -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
})
}
+123
View File
@@ -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)
}
}
+35 -3
View File
@@ -3,6 +3,7 @@ package engine
import ( import (
"fmt" "fmt"
"reflect" "reflect"
"slices"
"sort" "sort"
"jarl/internal/dnsname" "jarl/internal/dnsname"
@@ -23,7 +24,8 @@ func DiffOps(from, to *model.State) []model.Op {
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,
Refresh: &s.Refresh, Retry: &s.Retry, Expire: &s.Expire, NegativeTTL: &s.NegativeTTL, SerialPolicy: &s.SerialPolicy}, "restore settings") Refresh: &s.Refresh, Retry: &s.Retry, Expire: &s.Expire, NegativeTTL: &s.NegativeTTL, SerialPolicy: &s.SerialPolicy,
Exposure: &s.Exposure, Acknowledged: model.Ptr(slices.Clone(s.Acknowledged))}, "restore settings")
} }
for _, k := range sortedKeys(to.Keys) { for _, k := range sortedKeys(to.Keys) {
@@ -47,7 +49,8 @@ func DiffOps(from, to *model.State) []model.Op {
for _, k := range sortedKeys(to.Views) { for _, k := range sortedKeys(to.Views) {
n := to.Views[k] n := to.Views[k]
in := ViewInput{Name: &n.Name, MatchClients: model.Ptr(append([]string{}, n.MatchClients...)), in := ViewInput{Name: &n.Name, MatchClients: model.Ptr(append([]string{}, n.MatchClients...)),
MatchDestinations: model.Ptr(append([]string{}, n.MatchDestinations...)), Comment: &n.Comment, Position: model.Ptr(n.Position)} MatchDestinations: model.Ptr(append([]string{}, n.MatchDestinations...)), Comment: &n.Comment, Position: model.Ptr(n.Position),
Exposure: model.Ptr(n.Exposure), Acknowledged: model.Ptr(slices.Clone(n.Acknowledged))}
if n.MatchRecursiveOnly != nil { if n.MatchRecursiveOnly != nil {
in.MatchRecursiveOnly = model.Some(*n.MatchRecursiveOnly) in.MatchRecursiveOnly = model.Some(*n.MatchRecursiveOnly)
} else { } else {
@@ -104,9 +107,19 @@ 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) { for _, k := range sortedKeys(to.Blocklists) {
n := to.Blocklists[k] 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} 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 { if o, ok := from.Blocklists[k]; !ok {
add(OpBlocklistCreate, k, 0, in, "add blocklist "+n.Name) add(OpBlocklistCreate, k, 0, in, "add blocklist "+n.Name)
} else if !sameObject(o, n) { } else if !sameObject(o, n) {
@@ -122,7 +135,21 @@ func DiffOps(from, to *model.State) []model.Op {
add(OpBlockRuleUpdate, k, o.Version, in, "restore rule: "+ruleLabel(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) { for _, k := range sortedKeys(from.BlockRules) {
if _, ok := to.BlockRules[k]; !ok { if _, ok := to.BlockRules[k]; !ok {
add(OpBlockRuleDelete, k, from.BlockRules[k].Version, nil, "delete rule: "+ruleLabel(from.BlockRules[k])) add(OpBlockRuleDelete, k, from.BlockRules[k].Version, nil, "delete rule: "+ruleLabel(from.BlockRules[k]))
@@ -133,6 +160,11 @@ func DiffOps(from, to *model.State) []model.Op {
add(OpBlocklistDelete, k, from.Blocklists[k].Version, nil, "delete blocklist "+from.Blocklists[k].Name) 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]
+84 -15
View File
@@ -15,6 +15,7 @@ import (
"io/fs" "io/fs"
"log/slog" "log/slog"
"net/http" "net/http"
"net/netip"
"os" "os"
"path/filepath" "path/filepath"
"slices" "slices"
@@ -56,6 +57,10 @@ type Config struct {
NoReload bool NoReload bool
// SkipCheck disables named-checkconf (only for tests without BIND). // SkipCheck disables named-checkconf (only for tests without BIND).
SkipCheck bool SkipCheck bool
// LocalAddrs returns this host's addresses, for working out whether a
// server without views is reachable from the internet
// (docs/exposure.md). nil: none known.
LocalAddrs func() []netip.Addr
} }
// Engine applies changesets. // Engine applies changesets.
@@ -110,17 +115,20 @@ type Result struct {
DryRun bool `json:"dry_run,omitempty"` DryRun bool `json:"dry_run,omitempty"`
Valid bool `json:"valid"` Valid bool `json:"valid"`
Problems []validate.Problem `json:"problems"` // problems this change introduces Problems []validate.Problem `json:"problems"` // problems this change introduces
Existing []validate.Problem `json:"existing_problems,omitempty"` // Risks are the introduced problems that open the server up; applying
Check []CheckMessage `json:"check,omitempty"` // needs AcceptRisk (docs/exposure.md).
CheckOK bool `json:"check_ok"` Risks []validate.Problem `json:"risks,omitempty"`
Diff string `json:"diff"` Existing []validate.Problem `json:"existing_problems,omitempty"`
Files []string `json:"files_changed"` Check []CheckMessage `json:"check,omitempty"`
Serials []SerialChange `json:"serials,omitempty"` CheckOK bool `json:"check_ok"`
Reloaded []string `json:"reloaded,omitempty"` Diff string `json:"diff"`
Warnings []string `json:"warnings,omitempty"` Files []string `json:"files_changed"`
Drift []DriftEntry `json:"drift,omitempty"` Serials []SerialChange `json:"serials,omitempty"`
Ops []model.Op `json:"ops,omitempty"` Reloaded []string `json:"reloaded,omitempty"`
Error *Error `json:"error,omitempty"` Warnings []string `json:"warnings,omitempty"`
Drift []DriftEntry `json:"drift,omitempty"`
Ops []model.Op `json:"ops,omitempty"`
Error *Error `json:"error,omitempty"`
} }
// stored is what is kept in a changeset's result column. // stored is what is kept in a changeset's result column.
@@ -138,6 +146,9 @@ type ApplyOptions struct {
Force bool Force bool
// NoReload writes the files without reloading named. // NoReload writes the files without reloading named.
NoReload bool NoReload bool
// AcceptRisk confirms a change that opens the server up, such as an
// open resolver on a public view (validate.RiskCodes).
AcceptRisk bool
// wipeZone lets WipeDNSSEC switch signing off for this zone at once. // wipeZone lets WipeDNSSEC switch signing off for this zone at once.
wipeZone int64 wipeZone int64
} }
@@ -180,7 +191,46 @@ func ValidateOptions(st *model.State) validate.Options {
} }
func (e *Engine) validate(st *model.State) []validate.Problem { func (e *Engine) validate(st *model.State) []validate.Problem {
return validate.State(st, ValidateOptions(st)) return validate.State(st, e.validateOptions(st))
}
func (e *Engine) validateOptions(st *model.State) validate.Options {
opts := ValidateOptions(st)
if e.cfg.LocalAddrs != nil {
opts.LocalAddrs = e.cfg.LocalAddrs()
}
return opts
}
// Validate checks a state as applying it would.
func (e *Engine) Validate(st *model.State) []validate.Problem { return e.validate(st) }
// Exposures says which views (or the server, without views) are
// reachable from the internet, and why (docs/exposure.md).
func (e *Engine) Exposures(st *model.State) []validate.Exposure {
return validate.Exposures(st, e.validateOptions(st))
}
// risks returns the introduced problems that open a public view (or a
// public server) up: risky warnings that weren't there before at all. One
// that was there already, acknowledged (taking the acknowledgement back)
// or not, opens nothing new.
func risks(before, introduced []validate.Problem, public map[string]bool) []validate.Problem {
type key struct {
code, objType, view string
id int64
}
had := map[key]bool{}
for _, p := range before {
had[key{p.Code, p.ObjectType, p.View, p.ObjectID}] = true
}
var out []validate.Problem
for _, p := range introduced {
if p.Severity == validate.Warning && validate.RiskCodes[p.Code] && public[p.View] && !had[key{p.Code, p.ObjectType, p.View, p.ObjectID}] {
out = append(out, p)
}
}
return out
} }
// ---- changeset management // ---- changeset management
@@ -389,6 +439,8 @@ type plan struct {
base, work *model.State base, work *model.State
introduced []validate.Problem introduced []validate.Problem
existing []validate.Problem existing []validate.Problem
before []validate.Problem // the problems before the change
public map[string]bool // public views by name ("": the server), after the change
serials []SerialChange serials []SerialChange
out *render.Output out *render.Output
changed []*render.File changed []*render.File
@@ -419,6 +471,13 @@ func (pl *plan) files() []string {
func (e *Engine) makePlan(ctx context.Context, base, work *model.State, user string, opts ApplyOptions) (*plan, error) { func (e *Engine) makePlan(ctx context.Context, base, work *model.State, user string, opts ApplyOptions) (*plan, error) {
pl := &plan{base: base, work: work, verify: map[int64]uint32{}} pl := &plan{base: base, work: work, verify: map[int64]uint32{}}
before := e.validate(base) before := e.validate(base)
pl.before = before
pl.public = map[string]bool{}
for _, x := range e.Exposures(work) {
pl.public[x.View] = x.Public
// Server-wide findings (no view) count when anything is public.
pl.public[""] = pl.public[""] || x.Public
}
after := e.validate(work) after := e.validate(work)
pl.introduced = append(validate.Introduced(before, after), e.dnssecChanges(base, work, opts)...) pl.introduced = append(validate.Introduced(before, after), e.dnssecChanges(base, work, opts)...)
introducedKeys := map[string]bool{} introducedKeys := map[string]bool{}
@@ -501,13 +560,15 @@ 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 whose content changed. New ones come with a change to // Rules zones and gates whose content changed. New ones come with a
// named.conf, and reconfig loads them. // change to named.conf, and reconfig loads them.
for _, z := range pl.out.Policy { for _, z := range pl.out.Policy {
if z.ListID != 0 || !changedPaths[z.File] { if z.ListID != 0 || !changedPaths[z.File] {
continue continue
} }
if slices.Contains(baseOut.Policy, z) { 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) pl.reloadPolicy = append(pl.reloadPolicy, z)
} }
} }
@@ -650,6 +711,7 @@ func (pl *plan) result() *Result {
Files: pl.files(), Files: pl.files(),
Serials: pl.serials, Serials: pl.serials,
Drift: pl.drift, Drift: pl.drift,
Risks: risks(pl.before, pl.introduced, pl.public),
} }
r.Valid = r.Valid && r.CheckOK r.Valid = r.Valid && r.CheckOK
if r.Files == nil { if r.Files == nil {
@@ -779,6 +841,13 @@ func (e *Engine) applyOps(ctx context.Context, cs *model.Changeset, reqs []Reque
if opts.DryRun { if opts.DryRun {
return res, nil return res, nil
} }
if rs := risks(pl.before, pl.introduced, pl.public); len(rs) > 0 && !opts.AcceptRisk {
msg := rs[0].Message
if len(rs) > 1 {
msg += fmt.Sprintf(" (and %d more)", len(rs)-1)
}
return res, &Error{Kind: KindRisk, Message: "this change opens the server up: " + msg + "; apply it again with accept_risk to confirm (jarl: --accept-risk), or acknowledge the finding for good", Problems: rs}
}
if len(pl.drift) > 0 && !opts.Force { if len(pl.drift) > 0 && !opts.Force {
var paths []string var paths []string
for _, d := range pl.drift { for _, d := range pl.drift {
+13 -6
View File
@@ -21,19 +21,26 @@ const (
KindBIND ErrorKind = "bind_failed" // named-checkconf or rndc failed KindBIND ErrorKind = "bind_failed" // named-checkconf or rndc failed
KindDrift ErrorKind = "drift" // files were changed outside Jarl KindDrift ErrorKind = "drift" // files were changed outside Jarl
KindNotAdopted ErrorKind = "not_adopted" // named does not load Jarl's config yet KindNotAdopted ErrorKind = "not_adopted" // named does not load Jarl's config yet
KindRisk ErrorKind = "confirm_risk" // the change opens the server up and needs confirming
) )
// Error is an engine error with details for the caller. // Error is an engine error with details for the caller.
type Error struct { 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
Problems []validate.Problem `json:"problems,omitempty"` // OpSummary describes that operation, so people can find it in the
Check []CheckMessage `json:"check,omitempty"` // changeset (record IDs mean little to them).
Diff string `json:"diff,omitempty"` OpSummary string `json:"op_summary,omitempty"`
Problems []validate.Problem `json:"problems,omitempty"`
Check []CheckMessage `json:"check,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)
} }
+87
View File
@@ -228,6 +228,8 @@ 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 OpExposureAck, OpExposureUnack:
return x.exposureAck(req, req.Op == OpExposureAck)
case OpBlocklistCreate: case OpBlocklistCreate:
return x.blocklistCreate(req) return x.blocklistCreate(req)
case OpBlocklistUpdate, OpBlocklistDelete: case OpBlocklistUpdate, OpBlocklistDelete:
@@ -264,6 +266,30 @@ func (x *expander) expand(req Request) ([]model.Op, error) {
verb = "delete rule: " verb = "delete rule: "
} }
return []model.Op{x.versioned(req, r.Version, verb+ruleLabel(r))}, nil 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)
} }
@@ -1065,3 +1091,64 @@ func disabledZoneEffect(st *model.State, z *model.Zone) string {
} }
return fmt.Sprintf("once zone %s is disabled, %s answers REFUSED for its names", dnsname.Display(z.Name), where) return fmt.Sprintf("once zone %s is disabled, %s answers REFUSED for its names", dnsname.Display(z.Name), where)
} }
// exposureAck acknowledges an exposure finding of a view or the server,
// or takes the acknowledgement back: the view's (or the settings')
// acknowledgements are updated as a whole.
func (x *expander) exposureAck(req Request, ack bool) ([]model.Op, error) {
var in AckInput
if err := decode(req.Data, &in); err != nil {
return nil, err
}
in.Code = strings.TrimSpace(in.Code)
if !validate.Ackable(in.Code) {
return nil, errf(KindBadRequest, "%q is not an exposure finding that can be acknowledged (exposure_... or security_open_...)", in.Code)
}
if ack && strings.TrimSpace(in.Reason) == "" {
return nil, errf(KindBadRequest, "say why %s is accepted (a reason is required)", in.Code)
}
var v *model.View
switch {
case in.ViewID != 0:
v = x.st.Views[in.ViewID]
case in.View != "":
v = x.st.ViewByName(in.View)
}
if (in.ViewID != 0 || in.View != "") && v == nil {
return nil, errf(KindNotFound, "no view %s", firstNonEmptyStr(in.View, fmt.Sprint(in.ViewID)))
}
acks := x.st.Settings.Acknowledged
where := "the server"
if v != nil {
acks, where = v.Acknowledged, "view "+v.Name
}
var out []model.Acknowledgement
for _, a := range acks {
if a.Code != in.Code {
out = append(out, a)
}
}
summary := "acknowledge " + in.Code + " for " + where + ": " + strings.TrimSpace(in.Reason)
if ack {
out = append(out, model.Acknowledgement{Code: in.Code, Reason: strings.TrimSpace(in.Reason), By: x.p.Name(), At: x.now})
} else {
if len(out) == len(acks) {
return nil, errf(KindConflict, "%s isn't acknowledged for %s", in.Code, where)
}
summary = "take back the acknowledgement of " + in.Code + " for " + where
}
if out == nil {
out = []model.Acknowledgement{}
}
if v != nil {
return []model.Op{newOp(OpViewUpdate, v.ID, v.Version, ViewInput{Acknowledged: &out}, summary)}, nil
}
return []model.Op{newOp(OpSettingsUpdate, 0, 0, SettingsInput{Acknowledged: &out}, summary)}, nil
}
func firstNonEmptyStr(a, b string) string {
if a != "" {
return a
}
return b
}
+163
View File
@@ -0,0 +1,163 @@
package engine
import (
"net/netip"
"slices"
"strings"
"jarl/internal/model"
"jarl/internal/namedconf"
)
// ExposureFix returns the requests that fix an exposure finding of a view
// (viewID 0: the server), and what they do, or nil when there is no
// automatic fix: what to allow instead (your secondaries, your hosts)
// is for you to decide. The requests go through a changeset like any
// edit (docs/exposure.md).
func ExposureFix(st *model.State, viewID int64, code string) ([]Request, string) {
v := st.Views[viewID]
scope, scopeID := model.ScopeOptions, int64(0)
if v != nil {
scope, scopeID = model.ScopeView, v.ID
}
set := func(name, value string) Request {
if o := st.FindOption(scope, scopeID, name); o != nil {
return NewRequest(OpOptionUpdate, o.ID, OptionInput{Value: &value})
}
return NewRequest(OpOptionCreate, 0, OptionInput{Scope: &scope, ScopeID: &scopeID, Name: &name, Value: &value})
}
server := func(name, value string) Request {
if o := st.FindOption(model.ScopeOptions, 0, name); o != nil {
return NewRequest(OpOptionUpdate, o.ID, OptionInput{Value: &value})
}
return NewRequest(OpOptionCreate, 0, OptionInput{Scope: model.Ptr(model.ScopeOptions), Name: &name, Value: &value})
}
switch code {
case "exposure_rate_limit":
value := RecommendedRateLimit(st, v)
return []Request{set("rate-limit", value)}, "start rate limiting in log-only mode: rate-limit " + value
case "exposure_rate_limit_log_only":
o := st.FindOption(scope, scopeID, "rate-limit")
if o == nil {
o = st.FindOption(model.ScopeOptions, 0, "rate-limit")
}
if o == nil {
return nil, ""
}
value := withoutLogOnly(o.Value)
return []Request{NewRequest(OpOptionUpdate, o.ID, OptionInput{Value: &value})}, "start enforcing the rate limit: rate-limit " + value
case "exposure_minimal_any":
return []Request{set("minimal-any", "yes")}, "answer ANY queries minimally: minimal-any yes"
case "exposure_cache_snooping":
return []Request{set("allow-query-cache", "{ none; }")}, "close the cache to everyone: allow-query-cache { none; }"
case "exposure_version":
return []Request{server("version", "none")}, "hide BIND's version: version none"
case "exposure_hostname":
return []Request{server("hostname", "none")}, "hide the host name: hostname none"
case "security_open_resolver":
if v == nil {
return nil, "" // a server without views is also your own resolver
}
return []Request{set("recursion", "no")}, "turn recursion off in view " + v.Name + ": recursion no"
}
return nil, ""
}
// RecommendedRateLimit is the rate-limit block Jarl suggests for a public
// view (v nil: the server): ISC's example of 10 identical responses per
// second per client network, in log-only mode until you have looked at
// what it would limit, with your secondaries exempt so zone transfers
// and their queries are never limited.
func RecommendedRateLimit(st *model.State, v *model.View) string {
parts := []string{"responses-per-second 10;", "log-only yes;"}
if ex := secondaries(st, v); len(ex) > 0 {
parts = append(parts, "exempt-clients { "+strings.Join(ex, "; ")+"; };")
}
return "{ " + strings.Join(parts, " ") + " }"
}
// secondaries returns the addresses the view's zones notify and allow
// transfers to (ACLs resolved), the servers that rate limiting must leave
// alone.
func secondaries(st *model.State, v *model.View) []string {
var opts []*model.Option
add := func(scope string, id int64) {
for _, name := range []string{"also-notify", "allow-transfer"} {
if o := st.FindOption(scope, id, name); o != nil {
opts = append(opts, o)
}
}
}
add(model.ScopeOptions, 0)
vid := int64(0)
if v != nil {
vid = v.ID
add(model.ScopeView, v.ID)
}
for _, z := range st.SortedZones() {
if z.ViewID == vid {
add(model.ScopeZone, z.ID)
}
}
seen := map[string]bool{}
var out []string
var addElems func(list []string, depth int)
addElems = func(list []string, depth int) {
for _, s := range list {
e, err := namedconf.ParseAMElement(s)
if err != nil || e.Negated {
continue
}
switch e.Kind {
case namedconf.AMPrefix, namedconf.AMRange:
if t := e.String(); !seen[t] {
seen[t] = true
out = append(out, t)
}
case namedconf.AMName:
if a := st.ACLByName(e.Name); a != nil && depth < 10 {
addElems(a.Elements, depth+1)
}
}
}
}
for _, o := range opts {
stmt, err := namedconf.ParseOptionValue(o.Name, o.Value)
if err != nil || stmt.FirstBlock() == nil {
continue
}
var list []string
for _, s := range stmt.FirstBlock().Stmts {
if o.Name == "also-notify" {
// "ADDRESS [port N] [key K]": the address only.
if len(s.Items) > 0 && s.Items[0].Value != nil {
if _, err := netip.ParseAddr(s.Items[0].Value.Text); err == nil {
list = append(list, s.Items[0].Value.Text)
}
}
continue
}
list = append(list, namedconf.Compact(s.Items))
}
addElems(list, 0)
}
slices.Sort(out)
return out
}
// withoutLogOnly removes "log-only ...;" from a rate-limit block.
func withoutLogOnly(value string) string {
st, err := namedconf.ParseOptionValue("rate-limit", value)
if err != nil || st.FirstBlock() == nil {
return value
}
b := st.FirstBlock()
var keep []*namedconf.Stmt
for _, s := range b.Stmts {
if s.Keyword() != "log-only" {
keep = append(keep, s)
}
}
b.Stmts = keep
return namedconf.Compact(st.Items[1:])
}
+142
View File
@@ -0,0 +1,142 @@
package engine
import (
"net/netip"
"strings"
"testing"
"jarl/internal/model"
"jarl/internal/validate"
)
func problemFor(ps []validate.Problem, code, view string) *validate.Problem {
for i, p := range ps {
if p.Code == code && (view == "" || p.View == view) {
return &ps[i]
}
}
return nil
}
func TestExposure(t *testing.T) {
env := newEnv(t)
setupZone(env)
env.submit(env.admin,
// An internal network with a global IPv6 prefix is still yours.
NewRequest(OpACLCreate, 0, ACLInput{Name: model.Ptr("internal-clients"), Elements: &[]string{"2a02:8013:7f3d:10::/64", "192.168.1.0/24"}}),
optionReq("allow-transfer", "{ 192.0.2.53; }"),
)
env.submit(env.admin,
NewRequest(OpViewCreate, 0, ViewInput{Name: model.Ptr("internal"), MatchClients: &[]string{"internal-clients", "localhost"}, AdoptZones: true}),
NewRequest(OpViewCreate, 0, ViewInput{Name: model.Ptr("external"), MatchClients: &[]string{"!internal-clients", "any"}}),
NewRequest(OpViewCreate, 0, ViewInput{Name: model.Ptr("dmz"), MatchClients: &[]string{"any"}, MatchDestinations: &[]string{"10.9.0.53"}}),
)
st := env.state()
exps := map[string]validate.Exposure{}
for _, x := range env.e.Exposures(st) {
exps[x.View] = x
}
if x := exps["internal"]; x.Public || !strings.Contains(x.Reason, "no catch-all") {
t.Errorf("internal = %+v", x)
}
if x := exps["external"]; !x.Public || x.Reason != "match-clients ends in any" {
t.Errorf("external = %+v", x)
}
if x := exps["dmz"]; x.Public || !strings.Contains(x.Reason, "match-destinations") {
t.Errorf("dmz = %+v", x)
}
ps := env.e.validate(st)
for _, code := range []string{"exposure_rate_limit", "exposure_minimal_any"} {
if problemFor(ps, code, "external") == nil {
t.Errorf("no %s for external", code)
}
if problemFor(ps, code, "internal") != nil || problemFor(ps, code, "dmz") != nil {
t.Errorf("%s for a private view", code)
}
}
for _, code := range []string{"exposure_version", "exposure_hostname"} {
if problemFor(ps, code, "") == nil {
t.Errorf("no %s", code)
}
}
// The rate limit fix starts in log-only mode and exempts the
// secondaries.
ext := st.ViewByName("external")
reqs, desc := ExposureFix(st, ext.ID, "exposure_rate_limit")
if len(reqs) != 1 || !strings.Contains(desc, "{ responses-per-second 10; log-only yes; exempt-clients { 192.0.2.53; }; }") {
t.Fatalf("fix = %v %q", reqs, desc)
}
env.submit(env.admin, reqs...)
ps = env.e.validate(env.state())
if problemFor(ps, "exposure_rate_limit", "external") != nil {
t.Error("rate limit warning stays after the fix")
}
if p := problemFor(ps, "exposure_rate_limit_log_only", "external"); p == nil || p.Severity != validate.Info {
t.Errorf("log-only = %+v", p)
}
reqs, desc = ExposureFix(env.state(), ext.ID, "exposure_rate_limit_log_only")
if !strings.Contains(desc, "{ responses-per-second 10; exempt-clients { 192.0.2.53; }; }") {
t.Errorf("enforce = %q", desc)
}
env.submit(env.admin, reqs...)
if p := problemFor(env.e.validate(env.state()), "exposure_rate_limit_log_only", "external"); p != nil {
t.Errorf("still log-only: %+v", p)
}
// Opening the server up needs confirming.
open := optionReqView("allow-recursion", "{ any; }", ext.ID)
if e := env.submitErr(env.admin, open); e.Kind != KindRisk || !strings.Contains(e.Message, "open resolver") {
t.Fatalf("open resolver without confirming: %+v", e)
}
if _, err := env.e.Submit(env.ctx, []Request{open}, env.admin, "test", "", ApplyOptions{AcceptRisk: true}); err != nil {
t.Fatalf("with accept risk: %v", err)
}
// Acknowledged, it stays as info, with who and why.
env.submit(env.admin, NewRequest(OpExposureAck, 0, AckInput{View: "external", Code: "security_open_resolver", Reason: "public resolver for the club"}))
p := problemFor(env.e.validate(env.state()), "security_open_resolver", "external")
if p == nil || p.Severity != validate.Info || p.Acknowledged == nil || !strings.Contains(p.Message, "acknowledged by admin on 7 Oct 2026: public resolver for the club") {
t.Errorf("acknowledged = %+v", p)
}
if e := env.submitErr(env.admin, NewRequest(OpExposureAck, 0, AckInput{View: "external", Code: "security_open_resolver"})); e.Kind != KindBadRequest {
t.Errorf("ack without a reason: %+v", e)
}
// Reverting the acknowledgement brings the warning back.
env.submit(env.admin, NewRequest(OpExposureUnack, 0, AckInput{View: "external", Code: "security_open_resolver"}))
if p := problemFor(env.e.validate(env.state()), "security_open_resolver", "external"); p == nil || p.Severity != validate.Warning {
t.Errorf("after unack = %+v", p)
}
// Marking a view private takes it out of the checks.
env.submit(env.admin, NewRequest(OpViewUpdate, ext.ID, ViewInput{Exposure: model.Ptr("private")}))
if p := problemFor(env.e.validate(env.state()), "security_open_resolver", "external"); p != nil {
t.Errorf("private view still checked: %+v", p)
}
if e := env.submitErr(env.admin, NewRequest(OpViewUpdate, ext.ID, ViewInput{Exposure: model.Ptr("internet")})); e.Kind != KindBadRequest {
t.Errorf("bad exposure: %+v", e)
}
}
// A server without views is public only when named listens on an
// address that isn't private.
func TestServerExposure(t *testing.T) {
env := newEnv(t)
setupZone(env)
if x := env.e.Exposures(env.state())[0]; x.Public {
t.Errorf("no addresses known = %+v", x)
}
env.e.cfg.LocalAddrs = func() []netip.Addr {
return []netip.Addr{netip.MustParseAddr("192.168.1.2"), netip.MustParseAddr("2a02:8013:7f3d:10::53")}
}
if x := env.e.Exposures(env.state())[0]; !x.Public || !strings.Contains(x.Reason, "2a02:8013:7f3d:10::53") {
t.Errorf("GUA address = %+v", x)
}
env.submit(env.admin, NewRequest(OpSettingsUpdate, 0, SettingsInput{Exposure: model.Ptr("private")}))
if x := env.e.Exposures(env.state())[0]; x.Public || x.Override != "private" {
t.Errorf("marked private = %+v", x)
}
}
func optionReqView(name, value string, viewID int64) Request {
return NewRequest(OpOptionCreate, 0, OptionInput{Scope: model.Ptr(model.ScopeView), ScopeID: &viewID, Name: &name, Value: &value})
}
+53 -1
View File
@@ -39,18 +39,39 @@ const (
OpBlockRuleDelete = "blockrule.delete" OpBlockRuleDelete = "blockrule.delete"
OpBlockingPause = "blocking.pause" 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.
OpRecordDisable = "record.disable" OpRecordDisable = "record.disable"
OpRecordEnable = "record.enable" OpRecordEnable = "record.enable"
// Acknowledging an exposure finding (docs/exposure.md), and taking it
// back, become view.update or settings.update operations.
OpExposureAck = "exposure.ack"
OpExposureUnack = "exposure.unack"
) )
// AckInput acknowledges (or un-acknowledges) an exposure finding of a view
// (by ID or name), or of the server (neither given).
type AckInput struct {
ViewID int64 `json:"view_id,omitempty"`
View string `json:"view,omitempty"`
Code string `json:"code"`
Reason string `json:"reason,omitempty"`
}
// 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} 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"
@@ -176,6 +197,9 @@ type ViewInput struct {
AdoptZones bool `json:"adopt_zones,omitempty"` AdoptZones bool `json:"adopt_zones,omitempty"`
// Cascade (delete) also deletes the view's zones. // Cascade (delete) also deletes the view's zones.
Cascade bool `json:"cascade,omitempty"` Cascade bool `json:"cascade,omitempty"`
// Exposure is "auto" (or ""), "public" or "private" (docs/exposure.md).
Exposure *string `json:"exposure,omitempty"`
Acknowledged *[]model.Acknowledgement `json:"acknowledged,omitempty"`
} }
// ReorderInput sets the order of views. // ReorderInput sets the order of views.
@@ -219,6 +243,9 @@ type SettingsInput struct {
Expire *uint32 `json:"expire,omitempty"` Expire *uint32 `json:"expire,omitempty"`
NegativeTTL *uint32 `json:"negative_ttl,omitempty"` NegativeTTL *uint32 `json:"negative_ttl,omitempty"`
SerialPolicy *string `json:"serial_policy,omitempty"` SerialPolicy *string `json:"serial_policy,omitempty"`
// Exposure and Acknowledged are the server's (docs/exposure.md).
Exposure *string `json:"exposure,omitempty"`
Acknowledged *[]model.Acknowledgement `json:"acknowledged,omitempty"`
} }
// DeleteInput carries options for delete operations. // DeleteInput carries options for delete operations.
@@ -236,6 +263,31 @@ type BlocklistInput struct {
ViewIDs *[]int64 `json:"view_ids,omitempty"` ViewIDs *[]int64 `json:"view_ids,omitempty"`
Disabled *bool `json:"disabled,omitempty"` Disabled *bool `json:"disabled,omitempty"`
Comment *string `json:"comment,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 // BlockRuleInput creates or updates a block or allow rule. Views as for
+92 -3
View File
@@ -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"
@@ -106,13 +107,21 @@ func applyOp(st *model.State, op model.Op, c *applyCtx) error {
err = applyBlockRulePut(st, op, c) err = applyBlockRulePut(st, op, c)
case OpBlockRuleDelete: case OpBlockRuleDelete:
err = applyBlockRuleDelete(st, op, c) err = applyBlockRuleDelete(st, op, c)
case 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: case OpBlockingPause:
err = applyBlockingPause(st, op, c) 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
} }
@@ -449,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
@@ -525,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
@@ -753,9 +765,41 @@ func setViewFields(v *model.View, in *ViewInput) error {
if in.Position != nil { if in.Position != nil {
v.Position = *in.Position v.Position = *in.Position
} }
if in.Exposure != nil {
e, err := normalizeExposure(*in.Exposure)
if err != nil {
return err
}
v.Exposure = e
}
if in.Acknowledged != nil {
v.Acknowledged = cleanAcks(*in.Acknowledged)
}
return nil return nil
} }
// normalizeExposure checks an exposure override.
func normalizeExposure(s string) (string, error) {
switch e := strings.ToLower(strings.TrimSpace(s)); e {
case "", "auto":
return model.ExposureAuto, nil
case model.ExposurePublic, model.ExposurePrivate:
return e, nil
}
return "", errf(KindBadRequest, "exposure must be auto, public or private, not %q", s)
}
// cleanAcks drops empty acknowledgements (nil for none).
func cleanAcks(acks []model.Acknowledgement) []model.Acknowledgement {
var out []model.Acknowledgement
for _, a := range acks {
if a.Code != "" {
out = append(out, a)
}
}
return out
}
func applyViewCreate(st *model.State, op model.Op, c *applyCtx) error { func applyViewCreate(st *model.State, op model.Op, c *applyCtx) error {
var in ViewInput var in ViewInput
if err := decode(op.Data, &in); err != nil { if err := decode(op.Data, &in); err != nil {
@@ -1133,9 +1177,44 @@ func NormalizeOption(st *model.State, scope string, scopeID int64, name, value s
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 {
@@ -1282,6 +1361,16 @@ func applySettings(st *model.State, op model.Op, c *applyCtx) error {
if in.SerialPolicy != nil { if in.SerialPolicy != nil {
s.SerialPolicy = strings.ToLower(*in.SerialPolicy) s.SerialPolicy = strings.ToLower(*in.SerialPolicy)
} }
if in.Exposure != nil {
e, err := normalizeExposure(*in.Exposure)
if err != nil {
return err
}
s.Exposure = e
}
if in.Acknowledged != nil {
s.Acknowledged = cleanAcks(*in.Acknowledged)
}
s.Version++ s.Version++
return nil return nil
} }
+113
View File
@@ -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)
}
}
}
+11
View File
@@ -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 {
+51
View File
@@ -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)
}
}
+148
View File
@@ -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)}
}
+123
View File
@@ -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
}
+148 -1
View File
@@ -1,10 +1,14 @@
package model package model
import ( import (
"fmt"
"net/netip"
"slices" "slices"
"sort" "sort"
"strings" "strings"
"time" "time"
"jarl/internal/iprange"
) )
// Blocklist is a list of domains to block, downloaded from URL and kept up // Blocklist is a list of domains to block, downloaded from URL and kept up
@@ -17,10 +21,126 @@ type Blocklist struct {
// that offers recursion (see BlockingViews). // that offers recursion (see BlockingViews).
ViewIDs []int64 `json:"view_ids"` ViewIDs []int64 `json:"view_ids"`
Disabled bool `json:"disabled,omitempty"` Disabled bool `json:"disabled,omitempty"`
Comment string `json:"comment"` // 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 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. // Block rule actions.
const ( const (
BlockActionBlock = "block" BlockActionBlock = "block"
@@ -134,3 +254,30 @@ func (s *State) BlockingViews(viewIDs []int64) []int64 {
} }
return out 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
}
+70 -13
View File
@@ -9,6 +9,7 @@ package model
import ( import (
"encoding/json" "encoding/json"
"slices"
"sort" "sort"
"strings" "strings"
"time" "time"
@@ -49,9 +50,43 @@ type View struct {
MatchDestinations []string `json:"match_destinations"` MatchDestinations []string `json:"match_destinations"`
MatchRecursiveOnly *bool `json:"match_recursive_only,omitempty"` MatchRecursiveOnly *bool `json:"match_recursive_only,omitempty"`
Comment string `json:"comment"` Comment string `json:"comment"`
// Exposure overrides whether the view counts as reachable from the
// internet (docs/exposure.md): "" works it out, ExposurePublic or
// ExposurePrivate say so.
Exposure string `json:"exposure,omitempty"`
// Acknowledged are exposure findings accepted on purpose; they are
// shown as info instead of warnings.
Acknowledged []Acknowledgement `json:"acknowledged,omitempty"`
Meta Meta
} }
// Exposure overrides.
const (
ExposureAuto = ""
ExposurePublic = "public"
ExposurePrivate = "private"
)
// Acknowledgement accepts one exposure finding (by its code) for a view,
// or for the server: the finding stays, as info, with who accepted it,
// when and why.
type Acknowledgement struct {
Code string `json:"code"`
Reason string `json:"reason"`
By string `json:"by"`
At time.Time `json:"at"`
}
// FindAck returns the acknowledgement of code in acks, if any.
func FindAck(acks []Acknowledgement, code string) *Acknowledgement {
for i := range acks {
if acks[i].Code == code {
return &acks[i]
}
}
return nil
}
// ACL is a named address match list. // ACL is a named address match list.
type ACL struct { type ACL struct {
ID int64 `json:"id"` ID int64 `json:"id"`
@@ -216,7 +251,11 @@ type Settings struct {
BlockingPausedUntil *time.Time `json:"blocking_paused_until,omitempty"` BlockingPausedUntil *time.Time `json:"blocking_paused_until,omitempty"`
// BlockingPausedIndefinitely pauses blocking until someone resumes it. // BlockingPausedIndefinitely pauses blocking until someone resumes it.
BlockingPausedIndefinitely bool `json:"blocking_paused_indefinitely,omitempty"` BlockingPausedIndefinitely bool `json:"blocking_paused_indefinitely,omitempty"`
Version int `json:"version"` // Exposure and Acknowledged are the server's own (without views, and
// for server-wide findings such as a visible version): see View.
Exposure string `json:"exposure,omitempty"`
Acknowledged []Acknowledgement `json:"acknowledged,omitempty"`
Version int `json:"version"`
} }
// BlockingPaused reports whether blocking is paused, for a time or until // BlockingPaused reports whether blocking is paused, for a time or until
@@ -247,8 +286,10 @@ type State struct {
Records map[int64]*Record Records map[int64]*Record
Options map[int64]*Option Options map[int64]*Option
// Blocking (docs/blocking.md). // Blocking (docs/blocking.md).
Blocklists map[int64]*Blocklist Blocklists map[int64]*Blocklist
BlockRules map[int64]*BlockRule 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.
@@ -262,8 +303,10 @@ func NewState() *State {
Records: map[int64]*Record{}, Records: map[int64]*Record{},
Options: map[int64]*Option{}, Options: map[int64]*Option{},
Blocklists: map[int64]*Blocklist{}, Blocklists: map[int64]*Blocklist{},
BlockRules: map[int64]*BlockRule{}, BlockRules: map[int64]*BlockRule{},
BlockSteps: map[int64]*BlockStep{},
ClientGroups: map[int64]*ClientGroup{},
} }
} }
@@ -278,14 +321,18 @@ func (s *State) Clone() *State {
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)), Blocklists: make(map[int64]*Blocklist, len(s.Blocklists)),
BlockRules: make(map[int64]*BlockRule, len(s.BlockRules)), 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)
c.Settings.Acknowledged = slices.Clone(s.Settings.Acknowledged)
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)
n.Acknowledged = slices.Clone(v.Acknowledged)
if v.MatchRecursiveOnly != nil { if v.MatchRecursiveOnly != nil {
b := *v.MatchRecursiveOnly b := *v.MatchRecursiveOnly
n.MatchRecursiveOnly = &b n.MatchRecursiveOnly = &b
@@ -294,7 +341,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 {
@@ -319,14 +366,24 @@ func (s *State) Clone() *State {
} }
for id, b := range s.Blocklists { for id, b := range s.Blocklists {
n := *b n := *b
n.ViewIDs = append([]int64(nil), b.ViewIDs...) n.ViewIDs = slices.Clone(b.ViewIDs)
c.Blocklists[id] = &n c.Blocklists[id] = &n
} }
for id, r := range s.BlockRules { for id, r := range s.BlockRules {
n := *r n := *r
n.ViewIDs = append([]int64(nil), r.ViewIDs...) n.ViewIDs = slices.Clone(r.ViewIDs)
c.BlockRules[id] = &n 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
} }
+141 -8
View File
@@ -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.
@@ -26,10 +31,11 @@ var BuiltinACLs = map[string]bool{"any": true, "none": true, "localhost": true,
type AMElement struct { type AMElement struct {
Negated bool Negated bool
Kind AMKind Kind AMKind
Prefix netip.Prefix // AMPrefix Prefix netip.Prefix // AMPrefix
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).
+3
View File
@@ -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
+6
View File
@@ -77,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 {
@@ -167,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("; ")
} }
+90
View File
@@ -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
}
}
+79 -7
View File
@@ -2,6 +2,7 @@ package render
import ( import (
"fmt" "fmt"
"net/netip"
"path/filepath" "path/filepath"
"slices" "slices"
"strings" "strings"
@@ -21,9 +22,16 @@ type PolicyZone struct {
ViewID int64 // 0 without views ViewID int64 // 0 without views
View string // "" without views View string // "" without views
File string File string
ListID int64 // the blocklist; 0 for the rules zone 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 // BlocklistsDir holds the zone files of downloaded blocklists, one per
// list, shared by the views that use it. // list, shared by the views that use it.
func (p Paths) BlocklistsDir() string { return filepath.Join(p.CacheDir, "blocklists") } func (p Paths) BlocklistsDir() string { return filepath.Join(p.CacheDir, "blocklists") }
@@ -41,7 +49,9 @@ func (p Paths) rulesFile(view string) string {
// PolicyZones returns the policy zones named loads, per view in view // 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 // order, each view's in the order named checks them: the rules zone first
// (rules win over lists), then the enabled lists by name. // (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 { func PolicyZones(s *model.State, p Paths) []PolicyZone {
var out []PolicyZone var out []PolicyZone
rules, lists := s.SortedBlockRules(), s.SortedBlocklists() rules, lists := s.SortedBlockRules(), s.SortedBlocklists()
@@ -65,10 +75,26 @@ func PolicyZones(s *model.State, p Paths) []PolicyZone {
break break
} }
} }
for _, b := range lists { assigns := ClientAssignments(s, vid)
if !b.Disabled && slices.Contains(s.BlockingViews(b.ViewIDs), vid) { for k, step := range append([]int64{model.StepNormal}, stepIDs(s)...) {
zones = append(zones, PolicyZone{Name: blocking.ListZone(b.Name), ViewID: vid, View: view, File: p.BlocklistFile(b.ID), ListID: b.ID}) 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 // While paused, every view with blocking gets a rules zone: it
// lets everything through. // lets everything through.
@@ -80,6 +106,39 @@ func PolicyZones(s *model.State, p Paths) []PolicyZone {
return out 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). // ViewRules returns the rules that apply in a view (0 without views).
func ViewRules(s *model.State, viewID int64) []blocking.Rule { func ViewRules(s *model.State, viewID int64) []blocking.Rule {
var out []blocking.Rule var out []blocking.Rule
@@ -106,7 +165,9 @@ func writeResponsePolicy(w *confWriter, zones []PolicyZone, logRewrites bool) {
// changed (at most every blocking.refresh), so named's default // changed (at most every blocking.refresh), so named's default
// delay for batching frequent updates only adds a wait. // delay for batching frequent updates only adds a wait.
opts := []string{"min-update-interval 0"} opts := []string{"min-update-interval 0"}
if !logRewrites { // 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") opts = append(opts, "log no")
} }
w.write(fmt.Sprintf("\t\tzone %s%s;\n", namedconf.QuoteString(z.Name), prefixSpace(strings.Join(opts, " ")))) w.write(fmt.Sprintf("\t\tzone %s%s;\n", namedconf.QuoteString(z.Name), prefixSpace(strings.Join(opts, " "))))
@@ -132,8 +193,11 @@ func writePolicyZones(w *confWriter, s *model.State, zones []PolicyZone, out *Ou
for _, z := range zones { for _, z := range zones {
w.write("\n") w.write("\n")
label := "blocking: your rules" label := "blocking: your rules"
if z.ListID != 0 { switch {
case z.ListID != 0:
label = "blocking: blocklist " + s.Blocklists[z.ListID].Name 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.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", 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",
@@ -143,6 +207,14 @@ func writePolicyZones(w *confWriter, s *model.State, zones []PolicyZone, out *Ou
out.Blocklists = appendUnique(out.Blocklists, z.File) out.Blocklists = appendUnique(out.Blocklists, z.File)
continue 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" title := "Blocking rules"
if z.View != "" { if z.View != "" {
title += " for view " + z.View title += " for view " + z.View
+18 -5
View File
@@ -229,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 {
@@ -272,6 +272,12 @@ func renderConfig(s *model.State, p Paths, x Extras, out *Output) (string, []Spa
pzs := PolicyZones(s, p) pzs := PolicyZones(s, p)
out.Policy = pzs out.Policy = pzs
logRewrites := logResponses && len(pzs) > 0 logRewrites := logResponses && len(pzs) > 0
// Rate limiting logs what it limits (or would, in log-only mode) as
// query-errors; the statistics count it (docs/exposure.md).
logRRL := false
for _, o := range s.Options {
logRRL = logRRL || logResponses && o.Name == "rate-limit"
}
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")
@@ -282,7 +288,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, logRewrites)) w.write(st.loggingStatement(o, logRewrites, logRRL))
sawLogging = true sawLogging = true
default: default:
w.write(optionText(o, 0)) w.write(optionText(o, 0))
@@ -297,7 +303,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, logRewrites)) w.write(st.loggingStatement(nil, logRewrites, logRRL))
} }
w.end() w.end()
} }
@@ -313,7 +319,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()
} }
@@ -382,7 +388,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 {
@@ -507,3 +513,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)
}
+17 -7
View File
@@ -62,19 +62,20 @@ 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 (and rpz, for blocking's rewrites), merged into // the responses category (rpz too, for blocking's rewrites, and
// the user's own if there is one. // query-errors for rate limiting), merged into the user's own if there is
func (h *StatsHooks) loggingStatement(user *model.Option, rpz bool) string { // one.
func (h *StatsHooks) loggingStatement(user *model.Option, rpz, rrl bool) string {
var st *namedconf.Stmt var st *namedconf.Stmt
if user != nil { if user != nil {
var err error var err error
@@ -92,6 +93,9 @@ func (h *StatsHooks) loggingStatement(user *model.Option, rpz bool) string {
if rpz { if rpz {
categories = append(categories, "rpz") categories = append(categories, "rpz")
} }
if rrl {
categories = append(categories, "query-errors")
}
for _, cat := range categories { for _, cat := range categories {
merged := false merged := false
for _, s := range b.Stmts { for _, s := range b.Stmts {
@@ -101,14 +105,20 @@ func (h *StatsHooks) loggingStatement(user *model.Option, rpz bool) string {
} }
} }
if !merged { if !merged {
b.Stmts = append(b.Stmts, namedconf.NewStmt("category", cat, namedconf.NewBlock(namedconf.NewStmt(StatsChannelName)))) channels := namedconf.NewBlock(namedconf.NewStmt(StatsChannelName))
if cat == "query-errors" {
// Naming the category takes it away from named's
// default destination: keep that too.
channels.Stmts = append(channels.Stmts, namedconf.NewStmt("default_syslog"))
}
b.Stmts = append(b.Stmts, namedconf.NewStmt("category", cat, channels))
} }
} }
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 {
+68
View File
@@ -0,0 +1,68 @@
package server
import (
"encoding/json"
"strings"
"testing"
)
func TestExposureAPI(t *testing.T) {
e := newAPIEnv(t)
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", "/views", e.token, map[string]any{"name": "internal", "match_clients": []string{"192.168.1.0/24", "localhost"}, "adopt_zones": true}, 201)
ext := e.do("POST", "/views", e.token, map[string]any{"name": "external", "match_clients": []string{"any"}}, 201)["object"].(map[string]any)["id"]
type report struct {
Scopes []struct {
View string `json:"view"`
Reason string `json:"reason"`
RateLimit string `json:"rate_limit"`
Public bool `json:"public"`
Findings []struct {
Code, Severity, Fix string
}
}
Server []struct{ Code, Fix string }
}
get := func() report {
b, status := e.req("GET", "/exposure", e.token, nil)
if status != 200 {
t.Fatalf("exposure = %d %s", status, b)
}
var r report
json.Unmarshal(b, &r)
return r
}
r := get()
if len(r.Scopes) != 2 || r.Scopes[0].View != "internal" || r.Scopes[0].Public || !r.Scopes[1].Public {
t.Fatalf("scopes = %+v", r.Scopes)
}
var codes []string
for _, f := range r.Scopes[1].Findings {
codes = append(codes, f.Code)
if f.Code == "exposure_rate_limit" && !strings.Contains(f.Fix, "log-only yes") {
t.Errorf("rate limit fix = %q", f.Fix)
}
}
if !strings.Contains(strings.Join(codes, " "), "exposure_rate_limit") || len(r.Server) < 2 {
t.Errorf("findings = %v, server = %+v", codes, r.Server)
}
e.do("POST", "/exposure/fix", e.token, map[string]any{"view": "external", "code": "exposure_rate_limit"}, 201)
if r := get(); r.Scopes[1].RateLimit != "log-only" {
t.Errorf("rate limit = %q", r.Scopes[1].RateLimit)
}
if _, status := e.req("POST", "/exposure/fix", e.token, map[string]any{"view": "external", "code": "exposure_open_notify"}); status != 400 {
t.Errorf("fix without an automatic fix = %d", status)
}
// Opening a public view up needs confirming.
b, status := e.req("POST", "/options", e.token, map[string]any{"scope": "view", "scope_id": ext, "name": "allow-recursion", "value": "any"})
if status != 409 || !strings.Contains(string(b), "confirm_risk") {
t.Errorf("open resolver = %d %s", status, b)
}
e.do("POST", "/exposure/ack", e.token, map[string]any{"view": "external", "code": "exposure_minimal_any", "reason": "testing"}, 201)
for _, f := range get().Scopes[1].Findings {
if f.Code == "exposure_minimal_any" && f.Severity != "info" {
t.Errorf("acknowledged finding = %+v", f)
}
}
}
+2 -2
View File
@@ -33,11 +33,11 @@ func viewNames(st *model.State, ids []int64) []string {
func presentBlocklist(st *model.State, b *model.Blocklist, status *model.BlocklistStatus) api.Blocklist { func presentBlocklist(st *model.State, b *model.Blocklist, status *model.BlocklistStatus) api.Blocklist {
views := st.BlockingViews(b.ViewIDs) 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} 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 { switch {
case b.Disabled: case b.Disabled:
out.State = "disabled" out.State = "disabled"
case len(views) == 0: case len(views) == 0 || !stepReached(st, b, views):
out.State = "idle" out.State = "idle"
case status != nil && status.Error != "": case status != nil && status.Error != "":
out.State = "error" out.State = "error"
+9 -4
View File
@@ -61,6 +61,9 @@ func (s *Server) applyBatch(w http.ResponseWriter, r *http.Request, p *auth.Prin
if in.Force { if in.Force {
q.Set("force", "1") q.Set("force", "1")
} }
if in.AcceptRisk {
q.Set("accept_risk", "1")
}
r.URL.RawQuery = q.Encode() r.URL.RawQuery = q.Encode()
return s.mutate(w, r, p, in.Description, toRequests(in.Ops), nil) return s.mutate(w, r, p, in.Description, toRequests(in.Ops), nil)
} }
@@ -106,7 +109,7 @@ func (s *Server) createChangeset(w http.ResponseWriter, r *http.Request, p *auth
} }
cs = res.Changeset cs = res.Changeset
if in.Apply { if in.Apply {
out, err := s.eng.Apply(r.Context(), cs.ID, p, engine.ApplyOptions{DryRun: in.DryRun, Force: in.Force}) out, err := s.eng.Apply(r.Context(), cs.ID, p, engine.ApplyOptions{DryRun: in.DryRun, Force: in.Force, AcceptRisk: in.AcceptRisk})
if err != nil { if err != nil {
s.writeErr(w, r, err, out) s.writeErr(w, r, err, out)
return nil return nil
@@ -214,8 +217,9 @@ func (s *Server) applyChangeset(w http.ResponseWriter, r *http.Request, p *auth.
return err return err
} }
var in struct { var in struct {
DryRun bool `json:"dry_run"` DryRun bool `json:"dry_run"`
Force bool `json:"force"` Force bool `json:"force"`
AcceptRisk bool `json:"accept_risk"`
} }
if body, err := readBody(r); err != nil { if body, err := readBody(r); err != nil {
return err return err
@@ -224,7 +228,8 @@ func (s *Server) applyChangeset(w http.ResponseWriter, r *http.Request, p *auth.
return badRequest("invalid JSON: %v", err) return badRequest("invalid JSON: %v", err)
} }
} }
res, err := s.eng.Apply(r.Context(), id, p, engine.ApplyOptions{DryRun: in.DryRun || queryBool(r, "dry_run"), Force: in.Force || queryBool(r, "force")}) res, err := s.eng.Apply(r.Context(), id, p, engine.ApplyOptions{DryRun: in.DryRun || queryBool(r, "dry_run"), Force: in.Force || queryBool(r, "force"),
AcceptRisk: in.AcceptRisk || queryBool(r, "accept_risk")})
if err != nil { if err != nil {
s.writeErr(w, r, err, res) s.writeErr(w, r, err, res)
return nil return nil
+265
View File
@@ -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
}
+37
View File
@@ -13,6 +13,7 @@ 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/render"
"jarl/internal/rr" "jarl/internal/rr"
@@ -554,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 {
+146
View File
@@ -0,0 +1,146 @@
package server
import (
"net/http"
"time"
"jarl/internal/auth"
"jarl/internal/engine"
"jarl/internal/model"
"jarl/internal/stats"
"jarl/internal/validate"
)
// exposureFinding is an exposure problem with the fix Jarl can stage for
// it ("" when there is none).
type exposureFinding struct {
validate.Problem
Fix string `json:"fix,omitempty"`
}
type exposureScope struct {
validate.Exposure
RateLimit string `json:"rate_limit"` // off, log-only or enforcing
Findings []exposureFinding `json:"findings"`
}
type exposureReport struct {
Scopes []exposureScope `json:"scopes"`
// Server holds server-wide findings (version, host name, management
// interfaces).
Server []exposureFinding `json:"server"`
// RateLimiting is what rate limiting limited, or would have, in the
// last 24 hours (with query statistics on).
RateLimiting *rateLimitActivity `json:"rate_limiting,omitempty"`
}
type rateLimitActivity struct {
Since string `json:"since"`
Counts []stats.Entry `json:"counts"` // drop, slip, would-drop, would-slip
Networks []stats.Entry `json:"networks"` // client networks, most limited first
Names []stats.Entry `json:"names"` // "name TYPE"
}
// exposure: GET /exposure reports which views are reachable from the
// internet and what the exposure checks found (docs/exposure.md).
func (s *Server) exposure(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
if err := require(p, auth.Action{Resource: auth.ResOptions}); err != nil {
return err
}
st, err := s.store.LoadState(r.Context())
if err != nil {
return err
}
problems := s.eng.Validate(st)
out := exposureReport{Scopes: []exposureScope{}, Server: []exposureFinding{}}
finding := func(pr validate.Problem, viewID int64) exposureFinding {
_, desc := engine.ExposureFix(st, viewID, pr.Code)
return exposureFinding{Problem: pr, Fix: desc}
}
scopes := map[string]int{}
for _, x := range s.eng.Exposures(st) {
sc := exposureScope{Exposure: x, RateLimit: "off", Findings: []exposureFinding{}}
v := st.Views[x.ViewID]
o := st.FindOption(model.ScopeOptions, 0, "rate-limit")
if v != nil {
if vo := st.FindOption(model.ScopeView, v.ID, "rate-limit"); vo != nil {
o = vo
}
}
if o != nil {
sc.RateLimit = "enforcing"
if validate.LogOnly(o) {
sc.RateLimit = "log-only"
}
}
scopes[x.View] = len(out.Scopes)
out.Scopes = append(out.Scopes, sc)
}
for _, pr := range problems {
if !validate.Ackable(pr.Code) {
continue
}
if pr.ObjectType == "view" || pr.ObjectType == "zone" {
if i, ok := scopes[pr.View]; ok {
out.Scopes[i].Findings = append(out.Scopes[i].Findings, finding(pr, out.Scopes[i].ViewID))
continue
}
}
out.Server = append(out.Server, finding(pr, 0))
}
if s.statsQ != nil && p.Can(auth.Action{Resource: auth.ResStats}) {
ctx, since := r.Context(), 24*time.Hour
a := &rateLimitActivity{Since: "24h"}
a.Counts, _ = s.statsQ.Breakdown(ctx, "rrl", since)
a.Networks, _ = s.statsQ.Top(ctx, stats.KindRateLimited, since, 10)
a.Names, _ = s.statsQ.Top(ctx, stats.KindRateLimitedName, since, 10)
out.RateLimiting = a
}
writeJSON(w, http.StatusOK, out)
return nil
}
// exposureFix: POST /exposure/fix {"view_id": 3, "code": "..."} stages (or
// applies) the fix for a finding, like any other change.
func (s *Server) exposureFix(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
var in struct {
ViewID int64 `json:"view_id"`
View string `json:"view"`
Code string `json:"code"`
}
if err := decodeJSON(r, &in); err != nil {
return err
}
st, err := s.store.LoadState(r.Context())
if err != nil {
return err
}
if in.View != "" {
v := st.ViewByName(in.View)
if v == nil {
return notFound("no view %s", in.View)
}
in.ViewID = v.ID
}
reqs, desc := engine.ExposureFix(st, in.ViewID, in.Code)
if reqs == nil {
return badRequest("there is no automatic fix for %s: what to allow is yours to decide; change the option yourself, or acknowledge the finding", in.Code)
}
return s.mutate(w, r, p, desc, reqs, nil)
}
// exposureAck: POST /exposure/ack {"view": "external", "code": "...",
// "reason": "..."} accepts a finding (unack takes it back).
func (s *Server) exposureAck(ack bool) handler {
return func(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
var in engine.AckInput
if err := decodeJSON(r, &in); err != nil {
return err
}
op := engine.OpExposureUnack
if ack {
op = engine.OpExposureAck
}
return s.mutate(w, r, p, "", []engine.Request{engine.NewRequest(op, 0, in)}, nil)
}
}
+475
View File
@@ -0,0 +1,475 @@
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)."},
"rrl": {"jarl_ratelimit_logged_total", "action", "Rate limited responses from named's log: drop and slip, or would-drop and would-slip in log-only mode."},
"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
}
+206
View File
@@ -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)
}
}
+31
View File
@@ -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])
}
}
+1 -1
View File
@@ -62,7 +62,7 @@ func engineStatus(k engine.ErrorKind) int {
return http.StatusForbidden return http.StatusForbidden
case engine.KindNotFound: case engine.KindNotFound:
return http.StatusNotFound return http.StatusNotFound
case engine.KindConflict, engine.KindDrift, engine.KindNotAdopted: case engine.KindConflict, engine.KindDrift, engine.KindNotAdopted, engine.KindRisk:
return http.StatusConflict return http.StatusConflict
case engine.KindValidation: case engine.KindValidation:
return http.StatusUnprocessableEntity return http.StatusUnprocessableEntity
+16 -1
View File
@@ -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))
@@ -69,6 +70,15 @@ func (s *Server) routes() {
m.HandleFunc("DELETE "+p+"/blocking/rules", s.authed(s.deleteBlockRulesByName)) m.HandleFunc("DELETE "+p+"/blocking/rules", s.authed(s.deleteBlockRulesByName))
m.HandleFunc("PATCH "+p+"/blocking/rules/{id}", s.authed(s.updateBlockRule)) m.HandleFunc("PATCH "+p+"/blocking/rules/{id}", s.authed(s.updateBlockRule))
m.HandleFunc("DELETE "+p+"/blocking/rules/{id}", s.authed(s.deleteBlockRule)) m.HandleFunc("DELETE "+p+"/blocking/rules/{id}", s.authed(s.deleteBlockRule))
m.HandleFunc("GET "+p+"/blocking/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/check", s.authed(s.checkBlocking))
m.HandleFunc("GET "+p+"/blocking/status", s.authed(s.blockingStatus)) 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/pause", s.authed(s.pauseBlocking(false)))
@@ -126,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))
@@ -151,6 +162,10 @@ func (s *Server) routes() {
m.HandleFunc("POST "+p+"/acme/httpreq/view/{view}/present", s.httpreq(true)) m.HandleFunc("POST "+p+"/acme/httpreq/view/{view}/present", s.httpreq(true))
m.HandleFunc("POST "+p+"/acme/httpreq/view/{view}/cleanup", s.httpreq(false)) m.HandleFunc("POST "+p+"/acme/httpreq/view/{view}/cleanup", s.httpreq(false))
// Query statistics // Query statistics
m.HandleFunc("GET "+p+"/exposure", s.authed(s.exposure))
m.HandleFunc("POST "+p+"/exposure/fix", s.authed(s.exposureFix))
m.HandleFunc("POST "+p+"/exposure/ack", s.authed(s.exposureAck(true)))
m.HandleFunc("POST "+p+"/exposure/unack", s.authed(s.exposureAck(false)))
m.HandleFunc("GET "+p+"/stats/status", s.authed(s.statsStatus)) m.HandleFunc("GET "+p+"/stats/status", s.authed(s.statsStatus))
m.HandleFunc("GET "+p+"/stats/summary", s.authed(s.statsSummary)) m.HandleFunc("GET "+p+"/stats/summary", s.authed(s.statsSummary))
m.HandleFunc("GET "+p+"/stats/top", s.authed(s.statsTop)) m.HandleFunc("GET "+p+"/stats/top", s.authed(s.statsTop))
@@ -241,7 +256,7 @@ func (s *Server) mutate(w http.ResponseWriter, r *http.Request, p *auth.Principa
writeJSON(w, http.StatusAccepted, api.MutationResponse{Staged: true, ChangesetID: csID, Changeset: raw(res.Changeset), Added: engine.RedactOps(res.Added), Warnings: res.Warnings, Problems: raw(res.Problems)}) writeJSON(w, http.StatusAccepted, api.MutationResponse{Staged: true, ChangesetID: csID, Changeset: raw(res.Changeset), Added: engine.RedactOps(res.Added), Warnings: res.Warnings, Problems: raw(res.Problems)})
return nil return nil
} }
opts := engine.ApplyOptions{DryRun: queryBool(r, "dry_run"), Force: queryBool(r, "force")} opts := engine.ApplyOptions{DryRun: queryBool(r, "dry_run"), Force: queryBool(r, "force"), AcceptRisk: queryBool(r, "accept_risk")}
res, err := s.eng.Submit(r.Context(), reqs, p, via(r, p), description, opts) res, err := s.eng.Submit(r.Context(), reqs, p, via(r, p), description, opts)
if err != nil { if err != nil {
s.writeErr(w, r, err, res) s.writeErr(w, r, err, res)
+11 -1
View File
@@ -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 {
+53 -2
View File
@@ -26,10 +26,15 @@ const (
KindBlocked = "blocked" // names blocked (docs/blocking.md) KindBlocked = "blocked" // names blocked (docs/blocking.md)
// KindBlockedClient: clients with the most blocked queries. // KindBlockedClient: clients with the most blocked queries.
KindBlockedClient = "blocked_client" KindBlockedClient = "blocked_client"
// KindRateLimited: client networks whose responses rate limiting
// limited (or would have, in log-only mode); KindRateLimitedName: the
// names and types they asked for (docs/exposure.md).
KindRateLimited = "ratelimited"
KindRateLimitedName = "ratelimited_name"
) )
// Kinds lists the top-list kinds. // Kinds lists the top-list kinds.
var Kinds = []string{KindName, KindClient, KindNXDomain, KindServFail, KindRefused, KindBlocked, KindBlockedClient} var Kinds = []string{KindName, KindClient, KindNXDomain, KindServFail, KindRefused, KindBlocked, KindBlockedClient, KindRateLimited, KindRateLimitedName}
// 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).
@@ -114,6 +119,9 @@ type Collector struct {
// statistics (server.go). // statistics (server.go).
proc *namedProc proc *namedProc
server *Server 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.
@@ -125,7 +133,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}
} }
@@ -286,6 +294,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.
@@ -317,6 +342,8 @@ func (c *Collector) handleLine(line string) {
if !ok { if !ok {
if rw, ok := ParseRewrite(line, c.opts.Location); ok { if rw, ok := ParseRewrite(line, c.opts.Location); ok {
c.handleRewrite(rw) c.handleRewrite(rw)
} else if rl, ok := ParseRateLimit(line, c.opts.Location); ok {
c.handleRateLimit(rl)
} }
return return
} }
@@ -396,6 +423,30 @@ func (c *Collector) handleRewrite(rw Rewrite) {
hours[hour][KindBlockedClient][clientKey(rw.Client, c.opts.Anonymize)]++ hours[hour][KindBlockedClient][clientKey(rw.Client, c.opts.Anonymize)]++
} }
// handleRateLimit counts a response rate limiting limited, or would have:
// "rrl:drop", "rrl:slip", "rrl:would-drop" or "rrl:would-slip", and the
// client network and name in the hour's top lists.
func (c *Collector) handleRateLimit(rl RateLimit) {
for _, p := range c.opts.Exclude {
if p.Contains(rl.Client) {
return
}
}
key := "rrl:" + rl.Action
if rl.Would {
key = "rrl:would-" + rl.Action
}
minute, hour := c.minuteOf(rl.Time), c.hourOf(rl.Time)
c.count(minute, key, 1)
hours := c.hours
if hour < c.liveHour {
hours = c.late
}
hours[hour] = c.hourMaps(hours, hour)
hours[hour][KindRateLimited][rl.Prefix]++
hours[hour][KindRateLimitedName][rl.Name+" "+rl.Type]++
}
// policySource names what a rewrite came from: a blocklist's name, "rules" // policySource names what a rewrite came from: a blocklist's name, "rules"
// for the rules entered in Jarl, or the policy zone for others. // for the rules entered in Jarl, or the policy zone for others.
func policySource(via string) string { func policySource(via string) string {
+41 -3
View File
@@ -33,17 +33,55 @@ type Rewrite struct {
// //
// 07-Oct-2026 19:46:22.195 client @0x7135... 10.10.0.37#51709 (nas.adbraun.lol): view internal: response: nas.adbraun.lol IN A NOERROR 1 0 1 +E(0)K (10.10.0.2) // 07-Oct-2026 19:46:22.195 client @0x7135... 10.10.0.37#51709 (nas.adbraun.lol): view internal: response: nas.adbraun.lol IN A NOERROR 1 0 1 +E(0)K (10.10.0.2)
// //
// The "view NAME: " part is missing when no views are configured. // The "view NAME: " part is missing when no views are configured, and a
var responseRe = regexp.MustCompile(`^(\d{2}-[A-Za-z]{3}-\d{4} \d{2}:\d{2}:\d{2}\.\d{3}) client @0x[0-9a-fA-F]+ (\S+)#(\d+) \(.*?\): (?:view (.+?): )?response: (\S+) (\S+) (\S+) ([A-Z0-9]+) `) // query signed with a TSIG key has "/key NAME" after the port.
var responseRe = regexp.MustCompile(`^(\d{2}-[A-Za-z]{3}-\d{4} \d{2}:\d{2}:\d{2}\.\d{3}) client @0x[0-9a-fA-F]+ (\S+)#(\d+)(?:/key \S+)? \(.*?\): (?:view (.+?): )?response: (\S+) (\S+) (\S+) ([A-Z0-9]+) `)
// rewriteRe matches named's RPZ rewrite lines (category rpz), logged just // rewriteRe matches named's RPZ rewrite lines (category rpz), logged just
// before the response line of the same query: // before the response line of the same query:
// //
// 09-Oct-2026 23:49:13.698 client @0x7135... 10.10.0.37#54468 (ads.example): view internal: rpz QNAME NXDOMAIN rewrite ads.example/A/IN via ads.example.hagezi.blocklist.jarl.invalid // 09-Oct-2026 23:49:13.698 client @0x7135... 10.10.0.37#54468 (ads.example): view internal: rpz QNAME NXDOMAIN rewrite ads.example/A/IN via ads.example.hagezi.blocklist.jarl.invalid
var rewriteRe = regexp.MustCompile(`^(\d{2}-[A-Za-z]{3}-\d{4} \d{2}:\d{2}:\d{2}\.\d{3}) client @0x[0-9a-fA-F]+ (\S+)#(\d+) \((.*?)\): (?:view (.+?): )?rpz \S+ (\S+) rewrite \S+ via (\S+)`) var rewriteRe = regexp.MustCompile(`^(\d{2}-[A-Za-z]{3}-\d{4} \d{2}:\d{2}:\d{2}\.\d{3}) client @0x[0-9a-fA-F]+ (\S+)#(\d+)(?:/key \S+)? \((.*?)\): (?:view (.+?): )?rpz \S+ (\S+) rewrite \S+ via (\S+)`)
// rateLimitRe matches named's response rate limiting lines (category
// query-errors), when it limits a response or, in log-only mode, would:
//
// 12-Oct-2026 09:56:35.996 client @0x70a2... 127.0.0.2#59296 (www.rrl.test): would rate limit slip response to 127.0.0.0/24 for www.rrl.test IN A (0ce833a4)
var rateLimitRe = regexp.MustCompile(`^(\d{2}-[A-Za-z]{3}-\d{4} \d{2}:\d{2}:\d{2}\.\d{3}) client @0x[0-9a-fA-F]+ (\S+)#(\d+)(?:/key \S+)? \(.*?\): (?:view (.+?): )?(would )?rate limit (drop|slip) (?:\S+ )?response to (\S+) for (\S+) \S+ (\S+)`)
// RateLimit is a response that rate limiting dropped or slipped (sent
// truncated, so a real client retries over TCP), or would have in
// log-only mode.
type RateLimit struct {
Time time.Time
Client netip.Addr
View string
Would bool // log-only: nothing was limited
Action string // drop or slip
Prefix string // the client network named counts, e.g. 192.0.2.0/24
Name string // the query name, normalized like Event.Name
Type string
}
const logTimeLayout = "02-Jan-2006 15:04:05.000" const logTimeLayout = "02-Jan-2006 15:04:05.000"
// ParseRateLimit parses a rate limiting log line.
func ParseRateLimit(line string, loc *time.Location) (rl RateLimit, ok bool) {
m := rateLimitRe.FindStringSubmatch(line)
if m == nil {
return rl, false
}
t, err := time.ParseInLocation(logTimeLayout, m[1], loc)
if err != nil {
return rl, false
}
client, err := netip.ParseAddr(m[2])
if err != nil {
return rl, false
}
return RateLimit{Time: t, Client: client.Unmap(), View: m[4], Would: m[5] != "", Action: m[6], Prefix: m[7], Name: normalizeName(m[8]), Type: m[9]}, true
}
// ParseResponse parses a response log line. ok is false for other lines // ParseResponse parses a response log line. ok is false for other lines
// (named also logs to the channel when it starts and rotates files). // (named also logs to the channel when it starts and rotates files).
func ParseResponse(line string, loc *time.Location) (ev Event, ok bool) { func ParseResponse(line string, loc *time.Location) (ev Event, ok bool) {
+42
View File
@@ -1,6 +1,7 @@
package stats package stats
import ( import (
"net/netip"
"testing" "testing"
"time" "time"
) )
@@ -42,3 +43,44 @@ func TestParseResponse(t *testing.T) {
} }
} }
} }
func TestParseRateLimit(t *testing.T) {
loc := time.UTC
for _, tc := range []struct {
line string
want RateLimit
views bool
}{
{line: "12-Oct-2026 09:56:35.996 client @0x70a247adf000 127.0.0.2#59296 (www.rrl.test): would rate limit slip response to 127.0.0.0/24 for www.rrl.test IN A (0ce833a4)",
want: RateLimit{Would: true, Action: "slip", Prefix: "127.0.0.0/24", Name: "www.rrl.test", Type: "A"}},
{line: "12-Oct-2026 09:56:38.303 client @0x7eee390df000 127.0.0.2#35687 (www.rrl.test): rate limit drop response to 127.0.0.0/24 for www.rrl.test IN A (0ce833a4)",
want: RateLimit{Action: "drop", Prefix: "127.0.0.0/24", Name: "www.rrl.test", Type: "A"}},
{line: "12-Oct-2026 09:56:38.303 client @0x7eee390df000 2001:db8::7#35687 (Adbraun.lol): view external: rate limit drop response to 2001:db8::/56 for adbraun.lol IN ANY (0ce833a4)",
want: RateLimit{View: "external", Action: "drop", Prefix: "2001:db8::/56", Name: "adbraun.lol", Type: "ANY"}},
} {
rl, ok := ParseRateLimit(tc.line, loc)
rl.Time, rl.Client = time.Time{}, netip.Addr{}
if !ok || rl != tc.want {
t.Errorf("%s:\n got %+v %v\nwant %+v", tc.line, rl, ok, tc.want)
}
}
if _, ok := ParseRateLimit("12-Oct-2026 09:56:35.996 rate-limit: info: would limit responses to 127.0.0.0/24 for www.rrl.test IN A (0ce833a4)", loc); ok {
t.Error("the start-of-limiting line counted as a response")
}
}
// Signed queries have "/key NAME" after the client's port.
func TestParseSigned(t *testing.T) {
loc := time.UTC
ev, ok := ParseResponse("12-Oct-2026 10:11:06.490 client @0x7a22f754b000 127.0.0.1#34745/key jarl-view-external (ns1.adbraun.test): view external: response: ns1.adbraun.test IN A NOERROR 1 0 2 +E(0)SK (127.0.0.1)", loc)
if !ok || ev.View != "external" || ev.Name != "ns1.adbraun.test" || ev.Port != "34745" {
t.Errorf("signed response = %+v %v", ev, ok)
}
rl, ok := ParseRateLimit("12-Oct-2026 10:11:06.599 client @0x7a22f754b000 127.0.0.1#34856/key jarl-view-external (ns1.adbraun.test): view external: would rate limit slip response to 127.0.0.0/24 for ns1.adbraun.test IN A (e777e288)", loc)
if !ok || rl.View != "external" || !rl.Would || rl.Action != "slip" || rl.Prefix != "127.0.0.0/24" {
t.Errorf("signed rate limit = %+v %v", rl, ok)
}
if _, ok := ParseRewrite("09-Oct-2026 23:49:13.089 client @0x7bd607320000 127.0.0.1#56905/key k1 (x.ads.example): view internal: rpz QNAME NXDOMAIN rewrite x.ads.example/A/IN via x.ads.example.rules.jarl.invalid", loc); !ok {
t.Error("signed rewrite not parsed")
}
}
+1 -1
View File
@@ -256,7 +256,7 @@ func (q *Querier) Breakdown(ctx context.Context, group string, since time.Durati
} }
// Groups are the counter groups Breakdown accepts. // Groups are the counter groups Breakdown accepts.
var Groups = []string{"qtype", "rcode", "view", "zone", "answer", "transport", "rtt", "dnssec", "cache", "ratelimit", "blocklist"} var Groups = []string{"qtype", "rcode", "view", "zone", "answer", "transport", "rtt", "dnssec", "cache", "ratelimit", "rrl", "blocklist"}
// Timeline is a set of series over time. // Timeline is a set of series over time.
type Timeline struct { type Timeline struct {
+3 -1
View File
@@ -14,7 +14,8 @@ type Server struct {
PID int `json:"pid,omitempty"` PID int `json:"pid,omitempty"`
RSS int64 `json:"rss,omitempty"` // resident memory, bytes RSS int64 `json:"rss,omitempty"` // resident memory, bytes
Threads int64 `json:"threads,omitempty"` Threads int64 `json:"threads,omitempty"`
CPUPercent float64 `json:"cpu_percent"` // of one CPU, over the last minute CPUPercent float64 `json:"cpu_percent"` // of one CPU, over the last minute
CPUSeconds float64 `json:"cpu_seconds,omitempty"` // user + system time since named started
// From the statistics channel. // From the statistics channel.
MemInUse int64 `json:"mem_in_use"` // named's own count of the memory it uses MemInUse int64 `json:"mem_in_use"` // named's own count of the memory it uses
CacheRRsets int64 `json:"cache_rrsets"` CacheRRsets int64 `json:"cache_rrsets"`
@@ -63,6 +64,7 @@ func (c *Collector) sampleServer(minute int64, now time.Time, snap *Snapshot) {
c.proc = nil c.proc = nil
} else { } else {
srv.PID, srv.RSS, srv.Threads = p.PID, p.RSS, p.Threads srv.PID, srv.RSS, srv.Threads = p.PID, p.RSS, p.Threads
srv.CPUSeconds = float64(p.CPU) / clockTicks
if prev := c.proc; prev.At.Before(now) && !prev.At.IsZero() && p.CPU >= prev.CPU { if prev := c.proc; prev.At.Before(now) && !prev.At.IsZero() && p.CPU >= prev.CPU {
// Tenths of a percent of one CPU: ticks / clockTicks is CPU seconds. // Tenths of a percent of one CPU: ticks / clockTicks is CPU seconds.
permille := (p.CPU - prev.CPU) * 1000 / clockTicks / max(int64(now.Sub(prev.At)/time.Second), 1) permille := (p.CPU - prev.CPU) * 1000 / clockTicks / max(int64(now.Sub(prev.At)/time.Second), 1)
+74 -12
View File
@@ -31,20 +31,23 @@ func loadState(ctx context.Context, e execer) (*model.State, error) {
} }
} }
rows, err := e.QueryContext(ctx, `SELECT id, name, position, match_clients, match_destinations, match_recursive_only, comment, version, created_at, updated_at, created_by, updated_by FROM views`) rows, err := e.QueryContext(ctx, `SELECT id, name, position, match_clients, match_destinations, match_recursive_only, comment, exposure, acknowledged, version, created_at, updated_at, created_by, updated_by FROM views`)
if err != nil { if err != nil {
return nil, err return nil, err
} }
for rows.Next() { for rows.Next() {
v := &model.View{} v := &model.View{}
var mc, md, ca, ua string var mc, md, acks, ca, ua string
var mro sql.NullInt64 var mro sql.NullInt64
if err := rows.Scan(&v.ID, &v.Name, &v.Position, &mc, &md, &mro, &v.Comment, &v.Version, &ca, &ua, &v.CreatedBy, &v.UpdatedBy); err != nil { if err := rows.Scan(&v.ID, &v.Name, &v.Position, &mc, &md, &mro, &v.Comment, &v.Exposure, &acks, &v.Version, &ca, &ua, &v.CreatedBy, &v.UpdatedBy); err != nil {
rows.Close() rows.Close()
return nil, err return nil, err
} }
json.Unmarshal([]byte(mc), &v.MatchClients) json.Unmarshal([]byte(mc), &v.MatchClients)
json.Unmarshal([]byte(md), &v.MatchDestinations) json.Unmarshal([]byte(md), &v.MatchDestinations)
if acks != "[]" && acks != "" {
json.Unmarshal([]byte(acks), &v.Acknowledged)
}
if mro.Valid { if mro.Valid {
v.MatchRecursiveOnly = model.Ptr(mro.Int64 != 0) v.MatchRecursiveOnly = model.Ptr(mro.Int64 != 0)
} }
@@ -150,14 +153,14 @@ func loadState(ctx context.Context, e execer) (*model.State, error) {
return nil, err return nil, err
} }
rows, err = e.QueryContext(ctx, `SELECT id, name, url, view_ids, disabled, comment, version, created_at, updated_at, created_by, updated_by FROM blocklists`) rows, err = e.QueryContext(ctx, `SELECT id, name, url, view_ids, disabled, step_id, comment, version, created_at, updated_at, created_by, updated_by FROM blocklists`)
if err != nil { if err != nil {
return nil, err return nil, err
} }
for rows.Next() { for rows.Next() {
b := &model.Blocklist{} b := &model.Blocklist{}
var views, ca, ua string var views, ca, ua string
if err := rows.Scan(&b.ID, &b.Name, &b.URL, &views, &b.Disabled, &b.Comment, &b.Version, &ca, &ua, &b.CreatedBy, &b.UpdatedBy); err != nil { if err := rows.Scan(&b.ID, &b.Name, &b.URL, &views, &b.Disabled, &b.StepID, &b.Comment, &b.Version, &ca, &ua, &b.CreatedBy, &b.UpdatedBy); err != nil {
rows.Close() rows.Close()
return nil, err return nil, err
} }
@@ -187,6 +190,44 @@ func loadState(ctx context.Context, e execer) (*model.State, error) {
if err := closeRows(rows); err != nil { if err := closeRows(rows); err != nil {
return nil, err return nil, err
} }
rows, err = e.QueryContext(ctx, `SELECT id, name, position, comment, version, created_at, updated_at, created_by, updated_by FROM block_steps`)
if err != nil {
return nil, err
}
for rows.Next() {
s := &model.BlockStep{}
var ca, ua string
if err := rows.Scan(&s.ID, &s.Name, &s.Position, &s.Comment, &s.Version, &ca, &ua, &s.CreatedBy, &s.UpdatedBy); err != nil {
rows.Close()
return nil, err
}
s.CreatedAt, s.UpdatedAt = parseTS(ca), parseTS(ua)
st.BlockSteps[s.ID] = s
}
if err := closeRows(rows); err != nil {
return nil, err
}
rows, err = e.QueryContext(ctx, `SELECT id, name, members, step_id, view_ids, comment, version, created_at, updated_at, created_by, updated_by FROM client_groups`)
if err != nil {
return nil, err
}
for rows.Next() {
g := &model.ClientGroup{}
var members, views, ca, ua string
if err := rows.Scan(&g.ID, &g.Name, &members, &g.StepID, &views, &g.Comment, &g.Version, &ca, &ua, &g.CreatedBy, &g.UpdatedBy); err != nil {
rows.Close()
return nil, err
}
json.Unmarshal([]byte(members), &g.Members)
json.Unmarshal([]byte(views), &g.ViewIDs)
g.CreatedAt, g.UpdatedAt = parseTS(ca), parseTS(ua)
st.ClientGroups[g.ID] = g
}
if err := closeRows(rows); err != nil {
return nil, err
}
return st, nil return st, nil
} }
@@ -204,7 +245,7 @@ func (t *Tx) SaveState(old, new *model.State) error {
// ReplaceState replaces the stored configuration with st (used by import // ReplaceState replaces the stored configuration with st (used by import
// and tests). // and tests).
func (t *Tx) ReplaceState(st *model.State) error { func (t *Tx) ReplaceState(st *model.State) error {
for _, tbl := range []string{"views", "acls", "tsig_keys", "zones", "records", "options", "blocklists", "block_rules"} { for _, tbl := range []string{"views", "acls", "tsig_keys", "zones", "records", "options", "blocklists", "block_rules", "block_steps", "client_groups"} {
if _, err := t.tx.ExecContext(t.ctx, "DELETE FROM "+tbl); err != nil { if _, err := t.tx.ExecContext(t.ctx, "DELETE FROM "+tbl); err != nil {
return err return err
} }
@@ -226,8 +267,8 @@ func saveState(ctx context.Context, e execer, old, new *model.State) error {
if v.MatchRecursiveOnly != nil { if v.MatchRecursiveOnly != nil {
mro = boolInt(*v.MatchRecursiveOnly) mro = boolInt(*v.MatchRecursiveOnly)
} }
_, err := e.ExecContext(ctx, `INSERT OR REPLACE INTO views (id, name, position, match_clients, match_destinations, match_recursive_only, comment, version, created_at, updated_at, created_by, updated_by) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)`, _, err := e.ExecContext(ctx, `INSERT OR REPLACE INTO views (id, name, position, match_clients, match_destinations, match_recursive_only, comment, exposure, acknowledged, version, created_at, updated_at, created_by, updated_by) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
v.ID, v.Name, v.Position, jsonText(nonNil(v.MatchClients)), jsonText(nonNil(v.MatchDestinations)), mro, v.Comment, v.Version, ts(v.CreatedAt), ts(v.UpdatedAt), v.CreatedBy, v.UpdatedBy) v.ID, v.Name, v.Position, jsonText(nonNil(v.MatchClients)), jsonText(nonNil(v.MatchDestinations)), mro, v.Comment, v.Exposure, jsonText(nonNilAcks(v.Acknowledged)), v.Version, ts(v.CreatedAt), ts(v.UpdatedAt), v.CreatedBy, v.UpdatedBy)
return err return err
}, func(id int64) error { return del(ctx, e, "views", id) }); err != nil { }, func(id int64) error { return del(ctx, e, "views", id) }); err != nil {
return err return err
@@ -272,17 +313,31 @@ func saveState(ctx context.Context, e execer, old, new *model.State) error {
return err return err
} }
if err := diffMap(old.Blocklists, new.Blocklists, func(b *model.Blocklist) error { if err := diffMap(old.Blocklists, new.Blocklists, func(b *model.Blocklist) error {
_, err := e.ExecContext(ctx, `INSERT OR REPLACE INTO blocklists (id, name, url, view_ids, disabled, comment, version, created_at, updated_at, created_by, updated_by) VALUES (?,?,?,?,?,?,?,?,?,?,?)`, _, err := e.ExecContext(ctx, `INSERT OR REPLACE INTO blocklists (id, name, url, view_ids, disabled, step_id, comment, version, created_at, updated_at, created_by, updated_by) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)`,
b.ID, b.Name, b.URL, jsonText(nonNilIDs(b.ViewIDs)), b.Disabled, b.Comment, b.Version, ts(b.CreatedAt), ts(b.UpdatedAt), b.CreatedBy, b.UpdatedBy) b.ID, b.Name, b.URL, jsonText(nonNilIDs(b.ViewIDs)), b.Disabled, b.StepID, b.Comment, b.Version, ts(b.CreatedAt), ts(b.UpdatedAt), b.CreatedBy, b.UpdatedBy)
return err return err
}, func(id int64) error { return del(ctx, e, "blocklists", id) }); err != nil { }, func(id int64) error { return del(ctx, e, "blocklists", id) }); err != nil {
return err return err
} }
return diffMap(old.BlockRules, new.BlockRules, func(r *model.BlockRule) error { if err := diffMap(old.BlockRules, new.BlockRules, func(r *model.BlockRule) error {
_, err := e.ExecContext(ctx, `INSERT OR REPLACE INTO block_rules (id, name, action, exact, view_ids, comment, version, created_at, updated_at, created_by, updated_by) VALUES (?,?,?,?,?,?,?,?,?,?,?)`, _, err := e.ExecContext(ctx, `INSERT OR REPLACE INTO block_rules (id, name, action, exact, view_ids, comment, version, created_at, updated_at, created_by, updated_by) VALUES (?,?,?,?,?,?,?,?,?,?,?)`,
r.ID, r.Name, r.Action, r.Exact, jsonText(nonNilIDs(r.ViewIDs)), r.Comment, r.Version, ts(r.CreatedAt), ts(r.UpdatedAt), r.CreatedBy, r.UpdatedBy) r.ID, r.Name, r.Action, r.Exact, jsonText(nonNilIDs(r.ViewIDs)), r.Comment, r.Version, ts(r.CreatedAt), ts(r.UpdatedAt), r.CreatedBy, r.UpdatedBy)
return err return err
}, func(id int64) error { return del(ctx, e, "block_rules", id) }) }, func(id int64) error { return del(ctx, e, "block_rules", id) }); err != nil {
return err
}
if err := diffMap(old.BlockSteps, new.BlockSteps, func(s *model.BlockStep) error {
_, err := e.ExecContext(ctx, `INSERT OR REPLACE INTO block_steps (id, name, position, comment, version, created_at, updated_at, created_by, updated_by) VALUES (?,?,?,?,?,?,?,?,?)`,
s.ID, s.Name, s.Position, s.Comment, s.Version, ts(s.CreatedAt), ts(s.UpdatedAt), s.CreatedBy, s.UpdatedBy)
return err
}, func(id int64) error { return del(ctx, e, "block_steps", id) }); err != nil {
return err
}
return diffMap(old.ClientGroups, new.ClientGroups, func(g *model.ClientGroup) error {
_, err := e.ExecContext(ctx, `INSERT OR REPLACE INTO client_groups (id, name, members, step_id, view_ids, comment, version, created_at, updated_at, created_by, updated_by) VALUES (?,?,?,?,?,?,?,?,?,?,?)`,
g.ID, g.Name, jsonText(nonNil(g.Members)), g.StepID, jsonText(nonNilIDs(g.ViewIDs)), g.Comment, g.Version, ts(g.CreatedAt), ts(g.UpdatedAt), g.CreatedBy, g.UpdatedBy)
return err
}, func(id int64) error { return del(ctx, e, "client_groups", id) })
} }
func nonNilIDs(ids []int64) []int64 { func nonNilIDs(ids []int64) []int64 {
@@ -292,6 +347,13 @@ func nonNilIDs(ids []int64) []int64 {
return ids return ids
} }
func nonNilAcks(a []model.Acknowledgement) []model.Acknowledgement {
if a == nil {
return []model.Acknowledgement{}
}
return a
}
func nonNil(s []string) []string { func nonNil(s []string) []string {
if s == nil { if s == nil {
return []string{} return []string{}
+28
View File
@@ -304,6 +304,34 @@ CREATE TABLE blocklist_status (
// 7: disabled zones, kept but not loaded by named. // 7: disabled zones, kept but not loaded by named.
` `
ALTER TABLE zones ADD COLUMN disabled INTEGER NOT NULL DEFAULT 0; ALTER TABLE zones ADD COLUMN disabled INTEGER NOT NULL DEFAULT 0;
`,
// 8: blocking steps and client groups (docs/blocking.md).
`
ALTER TABLE blocklists ADD COLUMN step_id INTEGER NOT NULL DEFAULT 0;
CREATE TABLE block_steps (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
position INTEGER NOT NULL DEFAULT 0,
comment TEXT NOT NULL DEFAULT '',
version INTEGER NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL,
created_by TEXT NOT NULL DEFAULT '', updated_by TEXT NOT NULL DEFAULT ''
);
CREATE TABLE client_groups (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
members TEXT NOT NULL DEFAULT '[]',
step_id INTEGER NOT NULL DEFAULT 0,
view_ids TEXT NOT NULL DEFAULT '[]',
comment TEXT NOT NULL DEFAULT '',
version INTEGER NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL,
created_by TEXT NOT NULL DEFAULT '', updated_by TEXT NOT NULL DEFAULT ''
);
`,
// 9: internet exposure (docs/exposure.md): a view's override and its
// acknowledged findings.
`
ALTER TABLE views ADD COLUMN exposure TEXT NOT NULL DEFAULT '';
ALTER TABLE views ADD COLUMN acknowledged TEXT NOT NULL DEFAULT '[]';
`, `,
} }
+98
View File
@@ -1,9 +1,12 @@
package validate package validate
import ( import (
"net/netip"
"strings" "strings"
"jarl/internal/iprange"
"jarl/internal/model" "jarl/internal/model"
"jarl/internal/namedconf"
) )
// checkBlocking checks blocklists and block/allow rules (docs/blocking.md). // checkBlocking checks blocklists and block/allow rules (docs/blocking.md).
@@ -67,6 +70,7 @@ func (c *checker) checkBlocking() {
seen[key] = r seen[key] = r
} }
} }
c.checkClientGroups(effective)
// Jarl writes the response-policy statement where blocking is used; // Jarl writes the response-policy statement where blocking is used;
// named allows one per view (or for the server). // named allows one per view (or for the server).
for vid := range used { for vid := range used {
@@ -91,3 +95,97 @@ func (c *checker) blockingScope(viewID int64) string {
} }
return "" return ""
} }
// checkClientGroups checks blocking steps and client groups: steps exist,
// members are addresses (directly or through ACLs), and no address is in
// two groups of the same view.
func (c *checker) checkClientGroups(effective func(objType string, id int64, label string, ids []int64) []int64) {
for _, b := range c.s.SortedBlocklists() {
if _, ok := c.s.StepRank(b.StepID); !ok || b.StepID == model.StepOff {
c.errf("blocking_step_missing", "blocklist", b.ID, "blocklist %s is at step %d, which doesn't exist", b.Name, b.StepID)
}
}
// The highest step any group reaches, per view: lists above it block
// nothing.
top := map[int64]int{}
seen := map[string]*model.ClientGroup{} // view|prefix -> first group
for _, g := range c.s.SortedClientGroups() {
label := "client group " + g.Name
rank, ok := c.s.StepRank(g.StepID)
if !ok {
c.errf("blocking_step_missing", "clientgroup", g.ID, "%s is at step %d, which doesn't exist", label, g.StepID)
}
if len(g.Members) == 0 {
c.warnf("client_group_empty", "clientgroup", g.ID, "%s has no members", label)
}
for _, m := range g.Members {
c.checkMember(g, m, map[string]bool{})
}
views := effective("clientgroup", g.ID, label, g.ViewIDs)
prefixes := c.s.GroupPrefixes(g)
for _, vid := range views {
if len(prefixes) > 0 {
top[vid] = max(top[vid], rank)
}
for _, p := range prefixes {
key := c.blockingScope(vid) + "|" + p.String()
if first := seen[key]; first != nil && first.ID != g.ID {
c.errf("client_group_overlap", "clientgroup", g.ID, "%s is in client groups %s and %s; a client can only be in one (a more specific prefix in another group is fine)", p, first.Name, g.Name)
continue
}
seen[key] = g
}
}
}
for _, b := range c.s.SortedBlocklists() {
rank, ok := c.s.StepRank(b.StepID)
if !ok || rank == 0 || b.Disabled {
continue
}
reached := false
for _, vid := range c.s.BlockingViews(b.ViewIDs) {
if top[vid] >= rank {
reached = true
}
}
if !reached {
c.warnf("blocklist_step_unused", "blocklist", b.ID, "blocklist %s blocks nothing: it is at step %s, and no client group is at that step or above", b.Name, c.s.StepName(b.StepID))
}
}
}
// checkMember checks one member of a client group: an address, a prefix,
// or an ACL whose elements are (recursively) addresses and prefixes.
func (c *checker) checkMember(g *model.ClientGroup, m string, seen map[string]bool) {
if _, err := netip.ParseAddr(m); err == nil {
return
}
if _, err := netip.ParsePrefix(m); err == nil {
return
}
if _, ok, err := iprange.Parse(m); ok && err == nil {
return
}
acl := c.s.ACLByName(m)
if acl == nil {
c.errf("client_group_member_invalid", "clientgroup", g.ID, "client group %s: %q is not an address, a prefix or an ACL", g.Name, m)
return
}
if seen[acl.Name] {
return
}
seen[acl.Name] = true
for _, e := range acl.Elements {
el, err := namedconf.ParseAMElement(e)
if err != nil {
continue // reported for the ACL
}
if el.Negated || el.Kind == namedconf.AMKey || el.Kind == namedconf.AMNested || el.Kind == namedconf.AMName && el.IsBuiltin() {
c.errf("client_group_member_invalid", "clientgroup", g.ID, "client group %s uses ACL %s, whose element %q isn't an address or a prefix: blocking matches clients by address only", g.Name, acl.Name, e)
continue
}
if el.Kind == namedconf.AMName {
c.checkMember(g, el.Name, seen)
}
}
}
+25 -2
View File
@@ -126,6 +126,7 @@ func (c *checker) checkACLs() {
c.warnf("acl_empty", "acl", a.ID, "%s is empty and matches nothing", label) c.warnf("acl_empty", "acl", a.ID, "%s is empty and matches nothing", label)
} }
c.checkRefs(elems, label, "acl", a.ID) c.checkRefs(elems, label, "acl", a.ID)
c.checkRanges(elems, label, "acl", a.ID)
for _, w := range namedconf.Shadowed(elems) { for _, w := range namedconf.Shadowed(elems) {
c.warnf("acl_shadowed", "acl", a.ID, "%s: %s", label, w) c.warnf("acl_shadowed", "acl", a.ID, "%s: %s", label, w)
} }
@@ -179,6 +180,7 @@ func (c *checker) checkViews() {
c.errf("view_acl_invalid", "view", v.ID, "%s: %s element %q: %v", label, field, s, err) c.errf("view_acl_invalid", "view", v.ID, "%s: %s element %q: %v", label, field, s, err)
}) })
c.checkRefs(elems, label+" "+field, "view", v.ID) c.checkRefs(elems, label+" "+field, "view", v.ID)
c.checkRanges(elems, label+" "+field, "view", v.ID)
for _, w := range namedconf.Shadowed(elems) { for _, w := range namedconf.Shadowed(elems) {
c.warnf("acl_shadowed", "view", v.ID, "%s %s: %s", label, field, w) c.warnf("acl_shadowed", "view", v.ID, "%s %s: %s", label, field, w)
} }
@@ -237,11 +239,24 @@ func (c *checker) flatten(list []string, depth int) []namedconf.AMElement {
continue continue
} }
} }
out = append(out, e) out = append(out, e.Expand()...)
} }
return out return out
} }
// checkRanges warns about IPv6 ranges in the IPv4-mapped space, which may
// match IPv4 clients too.
func (c *checker) checkRanges(elems []namedconf.AMElement, where, objType string, id int64) {
for _, e := range elems {
if e.Kind == namedconf.AMRange && e.Range.TouchesMapped4() {
c.warnf("range_mapped4", objType, id, "%s: range %s reaches into ::ffff:0:0/96, where named can see IPv4 clients as IPv6 addresses, so it may match IPv4 clients too; for IPv4 clients, write an IPv4 range", where, e.Range)
}
if e.Kind == namedconf.AMNested {
c.checkRanges(e.Nested, where, objType, id)
}
}
}
// optionScope maps an option to its catalog scope. // optionScope maps an option to its catalog scope.
func (c *checker) optionScope(o *model.Option) (string, string, bool) { func (c *checker) optionScope(o *model.Option) (string, string, bool) {
switch o.Scope { switch o.Scope {
@@ -548,10 +563,15 @@ func (c *checker) checkSecurity() {
for _, v := range views { for _, v := range views {
ctxs = append(ctxs, ctx{v, "view " + v.Name}) ctxs = append(ctxs, ctx{v, "view " + v.Name})
} }
exposure := map[int64]Exposure{}
for _, x := range c.exposures() {
exposure[x.ViewID] = x
}
for _, x := range ctxs { for _, x := range ctxs {
// A view only reachable from some clients (e.g. "internal") can // A view only reachable from some clients (e.g. "internal") can
// recurse for everyone inside it without being an open resolver. // recurse for everyone inside it without being an open resolver.
if x.v != nil && !c.listMatchesEveryone(x.v.MatchClients, true) { // Without views, only a server marked private is left out.
if x := exposure[viewID(x.v)]; !x.Public && (x.ViewID != 0 || x.Override == model.ExposurePrivate) {
continue continue
} }
if c.effectiveBool(x.v, "recursion", true) { if c.effectiveBool(x.v, "recursion", true) {
@@ -593,6 +613,9 @@ func (c *checker) checkSecurity() {
p := c.errf("dynamic_update_unsupported", "zone", z.ID, "allow-update makes the zone dynamic, but Jarl rewrites zone files and would discard dynamic updates; remove allow-update (or set it to { none; }) and manage records through Jarl") p := c.errf("dynamic_update_unsupported", "zone", z.ID, "allow-update makes the zone dynamic, but Jarl rewrites zone files and would discard dynamic updates; remove allow-update (or set it to { none; }) and manage records through Jarl")
p.Zone, p.View = z.Name, c.s.ViewName(z) p.Zone, p.View = z.Name, c.s.ViewName(z)
} }
if x, ok := exposure[z.ViewID]; ok && !x.Public && (x.ViewID != 0 || x.Override == model.ExposurePrivate) {
continue // only reachable by your own clients
}
if o := c.s.FindOption(model.ScopeZone, z.ID, "allow-transfer"); o != nil && namedconf.ContainsAny(c.amlOf(o)) { if o := c.s.FindOption(model.ScopeZone, z.ID, "allow-transfer"); o != nil && namedconf.ContainsAny(c.amlOf(o)) {
p := c.warnf("security_open_transfer", "zone", z.ID, "allows zone transfers (AXFR) to anyone; restrict allow-transfer to your secondaries or a TSIG key") p := c.warnf("security_open_transfer", "zone", z.ID, "allows zone transfers (AXFR) to anyone; restrict allow-transfer to your secondaries or a TSIG key")
p.Zone, p.View = z.Name, c.s.ViewName(z) p.Zone, p.View = z.Name, c.s.ViewName(z)
+387
View File
@@ -0,0 +1,387 @@
package validate
import (
"fmt"
"net/netip"
"strings"
"jarl/internal/model"
"jarl/internal/namedconf"
)
// Exposure says whether a view, or the server without views, can be
// reached from the internet (docs/exposure.md). The exposure checks only
// look at public ones.
type Exposure struct {
ViewID int64 `json:"view_id"` // 0: the server (no views)
View string `json:"view,omitempty"`
Public bool `json:"public"`
Override string `json:"override,omitempty"` // public or private, set by hand
Reason string `json:"reason"`
}
// Label names the scope in messages: "view external" or "the server".
func (e Exposure) Label() string {
if e.ViewID == 0 {
return "the server"
}
return "view " + e.View
}
// RiskCodes are the findings that open the server up rather than leave it
// less hardened: a change that introduces one must be confirmed
// (ApplyOptions.AcceptRisk).
var RiskCodes = map[string]bool{
"security_open_resolver": true,
"security_open_transfer": true,
"exposure_cache_snooping": true,
"exposure_open_notify": true,
"exposure_management": true,
}
// Ackable reports whether a finding can be acknowledged.
func Ackable(code string) bool {
return strings.HasPrefix(code, "exposure_") || strings.HasPrefix(code, "security_open_")
}
// Exposures classifies every view (or the server, without views).
func Exposures(s *model.State, opts Options) []Exposure {
c := &checker{s: s, opts: opts}
return c.exposures()
}
func (c *checker) exposures() []Exposure {
views := c.s.OrderedViews()
if len(views) == 0 {
return []Exposure{c.serverExposure()}
}
var out []Exposure
for _, v := range views {
out = append(out, c.viewExposure(v))
}
return out
}
func (c *checker) viewExposure(v *model.View) Exposure {
e := Exposure{ViewID: v.ID, View: v.Name}
switch v.Exposure {
case model.ExposurePublic:
e.Public, e.Override, e.Reason = true, v.Exposure, "marked public"
return e
case model.ExposurePrivate:
e.Override, e.Reason = v.Exposure, "marked private"
return e
}
clients := c.flatten(v.MatchClients, 0)
what, open := catchAll(clients)
if len(v.MatchClients) == 0 {
what, open = "nothing (so any client)", true
}
if !open {
e.Reason = "match-clients lists only " + describeList(v.MatchClients) + ", no catch-all"
return e
}
if len(v.MatchDestinations) > 0 {
if dst := c.flatten(v.MatchDestinations, 0); onlyPrivate(dst) {
e.Reason = "match-destinations lists only private addresses (" + describeList(v.MatchDestinations) + ")"
return e
}
}
e.Public, e.Reason = true, "match-clients ends in "+what
return e
}
// serverExposure classifies a server without views: public when anyone
// may query it and it listens on an address that isn't private.
func (c *checker) serverExposure() Exposure {
e := Exposure{}
switch c.s.Settings.Exposure {
case model.ExposurePublic:
e.Public, e.Override, e.Reason = true, model.ExposurePublic, "marked public"
return e
case model.ExposurePrivate:
e.Override, e.Reason = model.ExposurePrivate, "marked private"
return e
}
if aml, _, set := c.effectiveAML(nil, "allow-query"); set {
if _, open := catchAll(aml); !open {
e.Reason = "allow-query has no catch-all"
return e
}
}
addr, ok := c.publicListenAddr()
if !ok {
e.Reason = "named listens on private addresses only"
return e
}
e.Public, e.Reason = true, "anyone may query it, and named listens on "+addr
return e
}
// publicListenAddr returns an address named listens on that isn't
// private, from listen-on/listen-on-v6 and this host's addresses.
func (c *checker) publicListenAddr() (string, bool) {
for _, name := range []string{"listen-on", "listen-on-v6"} {
opts := []*model.Option{}
for _, o := range c.s.ScopedOptions(model.ScopeOptions, 0) {
if o.Name == name {
opts = append(opts, o)
}
}
if len(opts) == 0 {
// named listens on every interface by default.
if a, ok := c.publicLocalAddr(name == "listen-on-v6"); ok {
return a, true
}
continue
}
for _, o := range opts {
elems := c.amlOf(o)
if _, open := catchAll(elems); open {
if a, ok := c.publicLocalAddr(name == "listen-on-v6"); ok {
return a, true
}
continue
}
for _, e := range elems {
if e.Kind == namedconf.AMPrefix && !e.Negated && !privatePrefix(e.Prefix) {
return e.Prefix.Addr().String(), true
}
}
}
}
return "", false
}
// publicLocalAddr returns one of this host's addresses that isn't
// private (none known in tests).
func (c *checker) publicLocalAddr(v6 bool) (string, bool) {
for _, a := range c.opts.LocalAddrs {
a = a.Unmap()
if a.Is6() == v6 && !privatePrefix(netip.PrefixFrom(a, a.BitLen())) {
return a.String(), true
}
}
return "", false
}
// catchAll reports whether a list lets in clients from anywhere: a
// non-negated "any", or a prefix so wide (shorter than /8 for IPv4, /16
// for IPv6) that it holds most of the internet. Negated elements before
// it only take some clients out.
func catchAll(elems []namedconf.AMElement) (string, bool) {
for _, e := range elems {
if e.Negated {
continue
}
switch {
case e.Kind == namedconf.AMName && e.Name == "any":
return "any", true
case e.Kind == namedconf.AMName && e.Name == "none":
return "", false
case e.Kind == namedconf.AMPrefix && e.Prefix.Addr().Is4() && e.Prefix.Bits() < 8,
e.Kind == namedconf.AMPrefix && !e.Prefix.Addr().Is4() && e.Prefix.Bits() < 16:
return e.Prefix.String(), true
}
}
return "", false
}
var privateBlocks = []netip.Prefix{
netip.MustParsePrefix("10.0.0.0/8"), netip.MustParsePrefix("172.16.0.0/12"), netip.MustParsePrefix("192.168.0.0/16"),
netip.MustParsePrefix("100.64.0.0/10"), netip.MustParsePrefix("127.0.0.0/8"), netip.MustParsePrefix("169.254.0.0/16"),
netip.MustParsePrefix("fc00::/7"), netip.MustParsePrefix("fe80::/10"), netip.MustParsePrefix("::1/128"),
}
func privatePrefix(p netip.Prefix) bool {
a := p.Addr().Unmap()
for _, b := range privateBlocks {
if b.Addr().Is4() == a.Is4() && b.Contains(a) && p.Bits() >= b.Bits() {
return true
}
}
return false
}
// onlyPrivate reports whether every address element is private (names
// such as localhost and localnets count as private).
func onlyPrivate(elems []namedconf.AMElement) bool {
for _, e := range elems {
if e.Negated {
continue
}
switch e.Kind {
case namedconf.AMPrefix:
if !privatePrefix(e.Prefix) {
return false
}
case namedconf.AMName:
if e.Name == "any" {
return false
}
case namedconf.AMOther, namedconf.AMNested:
return false
}
}
return true
}
func describeList(list []string) string {
if len(list) > 4 {
return strings.Join(list[:4], ", ") + ", …"
}
return strings.Join(list, ", ")
}
// checkExposure runs the exposure checks on public views (or the server).
func (c *checker) checkExposure() {
exps := c.exposures()
anyPublic := false
for _, x := range exps {
if !x.Public {
continue
}
anyPublic = true
v := c.s.Views[x.ViewID]
label := x.Label()
warn := func(code, format string, args ...any) {
p := c.warnf(code, "view", x.ViewID, "%s is reachable from the internet (%s): %s", label, x.Reason, fmt.Sprintf(format, args...))
p.View = x.View
}
if o := c.option(v, "rate-limit"); o == nil {
warn("exposure_rate_limit", "it has no rate-limit, so it can be used to flood others with its answers (DNS amplification); start response rate limiting in log-only mode")
} else if LogOnly(o) {
p := c.infof("exposure_rate_limit_log_only", "view", x.ViewID, "%s: rate limiting is in log-only mode: named only logs what it would limit; check that nobody legitimate would be limited, then start enforcing", label)
p.View = x.View
}
if !c.effectiveBool(v, "minimal-any", false) {
warn("exposure_minimal_any", "ANY queries get full answers, a favourite for amplification; set minimal-any yes")
}
if !c.effectiveBool(v, "recursion", true) {
if aml, _, set := c.effectiveAML(v, "allow-query-cache"); set {
if _, open := catchAll(aml); open {
warn("exposure_cache_snooping", "anyone may query its cache (allow-query-cache), which shows what your clients look up; set allow-query-cache { none; }")
}
}
}
if aml, _, set := c.effectiveAML(v, "allow-notify"); set {
if _, open := catchAll(aml); open {
warn("exposure_open_notify", "anyone may send it NOTIFY messages (allow-notify); allow only your primaries")
}
}
}
if !anyPublic {
return
}
server := func(code, format string, args ...any) {
c.warnf(code, "server", 0, format, args...)
}
if c.s.FindOption(model.ScopeOptions, 0, "version") == nil {
server("exposure_version", "the server is reachable from the internet and tells anyone its exact BIND version (dig CH TXT version.bind), which helps attackers pick exploits; set version none")
}
if c.s.FindOption(model.ScopeOptions, 0, "hostname") == nil {
server("exposure_hostname", "the server is reachable from the internet and tells anyone its host name (dig CH TXT hostname.bind); set hostname none")
}
for _, name := range []string{"controls", "statistics-channels"} {
for _, o := range c.s.ScopedOptions(model.ScopeTopLevel, 0) {
if o.Name != name {
continue
}
if addr, ok := openManagement(o); ok {
c.warnf("exposure_management", "option", o.ID, "%s listens on %s and accepts anyone there; management interfaces must not be reachable from the internet: use 127.0.0.1, or an allow list of your own hosts", name, addr)
}
}
}
}
// option returns the option name for a view, or the server's.
func (c *checker) option(v *model.View, name string) *model.Option {
if v != nil {
if o := c.s.FindOption(model.ScopeView, v.ID, name); o != nil {
return o
}
}
return c.s.FindOption(model.ScopeOptions, 0, name)
}
// LogOnly reports whether a rate-limit option is in log-only mode.
func LogOnly(o *model.Option) bool {
st, err := namedconf.ParseOptionValue(o.Name, o.Value)
if err != nil || st.FirstBlock() == nil {
return false
}
for _, s := range st.FirstBlock().Stmts {
if s.Keyword() == "log-only" && len(s.Items) == 2 && s.Items[1].Value != nil {
switch strings.ToLower(s.Items[1].Value.Text) {
case "yes", "true", "1":
return true
}
}
}
return false
}
// openManagement reports an inet clause of controls or
// statistics-channels on an address that isn't loopback, open to anyone.
func openManagement(o *model.Option) (string, bool) {
st, err := namedconf.ParseOptionValue(o.Name, o.Value)
if err != nil || st.FirstBlock() == nil {
return "", false
}
for _, s := range st.FirstBlock().Stmts {
if s.Keyword() != "inet" || len(s.Items) < 2 || s.Items[1].Value == nil {
continue
}
addr := s.Items[1].Value.Text
if a, err := netip.ParseAddr(addr); err == nil && a.IsLoopback() {
continue
}
for i, it := range s.Items {
if it.Value != nil && strings.EqualFold(it.Value.Text, "allow") && i+1 < len(s.Items) && s.Items[i+1].Block != nil {
var elems []namedconf.AMElement
for _, as := range s.Items[i+1].Block.Stmts {
if e, err := namedconf.ParseAMElement(namedconf.Compact(as.Items)); err == nil {
elems = append(elems, e.Expand()...)
}
}
if _, open := catchAll(elems); open {
return addr, true
}
}
}
}
return "", false
}
// applyAcks turns acknowledged findings into info, saying who accepted
// them, when and why. A finding of a view (or of a zone in it) uses the
// view's acknowledgements, anything else the server's.
func (c *checker) applyAcks() {
for i := range c.problems {
p := &c.problems[i]
if p.Severity != Warning || !Ackable(p.Code) {
continue
}
acks := c.s.Settings.Acknowledged
if v := c.problemView(p); v != nil {
acks = v.Acknowledged
}
if a := model.FindAck(acks, p.Code); a != nil {
p.Severity = Info
p.Acknowledged = a
p.Message += fmt.Sprintf(" (acknowledged by %s on %s: %s)", a.By, a.At.Format("2 Jan 2006"), a.Reason)
}
}
}
func (c *checker) problemView(p *Problem) *model.View {
switch p.ObjectType {
case "view":
return c.s.Views[p.ObjectID]
case "zone":
if z := c.s.Zones[p.ObjectID]; z != nil {
return c.s.Views[z.ViewID]
}
}
return nil
}
+10
View File
@@ -11,6 +11,7 @@ package validate
import ( import (
"fmt" "fmt"
"net/netip"
"sort" "sort"
"strings" "strings"
@@ -37,6 +38,9 @@ type Problem struct {
Zone string `json:"zone,omitempty"` Zone string `json:"zone,omitempty"`
View string `json:"view,omitempty"` View string `json:"view,omitempty"`
Name string `json:"name,omitempty"` Name string `json:"name,omitempty"`
// Acknowledged is set when an exposure finding was accepted on
// purpose (it is then info, not a warning).
Acknowledged *model.Acknowledgement `json:"acknowledged,omitempty"`
} }
// Key identifies a problem independently of object IDs, so problems can be // Key identifies a problem independently of object IDs, so problems can be
@@ -67,6 +71,10 @@ type Options struct {
// ExternalKeysUnknown is set when an included file could not be read, // ExternalKeysUnknown is set when an included file could not be read,
// so references to unknown keys are not reported. // so references to unknown keys are not reported.
ExternalKeysUnknown bool ExternalKeysUnknown bool
// LocalAddrs are this host's addresses: a server without views is
// public when named listens on one that isn't private
// (docs/exposure.md).
LocalAddrs []netip.Addr
} }
// Result holds the problems found. // Result holds the problems found.
@@ -140,8 +148,10 @@ func State(s *model.State, opts Options) []Problem {
c.checkZones() c.checkZones()
c.checkOptions() c.checkOptions()
c.checkSecurity() c.checkSecurity()
c.checkExposure()
c.checkDNSSEC() c.checkDNSSEC()
c.checkBlocking() c.checkBlocking()
c.applyAcks()
sortProblems(c.problems) sortProblems(c.problems)
return c.problems return c.problems
} }
+32 -2
View File
@@ -16,6 +16,8 @@ export async function amlContext() {
} }
const ipRe = /^(\d{1,3}(\.\d{1,3}){0,3}|[0-9a-f:]*:[0-9a-f:.]*)(\/\d{1,3})?$/i; const ipRe = /^(\d{1,3}(\.\d{1,3}){0,3}|[0-9a-f:]*:[0-9a-f:.]*)(\/\d{1,3})?$/i;
// rangeRe finds address ranges (START-END); the server checks them.
const rangeRe = /!?\s*[0-9a-f]*[.:][0-9a-f:.]*\s*-\s*[0-9a-f]*[.:][0-9a-f:.]*/gi;
// checkElement returns a problem with one element, or "". // checkElement returns a problem with one element, or "".
function checkElement(line, ctx, exclude) { function checkElement(line, ctx, exclude) {
@@ -24,6 +26,7 @@ function checkElement(line, ctx, exclude) {
if (e.startsWith("!")) e = e.slice(1).trim(); if (e.startsWith("!")) e = e.slice(1).trim();
const lower = e.toLowerCase(); const lower = e.toLowerCase();
if (builtins.includes(lower) || ipRe.test(e) || e.startsWith("{") || lower.startsWith("geoip ")) return ""; if (builtins.includes(lower) || ipRe.test(e) || e.startsWith("{") || lower.startsWith("geoip ")) return "";
if (e.replace(rangeRe, "").trim() === "") return ""; // a range: rangeHints explains it
const key = e.match(/^key\s+"?([^"\s]+)"?$/i); const key = e.match(/^key\s+"?([^"\s]+)"?$/i);
if (key) return ctx.keys.some((k) => k.toLowerCase() === key[1].toLowerCase()) ? "" : "no TSIG key called " + key[1]; if (key) return ctx.keys.some((k) => k.toLowerCase() === key[1].toLowerCase()) ? "" : "no TSIG key called " + key[1];
if (/\s/.test(e)) return ""; if (/\s/.test(e)) return "";
@@ -32,6 +35,33 @@ function checkElement(line, ctx, exclude) {
return ctx.acls.some((a) => a.name.toLowerCase() === name.toLowerCase()) ? "" : "no ACL called " + name + (ctx.acls.length ? "" : " (there are no ACLs yet)"); return ctx.acls.some((a) => a.name.toLowerCase() === name.toLowerCase()) ? "" : "no ACL called " + name + (ctx.acls.length ? "" : " (there are no ACLs yet)");
} }
// rangeHints shows, below a field, what each address range in it
// (10.0.10.101-10.0.10.199) becomes in named.conf, which only knows
// prefixes, and what is wrong with a range that can't be used.
export function rangeHints(field) {
const box = h("div", { class: "range-hints" });
let timer, last = "";
const update = async () => {
const found = (field.value.match(rangeRe) || []).map((r) => r.replace(/\s+/g, ""));
const key = found.join(",");
if (key === last) return;
last = key;
if (!found.length) { mount(box); return; }
try {
const info = await get("/catalog/ranges", { r: key });
if (key !== last) return;
mount(box, info.map((x) => x.error
? h("div", { class: "msg warn small" }, x.error)
: h("details", { class: "range-hint small" },
h("summary", null, h("span", { class: "mono" }, x.input), " = " + x.size + ", written to named.conf as " + (x.prefixes.length === 1 ? "1 prefix" : x.prefixes.length + " prefixes")),
h("div", { class: "prefixes mono" }, x.prefixes.map((p) => h("span", null, p))))));
} catch { mount(box); }
};
field.addEventListener("input", () => { clearTimeout(timer); timer = setTimeout(update, 300); });
update();
return box;
}
// suggestions adds clickable names below a text field. mode "lines" adds // suggestions adds clickable names below a text field. mode "lines" adds
// one element per line (and checks the names); "inline" inserts // one element per line (and checks the names); "inline" inserts
// "name; " at the cursor in named.conf syntax. // "name; " at the cursor in named.conf syntax.
@@ -58,7 +88,7 @@ export function suggestions(field, ctx, { mode = "lines", exclude = "" } = {}) {
acls.length ? [h("span", { class: "muted small" }, "ACLs:"), acls.map((a) => chip(a.name, a.name, a.detail))] : null, acls.length ? [h("span", { class: "muted small" }, "ACLs:"), acls.map((a) => chip(a.name, a.name, a.detail))] : null,
ctx.keys.length ? [h("span", { class: "muted small" }, "Keys:"), ctx.keys.map((k) => chip("key " + k, "key " + k, "TSIG key"))] : null, ctx.keys.length ? [h("span", { class: "muted small" }, "Keys:"), ctx.keys.map((k) => chip("key " + k, "key " + k, "TSIG key"))] : null,
h("span", { class: "muted small" }, "Built-in:"), builtins.map((b) => chip(b, b, { any: "every address", none: "no address", localhost: "this server's own addresses", localnets: "networks this server is directly attached to" }[b])), h("span", { class: "muted small" }, "Built-in:"), builtins.map((b) => chip(b, b, { any: "every address", none: "no address", localhost: "this server's own addresses", localnets: "networks this server is directly attached to" }[b])),
mode === "lines" ? h("span", { class: "muted small" }, "Prefix a line with ! to exclude it.") : null); mode === "lines" ? h("span", { class: "muted small" }, "Prefix a line with ! to exclude it. Ranges: 10.0.10.101-10.0.10.199.") : null);
if (mode === "lines") { if (mode === "lines") {
const check = () => { const check = () => {
const msgs = field.value.split("\n").map((l) => [l.trim(), checkElement(l, ctx, exclude)]).filter(([, m]) => m); const msgs = field.value.split("\n").map((l) => [l.trim(), checkElement(l, ctx, exclude)]).filter(([, m]) => m);
@@ -67,5 +97,5 @@ export function suggestions(field, ctx, { mode = "lines", exclude = "" } = {}) {
field.addEventListener("input", check); field.addEventListener("input", check);
check(); check();
} }
return h("div", { class: "aml" }, row, problems); return h("div", { class: "aml" }, row, problems, rangeHints(field));
} }
+3
View File
@@ -18,6 +18,7 @@ import * as account from "./pages/account.js";
import * as search from "./pages/search.js"; import * as search from "./pages/search.js";
import * as stats from "./pages/stats.js"; import * as stats from "./pages/stats.js";
import * as blocking from "./pages/blocking.js"; import * as blocking from "./pages/blocking.js";
import * as exposure from "./pages/exposure.js";
const routes = [ const routes = [
{ pattern: /^$/, page: dashboard, nav: "dashboard" }, { pattern: /^$/, page: dashboard, nav: "dashboard" },
@@ -37,6 +38,7 @@ const routes = [
{ pattern: /^search$/, page: search, nav: "" }, { pattern: /^search$/, page: search, nav: "" },
{ pattern: /^stats$/, page: stats, nav: "stats" }, { pattern: /^stats$/, page: stats, nav: "stats" },
{ pattern: /^blocking$/, page: blocking, nav: "blocking" }, { pattern: /^blocking$/, page: blocking, nav: "blocking" },
{ pattern: /^exposure$/, page: exposure, nav: "exposure" },
]; ];
const navItems = [ const navItems = [
@@ -45,6 +47,7 @@ const navItems = [
["zones", "#/zones", "Zones"], ["zones", "#/zones", "Zones"],
["blocking", "#/blocking", "Blocking"], ["blocking", "#/blocking", "Blocking"],
["views", "#/views", "Views"], ["views", "#/views", "Views"],
["exposure", "#/exposure", "Exposure"],
["acls", "#/acls", "ACLs"], ["acls", "#/acls", "ACLs"],
["keys", "#/keys", "TSIG keys"], ["keys", "#/keys", "TSIG keys"],
["options", "#/options", "Server options"], ["options", "#/options", "Server options"],
+1 -1
View File
@@ -30,7 +30,7 @@ async function aclDialog(a) {
const comment = input({ value: a ? a.comment : "" }); const comment = input({ value: a ? a.comment : "" });
await modal(a ? "Edit ACL " + a.name : "New ACL", h("div", null, h("div", { class: "form-grid" }, await modal(a ? "Edit ACL " + a.name : "New ACL", h("div", null, h("div", { class: "form-grid" },
field("Name", name, a ? "Renaming updates every reference automatically." : ""), field("Name", name, a ? "Renaming updates every reference automatically." : ""),
field("Elements", h("div", null, elems, suggestions(elems, ctx, { exclude: a ? a.name : "" })), "One per line: address, prefix (host bits must be zero), !negated, key NAME, another ACL's name, any/none/localhost/localnets."), field("Elements", h("div", null, elems, suggestions(elems, ctx, { exclude: a ? a.name : "" })), "One per line: address, prefix (host bits must be zero), range (10.0.10.101-10.0.10.199), !negated, key NAME, another ACL's name, any/none/localhost/localnets."),
field("Comment", comment)), err), [ field("Comment", comment)), err), [
{ label: "Cancel", value: null }, { label: "Cancel", value: null },
{ label: a ? "Save" : "Create ACL", kind: "primary", onclick: async () => { { label: a ? "Save" : "Create ACL", kind: "primary", onclick: async () => {
+163 -10
View File
@@ -3,6 +3,7 @@ import { h, mount, table, field, input, select, checkbox, toast, errorBox, badge
import { get, change, describeResult, draftQuery, can, api } from "../api.js"; import { get, change, describeResult, draftQuery, can, api } from "../api.js";
import { rerender } from "../app.js"; import { rerender } from "../app.js";
import { pendingBadge } from "./zones.js"; import { pendingBadge } from "./zones.js";
import { rangeHints } from "../aml.js";
// Well-known lists in Adblock format (they say which entries cover // Well-known lists in Adblock format (they say which entries cover
// subdomains). Sizes change; these are rough guides. // subdomains). Sizes change; these are rough guides.
@@ -22,7 +23,12 @@ let refreshTimer = null;
export async function render(el, params, query) { export async function render(el, params, query) {
clearTimeout(refreshTimer); clearTimeout(refreshTimer);
const [lists, rules, views, status] = await Promise.all([get("/blocking/lists", draftQuery()), get("/blocking/rules", draftQuery()), get("/views"), get("/blocking/status")]); // Queries blocked in the last day, per list and for the rules together
// (null without query statistics, or without permission to see them).
const [lists, rules, views, status, hitList, steps, groups] = await Promise.all([get("/blocking/lists", draftQuery()), get("/blocking/rules", draftQuery()), get("/views"), get("/blocking/status"),
can("stats") ? get("/stats/breakdown", { group: "blocklist", since: "24h" }).catch(() => null) : null,
get("/blocking/steps", draftQuery()), get("/blocking/groups", draftQuery())]);
const hits = hitList ? Object.fromEntries(hitList.map((e) => [e.key, e.count])) : null;
const writable = can("blocking", true); const writable = can("blocking", true);
const recursive = lists.concat(rules).find((x) => x.auto_views); const recursive = lists.concat(rules).find((x) => x.auto_views);
const autoViews = recursive ? recursive.views : null; const autoViews = recursive ? recursive.views : null;
@@ -53,8 +59,9 @@ export async function render(el, params, query) {
h("form", { class: "actions", onsubmit: runCheck }, h("form", { class: "actions", onsubmit: runCheck },
h("label", { class: "muted" }, "Check a name"), checkName, h("button", { type: "submit" }, "Check")), h("label", { class: "muted" }, "Check a name"), checkName, h("button", { type: "submit" }, "Check")),
checkOut), checkOut),
rulesPanel(rules, views, autoViews, writable), rulesPanel(rules, views, autoViews, writable, hits),
listsPanel(lists, views, autoViews, writable)); listsPanel(lists, views, autoViews, writable, hits, steps),
devicesPanel(groups, steps, views, autoViews, writable));
if (checkName.value) runCheck(); if (checkName.value) runCheck();
// Lists are downloaded in the background, and a pause ends by itself: // Lists are downloaded in the background, and a pause ends by itself:
@@ -110,7 +117,8 @@ function checkResult(name, res, rules, writable) {
m.view ? h("span", { class: "muted" }, "view " + m.view + ": ") : null, m.view ? h("span", { class: "muted" }, "view " + m.view + ": ") : null,
m.result === "blocked" ? badge("blocked", "err") : m.result === "allowed" ? badge("allowed", "ok") : badge("not blocked", ""), m.result === "blocked" ? badge("blocked", "err") : m.result === "allowed" ? badge("allowed", "ok") : badge("not blocked", ""),
" ", " ",
m.list ? ["by blocklist ", h("strong", null, m.list), " (entry ", h("code", null, m.entry), ")"] m.list && m.step ? ["for devices at step ", h("strong", null, m.step), " and above, by blocklist ", h("strong", null, m.list), " (entry ", h("code", null, m.entry), "); other devices aren't blocked"]
: m.list ? ["by blocklist ", h("strong", null, m.list), " (entry ", h("code", null, m.entry), ")"]
: m.result !== "none" ? ["by your rule to ", rule ? rule.action : "", " ", h("code", null, rule ? rule.name : m.entry.replace(/^\*\./, "")), rule && !rule.exact ? " and below" : ""] : null); : m.result !== "none" ? ["by your rule to ", rule ? rule.action : "", " ", h("code", null, rule ? rule.name : m.entry.replace(/^\*\./, "")), rule && !rule.exact ? " and below" : ""] : null);
})), })),
action); action);
@@ -172,7 +180,8 @@ async function editDialog(title, path, rows, data) {
], { sticky: true }); ], { sticky: true });
} }
function editList(l, views, autoViews) { function editList(l, views, autoViews, steps) {
const step = stepSelect(steps, l.step, false);
const name = input({ value: l.name, class: "mono" }); const name = input({ value: l.name, class: "mono" });
const url = input({ value: l.url, class: "mono", style: { width: "100%" } }); const url = input({ value: l.url, class: "mono", style: { width: "100%" } });
const comment = input({ value: l.comment || "" }); const comment = input({ value: l.comment || "" });
@@ -180,8 +189,9 @@ function editList(l, views, autoViews) {
editDialog("Edit blocklist " + l.name, "/blocking/lists/" + l.id, [ editDialog("Edit blocklist " + l.name, "/blocking/lists/" + l.id, [
["Name", name], ["URL", url, "A new URL is downloaded right after saving."], ["Name", name], ["URL", url, "A new URL is downloaded right after saving."],
vp.picker ? ["Views", vp.picker, "None ticked: every view that offers recursion."] : null, vp.picker ? ["Views", vp.picker, "None ticked: every view that offers recursion."] : null,
steps.length ? ["Step", step, "Devices at this step or above get the list. normal: every device except those at off."] : null,
["Comment", comment], ["Comment", comment],
], () => ({ name: name.value.trim(), url: url.value.trim(), views: vp.value(), comment: comment.value })); ], () => ({ name: name.value.trim(), url: url.value.trim(), views: vp.value(), comment: comment.value, step: step.value }));
} }
function editRule(r, views, autoViews) { function editRule(r, views, autoViews) {
@@ -207,7 +217,12 @@ function viewsCell(x) {
return x.auto_views ? [x.views.join(", "), h("span", { class: "muted small" }, " (auto)")] : x.views.join(", "); return x.auto_views ? [x.views.join(", "), h("span", { class: "muted small" }, " (auto)")] : x.views.join(", ");
} }
function rulesPanel(rules, views, autoViews, writable) { // hitsLink shows a count of blocked queries, linking to the statistics.
function hitsLink(n) {
return h("a", { href: "#/stats?since=24h", title: "queries blocked in the last 24 hours; more on the Statistics page" }, (n || 0).toLocaleString());
}
function rulesPanel(rules, views, autoViews, writable, hits) {
const name = input({ placeholder: "ads.example.com", class: "mono" }); const name = input({ placeholder: "ads.example.com", class: "mono" });
const action = select([["block", "Block"], ["allow", "Allow"]], "block"); const action = select([["block", "Block"], ["allow", "Allow"]], "block");
const exact = checkbox("only this exact name", false); const exact = checkbox("only this exact name", false);
@@ -230,6 +245,7 @@ function rulesPanel(rules, views, autoViews, writable) {
h("div", { class: "f" }, h("label", null, " "), h("button", { class: "primary", type: "submit" }, "Add rule"))); h("div", { class: "f" }, h("label", null, " "), h("button", { class: "primary", type: "submit" }, "Add rule")));
return h("div", { class: "panel" }, return h("div", { class: "panel" },
h("h2", null, "Your rules"), h("h2", null, "Your rules"),
hits && rules.some((r) => r.action === "block") ? h("p", { class: "small" }, "Blocked ", hitsLink(hits.rules), " queries in the last 24 hours (all rules together: named doesn't say which rule matched).") : null,
h("p", { class: "muted small" }, "A rule covers the domain and every name below it, unless it's for the exact name only. Rules beat every blocklist, and the most specific rule wins: allowing cdn.example.com overrides blocking example.com for cdn.example.com and the names below it."), h("p", { class: "muted small" }, "A rule covers the domain and every name below it, unless it's for the exact name only. Rules beat every blocklist, and the most specific rule wins: allowing cdn.example.com overrides blocking example.com for cdn.example.com and the names below it."),
writable ? [form, err] : null, writable ? [form, err] : null,
table([ table([
@@ -243,21 +259,33 @@ function rulesPanel(rules, views, autoViews, writable) {
].filter(Boolean), rules, { empty: "No rules yet.", rowClass: (r) => r.pending ? "pending-" + r.pending : "" })); ].filter(Boolean), rules, { empty: "No rules yet.", rowClass: (r) => r.pending ? "pending-" + r.pending : "" }));
} }
function listsPanel(lists, views, autoViews, writable) { // stepSelect picks a step: off (only for groups), normal and the steps
// above it.
function stepSelect(steps, value, withOff) {
const opts = [];
if (withOff) opts.push(["off", "off (only your rules)"]);
opts.push(["normal", "normal" + (withOff ? " (like devices in no group)" : " (every device)")]);
steps.filter((s) => s.pending !== "delete").forEach((s) => opts.push([s.name, s.name]));
return select(opts, value || "normal");
}
function listsPanel(lists, views, autoViews, writable, hits, steps) {
const name = input({ placeholder: "hagezi-multi", class: "mono", style: { width: "11em" } }); const name = input({ placeholder: "hagezi-multi", class: "mono", style: { width: "11em" } });
const url = input({ placeholder: "https://…", class: "mono" }); const url = input({ placeholder: "https://…", class: "mono" });
const vp = viewPicker(views, autoViews); const vp = viewPicker(views, autoViews);
const listStep = stepSelect(steps, "normal", false);
const err = h("div"); const err = h("div");
const form = h("form", { class: "inline-form", onsubmit: async (ev) => { const form = h("form", { class: "inline-form", onsubmit: async (ev) => {
ev.preventDefault(); ev.preventDefault();
try { try {
const r = await change("POST", "/blocking/lists", { name: name.value.trim(), url: url.value.trim(), views: vp.value() }); const r = await change("POST", "/blocking/lists", { name: name.value.trim(), url: url.value.trim(), views: vp.value(), step: listStep.value });
toast(describeResult(r) + (r.applied ? " — jarld downloads it now" : "")); toast(describeResult(r) + (r.applied ? " — jarld downloads it now" : ""));
rerender(); rerender();
} catch (e) { mount(err, errorBox(e)); } } catch (e) { mount(err, errorBox(e)); }
} }, } },
h("div", { class: "f" }, h("label", null, "Name"), name), h("div", { class: "f" }, h("label", null, "Name"), name),
h("div", { class: "f grow" }, h("label", null, "URL"), url), h("div", { class: "f grow" }, h("label", null, "URL"), url),
steps.length ? h("div", { class: "f" }, h("label", null, "Step"), listStep) : null,
vp.el, vp.el,
h("div", { class: "f" }, h("label", null, " "), h("button", { class: "primary", type: "submit" }, "Add list"))); h("div", { class: "f" }, h("label", null, " "), h("button", { class: "primary", type: "submit" }, "Add list")));
const used = new Set(lists.map((l) => l.url)); const used = new Set(lists.map((l) => l.url));
@@ -273,12 +301,14 @@ function listsPanel(lists, views, autoViews, writable) {
{ label: "Name", render: (l) => [h("strong", { class: "mono" }, l.name), l.pending ? [" ", pendingBadge(l.pending)] : null] }, { label: "Name", render: (l) => [h("strong", { class: "mono" }, l.name), l.pending ? [" ", pendingBadge(l.pending)] : null] },
{ label: "State", render: (l) => { const [t, k] = stateBadge[l.state] || [l.state, ""]; return [badge(t, k), l.status && l.status.error ? h("div", { class: "small err-text" }, l.status.error) : null]; } }, { label: "State", render: (l) => { const [t, k] = stateBadge[l.state] || [l.state, ""]; return [badge(t, k), l.status && l.status.error ? h("div", { class: "small err-text" }, l.status.error) : null]; } },
{ label: "Domains", render: (l) => l.status && l.status.updated_at ? l.status.entries.toLocaleString() : "–", tdClass: "right" }, { label: "Domains", render: (l) => l.status && l.status.updated_at ? l.status.entries.toLocaleString() : "–", tdClass: "right" },
hits ? { label: "Blocked (24h)", render: (l) => hitsLink(hits[l.name]), class: "right", tdClass: "right" } : null,
steps.length ? { label: "Step", render: (l) => l.step === "normal" ? h("span", { class: "muted" }, "normal") : badge(l.step, "accent") } : null,
views.length ? { label: "Views", render: viewsCell } : null, views.length ? { label: "Views", render: viewsCell } : null,
{ label: "Updated", render: (l) => l.status && l.status.updated_at ? h("span", { title: "checked " + time(l.status.checked_at) }, relTime(l.status.updated_at)) : "–", tdClass: "small" }, { label: "Updated", render: (l) => l.status && l.status.updated_at ? h("span", { title: "checked " + time(l.status.checked_at) }, relTime(l.status.updated_at)) : "–", tdClass: "small" },
{ label: "Memory", render: (l) => l.memory_mb ? h("span", { title: "named's memory for this list, estimated (all its views)" }, "~" + l.memory_mb + " MB") : "–", tdClass: "small right" }, { label: "Memory", render: (l) => l.memory_mb ? h("span", { title: "named's memory for this list, estimated (all its views)" }, "~" + l.memory_mb + " MB") : "–", tdClass: "small right" },
{ label: "URL", render: (l) => h("a", { href: l.url, target: "_blank", rel: "noopener", class: "small mono", title: l.url }, shortURL(l.url)) }, { label: "URL", render: (l) => h("a", { href: l.url, target: "_blank", rel: "noopener", class: "small mono", title: l.url }, shortURL(l.url)) },
writable ? { label: "", render: (l) => l.pending === "delete" ? "" : h("span", null, writable ? { label: "", render: (l) => l.pending === "delete" ? "" : h("span", null,
h("button", { class: "small", onclick: () => editList(l, views, autoViews) }, "Edit"), " ", h("button", { class: "small", onclick: () => editList(l, views, autoViews, steps) }, "Edit"), " ",
h("button", { class: "small", disabled: l.state === "disabled" || !!l.pending, title: "Download it now (it is checked daily anyway)", onclick: () => refresh(l) }, "Refresh"), " ", h("button", { class: "small", disabled: l.state === "disabled" || !!l.pending, title: "Download it now (it is checked daily anyway)", onclick: () => refresh(l) }, "Refresh"), " ",
h("button", { class: "small", onclick: () => toggle(l) }, l.disabled ? "Enable" : "Disable"), " ", h("button", { class: "small", onclick: () => toggle(l) }, l.disabled ? "Enable" : "Disable"), " ",
h("button", { class: "small danger", onclick: () => del("/blocking/lists/" + l.id, "blocklist " + l.name) }, "Delete")), tdClass: "actions-cell" } : null, h("button", { class: "small danger", onclick: () => del("/blocking/lists/" + l.id, "blocklist " + l.name) }, "Delete")), tdClass: "actions-cell" } : null,
@@ -318,3 +348,126 @@ async function del(path, what) {
} catch (e) { toast(e.message, "err", 8000); } } catch (e) { toast(e.message, "err", 8000); }
} }
// ---- devices: blocking steps and client groups (docs/blocking.md)
// devicesPanel shows the steps (each includes the lists of the steps
// below it) and the client groups that put devices at a step.
function devicesPanel(groups, steps, views, autoViews, writable) {
const live = steps.filter((s) => s.pending !== "delete");
const name = input({ placeholder: "kids", class: "mono", style: { width: "9em" } });
const members = input({ placeholder: "10.10.0.50 10.10.0.20-10.10.0.29 or an ACL", class: "mono" });
const step = stepSelect(steps, live.length ? live[0].name : "off", true);
const vp = viewPicker(views, autoViews);
const err = h("div");
const form = h("form", { class: "inline-form", onsubmit: async (ev) => {
ev.preventDefault();
try {
const r = await change("POST", "/blocking/groups", { name: name.value.trim(), members: splitMembers(members.value), step: step.value, views: vp.value() });
toast(describeResult(r));
rerender();
} catch (e) { mount(err, errorBox(e)); }
} },
h("div", { class: "f" }, h("label", null, "Group"), name),
h("div", { class: "f grow" }, h("label", null, "Devices"), members, rangeHints(members)),
h("div", { class: "f" }, h("label", null, "Step"), step),
vp.el,
h("div", { class: "f" }, h("label", null, " "), h("button", { class: "primary", type: "submit" }, "Add group")));
return h("div", { class: "panel" },
h("h2", null, "Devices"),
h("p", { class: "muted small" }, "Give some devices less or more blocking. Each step gets the lists of the steps below it plus its own; devices in no group are at normal. Your rules apply at every step. named must see the devices' own addresses (not a router's), so give them fixed ones."),
stepsLine(steps, writable),
writable ? [form, err] : null,
table([
{ label: "Group", render: (g) => [h("strong", { class: "mono" }, g.name), g.pending ? [" ", pendingBadge(g.pending)] : null] },
{ label: "Step", render: (g) => g.step === "off" ? badge("off", "") : g.step === "normal" ? h("span", { class: "muted" }, "normal") : badge(g.step, "accent") },
{ label: "Devices", render: (g) => g.members.length ? h("span", { class: "mono small" }, g.members.join(", ")) : h("span", { class: "muted" }, "none") },
views.length ? { label: "Views", render: viewsCell } : null,
{ label: "Comment", render: (g) => h("span", { class: "muted" }, g.comment || "") },
writable ? { label: "", render: (g) => g.pending === "delete" ? "" : h("span", null,
h("button", { class: "small", onclick: () => editGroup(g, steps, views, autoViews) }, "Edit"), " ",
h("button", { class: "small danger", onclick: () => del("/blocking/groups/" + g.id, "client group " + g.name) }, "Delete")), tdClass: "actions-cell" } : null,
].filter(Boolean), groups, { empty: "No groups: every device is at normal.", rowClass: (g) => g.pending ? "pending-" + g.pending : "" }));
}
// splitMembers turns "a, b c" into a list; a range may have spaces
// around its dash.
function splitMembers(s) {
return s.replace(/\s*-\s*/g, "-").split(/[\s,;]+/).map((x) => x.trim()).filter(Boolean);
}
// stepsLine shows the steps in order, with controls for the ones above
// normal.
function stepsLine(steps, writable) {
const live = steps.filter((s) => s.pending !== "delete");
const order = async (ids) => {
try {
const r = await change("POST", "/blocking/steps/order", { steps: ids.map(String) });
toast(describeResult(r));
rerender();
} catch (e) { toast(e.message, "err", 8000); }
};
const move = (i, d) => {
const ids = live.map((s) => s.id);
[ids[i], ids[i + d]] = [ids[i + d], ids[i]];
order(ids);
};
const add = async () => {
const n = await promptName("Add a step", "Name of the new step (above the others), e.g. strict", "");
if (!n) return;
try {
const r = await change("POST", "/blocking/steps", { name: n });
toast(describeResult(r));
rerender();
} catch (e) { toast(e.message, "err", 8000); }
};
const rename = async (s) => {
const n = await promptName("Rename step " + s.name, "New name", s.name);
if (!n || n === s.name) return;
try {
const r = await change("PATCH", "/blocking/steps/" + s.id, { name: n });
toast(describeResult(r));
rerender();
} catch (e) { toast(e.message, "err", 8000); }
};
const chip = (label, title) => h("span", { class: "step-chip", title }, label);
const items = [chip("off", "only your rules"), chip("normal", "your rules and the normal lists; devices in no group")];
live.forEach((s, i) => {
const used = s.lists.length || s.groups.length;
items.push(h("span", { class: "step-chip accent", title: "lists: " + (s.lists.join(", ") || "none") + "; groups: " + (s.groups.join(", ") || "none") },
s.name, s.pending ? [" ", pendingBadge(s.pending)] : null,
writable ? h("span", { class: "step-tools" },
h("button", { class: "small", disabled: i === 0, title: "Move down: fewer lists", onclick: () => move(i, -1) }, "←"),
h("button", { class: "small", disabled: i === live.length - 1, title: "Move up: more lists", onclick: () => move(i, 1) }, "→"),
h("button", { class: "small", title: "Rename", onclick: () => rename(s) }, "✎"),
h("button", { class: "small danger", disabled: !!used, title: used ? "In use by lists or groups" : "Delete", onclick: () => del("/blocking/steps/" + s.id, "step " + s.name) }, "×")) : null));
});
return h("div", { class: "steps-line" }, h("span", { class: "muted small" }, "Steps: "),
items.map((x, i) => [i ? h("span", { class: "muted" }, " → ") : null, x]),
writable ? [" ", h("button", { class: "small", onclick: add }, "Add step")] : null);
}
// promptName asks for a short name in a dialog.
async function promptName(title, label, value) {
const i = input({ value, class: "mono" });
const v = await modal(title, h("div", { class: "form-grid" }, field(label, i)), [
{ label: "Cancel", value: null },
{ label: "OK", kind: "primary", value: true, onclick: () => !!i.value.trim() },
]);
return v === true ? i.value.trim() : null;
}
function editGroup(g, steps, views, autoViews) {
const name = input({ value: g.name, class: "mono" });
const members = input({ value: g.members.join(" "), class: "mono", style: { width: "100%" } });
const step = stepSelect(steps, g.step, true);
const comment = input({ value: g.comment || "" });
const vp = viewPicker(views, autoViews, ownViews(g));
editDialog("Edit client group " + g.name, "/blocking/groups/" + g.id, [
["Name", name],
["Devices", h("div", null, members, rangeHints(members)), "Addresses, networks (10.10.0.0/24), ranges (10.10.0.20-10.10.0.49) or ACLs, separated by spaces. A device can be in one group; a more specific address in another group wins."],
["Step", step],
vp.picker ? ["Views", vp.picker, "None ticked: every view with blocking."] : null,
["Comment", comment],
], () => ({ name: name.value.trim(), members: splitMembers(members.value), step: step.value, views: vp.value(), comment: comment.value }));
}
+33 -10
View File
@@ -43,28 +43,50 @@ async function draftPanel(d) {
let valid = false; let valid = false;
const applyBtn = h("button", { class: "primary", disabled: true }, "Apply changes"); const applyBtn = h("button", { class: "primary", disabled: true }, "Apply changes");
const forceBox = h("label", { class: "check small hidden" }, h("input", { type: "checkbox" }), "Overwrite files changed outside Jarl"); const forceBox = h("label", { class: "check small hidden" }, h("input", { type: "checkbox" }), "Overwrite files changed outside Jarl");
// Changes that open a public view up must be confirmed (docs/exposure.md).
const riskBox = h("label", { class: "check risk-confirm hidden" }, h("input", { type: "checkbox" }), "");
let risky = false;
riskBox.querySelector("input").addEventListener("change", () => { applyBtn.disabled = !valid || (risky && !riskBox.querySelector("input").checked); });
const ops = d.ops || []; const ops = d.ops || [];
const removeOp = async (seq) => {
try {
const cs = await api("DELETE", "/changesets/" + d.id + "/ops/" + seq);
useDraft(cs);
rerender();
} catch (e) { toast(e.message, "err", 8000); }
};
const opsTable = table([ const opsTable = table([
{ label: "#", render: (o) => String(o.seq) }, { label: "#", render: (o) => String(o.seq) },
{ label: "Change", render: (o) => o.summary || o.op, tdClass: "mono small" }, { label: "Change", render: (o) => o.summary || o.op, tdClass: "mono small" },
{ label: "Added", render: (o) => time(o.added_at) }, { label: "Added", render: (o) => time(o.added_at) },
{ label: "", render: (o) => h("button", { class: "small", title: "Remove this operation from the changeset", onclick: async () => { { label: "", render: (o) => h("button", { class: "small", title: "Remove this operation from the changeset", onclick: () => removeOp(o.seq) }, "Remove"), tdClass: "actions-cell" },
try {
const cs = await api("DELETE", "/changesets/" + d.id + "/ops/" + o.seq);
useDraft(cs);
rerender();
} catch (e) { toast(e.message, "err", 8000); }
} }, "Remove"), tdClass: "actions-cell" },
], ops); ], ops);
// failedOp marks the operation that no longer applies and says how to
// get past it.
const failedOp = (err) => {
const i = ops.findIndex((o) => o.seq === err.op);
const rows = opsTable.querySelectorAll("tbody tr");
if (i < 0 || !rows[i]) return errorBox({ message: err.message, details: err });
rows[i].classList.add("op-failed");
return h("div", { class: "msg err" },
h("div", null, h("strong", null, "Operation " + err.op + " can't be applied: "), err.message),
h("div", { class: "mono small" }, ops[i].summary || ops[i].op),
h("div", { style: { marginTop: "8px" } },
h("button", { class: "small", onclick: () => removeOp(err.op) }, "Remove operation " + err.op),
" ", h("span", { class: "muted small" }, "It is marked in the list above. Then check the rest again.")));
};
const runPreview = async () => { const runPreview = async () => {
try { try {
const p = await get("/changesets/" + d.id + "/preview"); const p = await get("/changesets/" + d.id + "/preview");
valid = p.valid && !p.error; valid = p.valid && !p.error;
applyBtn.disabled = !valid; risky = (p.risks || []).length > 0;
riskBox.classList.toggle("hidden", !risky);
if (risky) riskBox.lastChild.textContent = " I understand: " + p.risks.map((x) => x.message).join("; ");
applyBtn.disabled = !valid || (risky && !riskBox.querySelector("input").checked);
const drift = (p.drift || []); const drift = (p.drift || []);
forceBox.classList.toggle("hidden", drift.length === 0); forceBox.classList.toggle("hidden", drift.length === 0);
mount(preview, mount(preview,
p.error ? errorBox({ message: p.error.message, details: p.error }) : null, p.error ? (p.error.op ? failedOp(p.error) : errorBox({ message: p.error.message, details: p.error })) : null,
valid ? h("div", { class: "msg ok" }, "Valid. named-checkconf accepts the new configuration.", (p.serials || []).length ? " Serials: " + p.serials.map((s) => s.zone.replace(/\.$/, "") + " " + s.old + "→" + s.new).join(", ") : "") : null, valid ? h("div", { class: "msg ok" }, "Valid. named-checkconf accepts the new configuration.", (p.serials || []).length ? " Serials: " + p.serials.map((s) => s.zone.replace(/\.$/, "") + " " + s.old + "→" + s.new).join(", ") : "") : null,
!p.error && !valid ? h("div", { class: "msg err" }, "This changeset can't be applied yet:") : null, !p.error && !valid ? h("div", { class: "msg err" }, "This changeset can't be applied yet:") : null,
problemsList(p.problems), problemsList(p.problems),
@@ -82,7 +104,7 @@ async function draftPanel(d) {
try { try {
if (desc.value !== (d.description || "")) await api("PATCH", "/changesets/" + d.id, { body: { description: desc.value } }); if (desc.value !== (d.description || "")) await api("PATCH", "/changesets/" + d.id, { body: { description: desc.value } });
const force = forceBox.querySelector("input").checked; const force = forceBox.querySelector("input").checked;
const r = await api("POST", "/changesets/" + d.id + "/apply", { body: { force } }); const r = await api("POST", "/changesets/" + d.id + "/apply", { body: { force, accept_risk: riskBox.querySelector("input").checked } });
const res = r.result || {}; const res = r.result || {};
toast("Applied changeset #" + d.id + (res.reloaded && res.reloaded.length ? " — named reloaded" : "")); toast("Applied changeset #" + d.id + (res.reloaded && res.reloaded.length ? " — named reloaded" : ""));
(r.warnings || []).forEach((w) => toast(w, "warn", 10000)); (r.warnings || []).forEach((w) => toast(w, "warn", 10000));
@@ -104,6 +126,7 @@ async function draftPanel(d) {
h("h2", null, "Changeset #" + d.id + " ", badge(plural(ops.length, "operation"), "warn")), h("h2", null, "Changeset #" + d.id + " ", badge(plural(ops.length, "operation"), "warn")),
desc, desc,
h("div", { style: { marginTop: "10px" } }, opsTable), h("div", { style: { marginTop: "10px" } }, opsTable),
riskBox,
h("div", { class: "actions", style: { marginTop: "12px" } }, applyBtn, discard, forceBox), h("div", { class: "actions", style: { marginTop: "12px" } }, applyBtn, discard, forceBox),
result), result),
h("div", { class: "panel" }, h("h2", null, "Review"), preview)); h("div", { class: "panel" }, h("h2", null, "Review"), preview));
+12 -1
View File
@@ -2,7 +2,7 @@ import { h, mount, time, fmtTime, relTime, problemsList, badge, plural } from ".
import { get, state } from "../api.js"; import { get, state } from "../api.js";
export async function render(el) { export async function render(el) {
const [st, mine] = await Promise.all([get("/status"), get("/changesets", { status: "open", mine: 1, limit: 50 }).catch(() => [])]); const [st, mine, exposure] = await Promise.all([get("/status"), get("/changesets", { status: "open", mine: 1, limit: 50 }).catch(() => []), get("/exposure").catch(() => null)]);
const s = st.engine || {}; const s = st.engine || {};
// The user's own changesets with something in them: what the Changes page shows. // The user's own changesets with something in them: what the Changes page shows.
const pending = mine.filter((c) => (c.ops || []).length).length; const pending = mine.filter((c) => (c.ops || []).length).length;
@@ -16,6 +16,7 @@ export async function render(el) {
card("Access", plural((s.counts || {}).acls || 0, "ACL"), plural((s.counts || {}).tsig_keys || 0, "TSIG key")), card("Access", plural((s.counts || {}).acls || 0, "ACL"), plural((s.counts || {}).tsig_keys || 0, "TSIG key")),
card("Options", (s.counts || {}).options ?? "–", "server, view, zone and top-level"), card("Options", (s.counts || {}).options ?? "–", "server, view, zone and top-level"),
st.backup ? backupCard(st.backup) : null, st.backup ? backupCard(st.backup) : null,
exposure ? exposureCard(exposure) : null,
); );
const last = s.last_applied; const last = s.last_applied;
const parts = [h("div", { class: "page-head" }, const parts = [h("div", { class: "page-head" },
@@ -106,6 +107,16 @@ function backupCard(b) {
return card("Backup", value, sub); return card("Backup", value, sub);
} }
// exposureCard sums up the internet exposure page.
function exposureCard(r) {
const pub = r.scopes.filter((s) => s.public);
let warn = r.server.filter((f) => f.severity === "warning").length;
for (const s of r.scopes) warn += s.findings.filter((f) => f.severity === "warning").length;
const value = !pub.length ? badge("nothing public", "ok") : warn ? badge(plural(warn, "warning"), "warn") : badge("hardened", "ok");
const sub = pub.length ? "public: " + pub.map((s) => s.view || "the server").join(", ") : "no view is reachable from the internet";
return h("a", { class: "card link", href: "#/exposure" }, h("div", { class: "label" }, "Exposure"), h("div", { class: "value" }, value), h("div", { class: "sub" }, sub));
}
function card(label, value, sub) { function card(label, value, sub) {
return h("div", { class: "card" }, h("div", { class: "label" }, label), h("div", { class: "value" }, value), sub ? h("div", { class: "sub" }, sub) : null); return h("div", { class: "card" }, h("div", { class: "label" }, label), h("div", { class: "value" }, value), sub ? h("div", { class: "sub" }, sub) : null);
} }
@@ -0,0 +1,120 @@
// Internet exposure (docs/exposure.md): which views are reachable from
// the internet, why, and what to harden, with fixes staged like any edit
// and findings that can be accepted on purpose.
import { h, mount, table, toast, badge, modal, input, field, errorBox, select, plural } from "../ui.js";
import { get, change, describeResult, can } from "../api.js";
import { rerender } from "../app.js";
const sevKind = { warning: "warn", error: "err", info: "" };
export async function render(el) {
const r = await get("/exposure");
const writable = can("options", true);
const parts = [h("div", { class: "page-head" },
h("div", { class: "grow" }, h("h1", null, "Internet exposure"),
h("div", { class: "muted" }, "Which views can be reached from the internet, and what to harden on them. A view is public when its match-clients let in anyone (they end in any), whatever kind of addresses your own networks use. Fixes are staged as pending changes for you to review.")))];
parts.push(h("div", { class: "panel" }, h("h2", null, "Views"), table([
{ label: "View", render: (s) => s.view ? h("a", { href: "#/views/" + s.view_id }, s.view) : h("span", { class: "muted" }, "the server (no views)") },
{ label: "Exposure", render: (s) => [badge(s.public ? "public" : "private", s.public ? "warn" : "ok"), s.override ? h("span", { class: "muted small" }, " set by hand") : null] },
{ label: "Why", render: (s) => h("span", { class: "small" }, s.reason) },
{ label: "Rate limiting", render: (s) => s.public ? badge(s.rate_limit, { off: "err", "log-only": "warn", enforcing: "ok" }[s.rate_limit]) : h("span", { class: "muted" }, "–") },
{ label: "", render: (s) => writable ? overrideSelect(s) : null, tdClass: "actions-cell" },
], r.scopes)));
const all = [];
for (const s of r.scopes) for (const f of s.findings) all.push({ ...f, scope: s.view || "server", viewID: s.view_id });
for (const f of r.server) all.push({ ...f, scope: "server", viewID: 0 });
parts.push(h("div", { class: "panel" }, h("h2", null, "Findings ", badge(plural(all.filter((f) => f.severity === "warning").length, "warning"), all.some((f) => f.severity === "warning") ? "warn" : "ok")),
all.length ? h("div", { class: "findings" }, all.map((f) => findingRow(f, writable))) : h("div", { class: "empty" }, r.scopes.some((s) => s.public) ? "Nothing to harden." : "No view is reachable from the internet, so there is nothing to check.")));
if (r.rate_limiting) parts.push(activity(r.rate_limiting, r.scopes));
mount(el, parts);
}
function overrideSelect(s) {
const sel = select([["", "Work it out"], ["public", "Public"], ["private", "Private"]], s.override || "");
sel.title = "Override the classification when Jarl gets it wrong (e.g. a port forward, where named only sees private addresses)";
sel.addEventListener("change", async () => {
try {
const r = s.view_id
? await change("PATCH", "/views/" + s.view_id, { exposure: sel.value || "auto" })
: await change("PATCH", "/settings", { exposure: sel.value || "auto" });
toast(describeResult(r));
rerender();
} catch (e) { toast(e.message, "err", 8000); }
});
return sel;
}
function findingRow(f, writable) {
const acked = f.acknowledged;
const actions = writable ? h("div", { class: "actions" },
f.fix && !acked ? h("button", { class: "small primary", title: f.fix, onclick: () => fix(f) }, "Fix") : null,
acked
? h("button", { class: "small", onclick: () => unack(f) }, "Take back")
: h("button", { class: "small", onclick: () => ack(f) }, "Acknowledge")) : null;
return h("div", { class: "finding " + (acked ? "acked" : f.severity) },
h("div", { class: "grow" },
h("div", null, badge(acked ? "acknowledged" : f.severity, acked ? "" : sevKind[f.severity]), " ", h("strong", null, f.scope), " ", h("span", { class: "mono small muted" }, f.code)),
h("div", null, f.message),
f.fix && !acked ? h("div", { class: "small muted" }, "Fix: " + f.fix) : null),
actions);
}
async function fix(f) {
try {
const r = await change("POST", "/exposure/fix", { view_id: f.viewID, code: f.code });
toast(describeResult(r));
(r.warnings || []).forEach((w) => toast(w, "warn", 8000));
rerender();
} catch (e) { toast(e.message, "err", 10000); }
}
async function ack(f) {
const reason = input({ placeholder: "e.g. public resolver for the club's network, monitored", style: { width: "100%" } });
const err = h("div");
await modal("Acknowledge " + f.code, h("div", null,
h("p", null, f.message),
h("p", { class: "muted small" }, "The finding stays visible, as info, with your name, the date and your reason. Jarl warns again if it comes up somewhere else."),
field("Why is this accepted?", reason), err), [
{ label: "Cancel", value: null },
{ label: "Acknowledge", kind: "primary", onclick: async () => {
try {
const r = await change("POST", "/exposure/ack", { view_id: f.viewID, code: f.code, reason: reason.value.trim() });
toast(describeResult(r));
rerender();
return true;
} catch (e) { mount(err, errorBox(e)); return false; }
} },
]);
}
async function unack(f) {
try {
const r = await change("POST", "/exposure/unack", { view_id: f.viewID, code: f.code });
toast(describeResult(r));
rerender();
} catch (e) { toast(e.message, "err", 8000); }
}
// activity shows what rate limiting limited, or would have in log-only
// mode, so you can check nobody legitimate is hit before enforcing.
function activity(a, scopes) {
const counts = Object.fromEntries((a.counts || []).map((e) => [e.key, e.count]));
const would = (counts["would-drop"] || 0) + (counts["would-slip"] || 0);
const did = (counts["drop"] || 0) + (counts["slip"] || 0);
const logOnly = scopes.some((s) => s.public && s.rate_limit === "log-only");
const any = scopes.some((s) => s.public && s.rate_limit !== "off");
if (!any) return null;
return h("div", { class: "panel" }, h("h2", null, "Rate limiting, last " + a.since),
!would && !did ? h("div", { class: "empty" }, "Nothing was limited" + (logOnly ? " (or would have been)" : "") + ". If you just turned it on, give it a day.") : [
h("p", null,
did ? plural(did, "response") + " limited (" + (counts["drop"] || 0) + " dropped, " + (counts["slip"] || 0) + " slipped). " : "",
would ? plural(would, "response") + " would have been limited in log-only mode (" + (counts["would-drop"] || 0) + " dropped, " + (counts["would-slip"] || 0) + " slipped)." : ""),
h("p", { class: "muted small" }, "Dropped responses get no answer; slipped ones get a short reply asking the client to retry over TCP (or with a DNS cookie), which a real client does and a spoofed victim of an attack never sees."),
logOnly ? h("p", { class: "muted small" }, "Check the networks below: your secondaries, monitoring, or a big shared resolver shouldn't be among them. If they are, add them to exempt-clients; otherwise start enforcing.") : null,
h("div", { class: "chart-grid" },
table([{ label: "Client network (limited or would be)", render: (e) => h("span", { class: "mono" }, e.key) }, { label: "Responses", render: (e) => String(e.count) }], a.networks || []),
table([{ label: "Name and type", render: (e) => h("span", { class: "mono" }, e.key) }, { label: "Responses", render: (e) => String(e.count) }], a.names || []))]);
}
+8 -4
View File
@@ -25,15 +25,19 @@ function valueWidget(stmt, value, ctx) {
if (kind === "address_match_list") { if (kind === "address_match_list") {
const inner = (value || "").replace(/^\s*\{\s*/, "").replace(/\s*\}\s*$/, ""); const inner = (value || "").replace(/^\s*\{\s*/, "").replace(/\s*\}\s*$/, "");
const t = textarea(inner.split(";").map((s) => s.trim()).filter(Boolean).join("\n"), { class: "mono", rows: 4, placeholder: "192.168.1.0/24\n!192.168.1.66\nkey xfer-key\nlocalhost" }); const t = textarea(inner.split(";").map((s) => s.trim()).filter(Boolean).join("\n"), { class: "mono", rows: 4, placeholder: "192.168.1.0/24\n!192.168.1.66\nkey xfer-key\nlocalhost" });
return { el: h("div", null, t, ctx ? suggestions(t, ctx) : null), get: () => "{ " + lines(t.value).map((l) => l.replace(/;$/, "") + ";").join(" ") + " }", hint: "One element per line: address or prefix, !negated, key NAME, ACL name, any, none, localhost, localnets. First match wins." }; return { el: h("div", null, t, ctx ? suggestions(t, ctx) : null), get: () => "{ " + lines(t.value).map((l) => l.replace(/;$/, "") + ";").join(" ") + " }", hint: "One element per line: address, prefix or range (10.0.10.101-10.0.10.199), !negated, key NAME, ACL name, any, none, localhost, localnets. First match wins." };
} }
if (kind === "raw") { if (kind === "raw") {
const t = codeArea(value || ""); const t = codeArea(value || "");
// Statements such as allow-transfer and listen-on take an address // Statements such as allow-transfer and listen-on take an address
// match list after optional parameters: suggest names at the cursor. // match list after optional parameters: suggest names at the cursor.
const takesList = stmt && /address_match_element/.test(stmt.grammar || ""); // Names are suggested wherever the grammar has an address match list
// (rate-limit's exempt-clients too); the braces are only optional for
// statements that end in the list (catalog WrapList).
const hasAML = stmt && /address_match_element/.test(stmt.grammar || "");
const takesList = stmt && /\{ <address_match_element>; \.\.\. \};$/.test((stmt.grammar || "").split("\n")[0]);
if (takesList && !value) t.placeholder = "homelab; key xfer-key"; if (takesList && !value) t.placeholder = "homelab; key xfer-key";
return { el: h("div", null, t, takesList && ctx ? suggestions(t, ctx, { mode: "inline" }) : null), get: () => t.value.trim(), return { el: h("div", null, t, hasAML && ctx ? suggestions(t, ctx, { mode: "inline" }) : null), get: () => t.value.trim(),
hint: takesList ? "The { } around the list are optional: 10.0.0.53; key xfer-key becomes { 10.0.0.53; key xfer-key; }." : null }; hint: takesList ? "The { } around the list are optional: 10.0.0.53; key xfer-key becomes { 10.0.0.53; key xfer-key; }." : null };
} }
const i = input({ value: value || "", class: "mono" }); const i = input({ value: value || "", class: "mono" });
@@ -83,7 +87,7 @@ export async function optionsEditor(el, { scope, scope_id, catalogScope, title,
const draw = () => { const draw = () => {
mount(listEl, table([ mount(listEl, table([
{ label: "Option", render: (o) => h("span", null, h("strong", { class: "mono" }, o.name), o.status ? [" ", badge(o.status, "warn")] : null), tdClass: "nowrap" }, { label: "Option", render: (o) => h("span", null, h("strong", { class: "mono" }, o.name), o.status ? [" ", badge(o.status, "warn")] : null), tdClass: "nowrap" },
{ label: "Value", render: (o) => editing === o.id ? editRow(o) : h("pre", { class: "code", style: { padding: "2px 6px", whiteSpace: "pre-wrap" } }, o.formatted || o.value || "(no value)") }, { label: "Value", render: (o) => editing === o.id ? editRow(o) : h("pre", { class: "code optval" }, o.formatted || o.value || "(no value)") },
{ label: "Description", render: (o) => h("span", { class: "muted small" }, o.description || ""), tdClass: "small" }, { label: "Description", render: (o) => h("span", { class: "muted small" }, o.description || ""), tdClass: "small" },
{ label: "", render: (o) => editing === o.id ? "" : h("span", null, { label: "", render: (o) => editing === o.id ? "" : h("span", null,
h("button", { class: "small", onclick: () => { editing = o.id; draw(); } }, "Edit"), " ", h("button", { class: "small", onclick: () => { editing = o.id; draw(); } }, "Edit"), " ",
+1 -1
View File
@@ -128,7 +128,7 @@ async function viewForm(v, viewless) {
const adopt = checkbox("Move the " + viewless.length + " zone(s) that are not in a view into this view", true); const adopt = checkbox("Move the " + viewless.length + " zone(s) that are not in a view into this view", true);
const body = h("div", null, h("div", { class: "form-grid" }, const body = h("div", null, h("div", { class: "form-grid" },
field("Name", name), field("Name", name),
field("Match clients", h("div", null, mc, suggestions(mc, ctx)), "One element per line: addresses, prefixes, ACL names, key NAME, any/none/localhost/localnets; !negates. First match wins."), field("Match clients", h("div", null, mc, suggestions(mc, ctx)), "One element per line: addresses, prefixes, ranges (10.0.10.101-10.0.10.199), ACL names, key NAME, any/none/localhost/localnets; !negates. First match wins."),
field("Match destinations", h("div", null, md, suggestions(md, ctx)), "Server addresses this view answers on (optional)."), field("Match destinations", h("div", null, md, suggestions(md, ctx)), "Server addresses this view answers on (optional)."),
field("Recursive only", mro, "If yes, only recursive queries match this view."), field("Recursive only", mro, "If yes, only recursive queries match this view."),
field("Comment", comment), field("Comment", comment),
+1 -1
View File
@@ -482,7 +482,7 @@ async function recordsTab(el, zone, query) {
{ label: "Comment", render: (r) => [r.pending ? [pendingBadge(r.pending), " "] : null, h("span", { class: "muted" }, r.comment || "")] }, { label: "Comment", render: (r) => [r.pending ? [pendingBadge(r.pending), " "] : null, h("span", { class: "muted" }, r.comment || "")] },
{ label: "", render: (r) => r.pending === "delete" ? "" : h("span", null, { label: "", render: (r) => r.pending === "delete" ? "" : h("span", null,
!reverse && (r.type === "A" || r.type === "AAAA") && !r.disabled !reverse && (r.type === "A" || r.type === "AAAA") && !r.disabled
? [h("button", { class: "small", title: "Add a CNAME record that points to " + r.name.replace(/\.$/, ""), onclick: () => aliasTo(r) }, "+ CNAME"), " "] : null, ? [h("button", { class: "small reveal", title: "Add a CNAME record that points to " + r.name.replace(/\.$/, ""), onclick: () => aliasTo(r) }, "+ CNAME"), " "] : null,
h("button", { class: "small", title: "Edit", onclick: () => edit(r) }, "Edit"), " ", h("button", { class: "small", title: "Edit", onclick: () => edit(r) }, "Edit"), " ",
r.disabled r.disabled
? h("button", { class: "small toggle", title: "Put the record back in the zone", onclick: () => toggle(r) }, "Enable") ? h("button", { class: "small toggle", title: "Put the record back in the zone", onclick: () => toggle(r) }, "Enable")
+39
View File
@@ -154,6 +154,8 @@ td.actions-cell { white-space: nowrap; text-align: right; width: 1%; }
tr.editing td { background: var(--accent-soft); } tr.editing td { background: var(--accent-soft); }
tr.pending-add td { background: var(--add); } tr.pending-add td { background: var(--add); }
tr.pending-change td, tr.pending-disable td, tr.pending-enable td { background: var(--warn-soft); } tr.pending-change td, tr.pending-disable td, tr.pending-enable td { background: var(--warn-soft); }
tr.op-failed td { background: var(--err-soft); }
tr.op-failed td:first-child { box-shadow: inset 3px 0 0 var(--err); }
tr.pending-delete td { background: var(--del); text-decoration: line-through; color: var(--muted); } tr.pending-delete td { background: var(--del); text-decoration: line-through; color: var(--muted); }
tr.disabled td:not(.actions-cell), tr.disabled td strong { color: var(--muted); } tr.disabled td:not(.actions-cell), tr.disabled td strong { color: var(--muted); }
/* Disabled records: hatched, so they stand out from live and pending rows. */ /* Disabled records: hatched, so they stand out from live and pending rows. */
@@ -320,6 +322,18 @@ td.break { word-break: break-all; }
} }
.refreshing { opacity: 0.6; transition: opacity 0.2s; } .refreshing { opacity: 0.6; transition: opacity 0.2s; }
/* Secondary row actions show when the row is hovered or focused; touch
screens, which can't hover, always show them. Opacity keeps them in the
layout (no shifting) and reachable with Tab. */
tr .reveal { opacity: 0; transition: opacity 0.1s; }
tr:hover .reveal, tr:focus-within .reveal { opacity: 1; }
@media (hover: none) { tr .reveal { opacity: 1; } }
/* Blocking steps, in order, on the Blocking page. */
.steps-line { display: flex; flex-wrap: wrap; align-items: center; gap: 4px; margin: 4px 0 10px; }
.step-chip { display: inline-flex; align-items: center; gap: 4px; padding: 2px 8px; border: 1px solid var(--border); border-radius: 12px; font-family: var(--mono); font-size: 13px; background: var(--panel); }
.step-chip.accent { border-color: var(--accent); }
.step-tools { display: inline-flex; gap: 2px; margin-left: 2px; }
.step-tools button { padding: 0 5px; min-width: 0; }
h2.section { margin: 24px 0 10px; font-size: 18px; } h2.section { margin: 24px 0 10px; font-size: 18px; }
.filter-row { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin-bottom: 12px; } .filter-row { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin-bottom: 12px; }
.segmented { display: inline-flex; border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden; background: var(--panel); } .segmented { display: inline-flex; border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden; background: var(--panel); }
@@ -412,6 +426,11 @@ td.num, th.num { text-align: right; font-variant-numeric: tabular-nums; }
button.chip { padding: 1px 8px; font-size: 12px; font-family: var(--mono); border-radius: 999px; background: var(--info-soft); border: 1px solid var(--border); color: var(--text); } button.chip { padding: 1px 8px; font-size: 12px; font-family: var(--mono); border-radius: 999px; background: var(--info-soft); border: 1px solid var(--border); color: var(--text); }
button.chip:hover { background: var(--accent-soft); border-color: var(--accent); } button.chip:hover { background: var(--accent-soft); border-color: var(--accent); }
.aml-problems .msg { margin: 4px 0 0; padding: 3px 8px; } .aml-problems .msg { margin: 4px 0 0; padding: 3px 8px; }
.range-hints .msg { margin: 4px 0 0; padding: 3px 8px; }
.range-hint { margin-top: 4px; color: var(--muted); }
.range-hint summary { cursor: pointer; }
.range-hint .prefixes { display: flex; flex-wrap: wrap; gap: 2px 12px; margin: 2px 0 0 14px; }
.range-hint .prefixes span { white-space: nowrap; }
/* Zone name and record previews. */ /* Zone name and record previews. */
.segmented button { border: none; border-right: 1px solid var(--border); border-radius: 0; background: transparent; padding: 4px 12px; color: var(--text); } .segmented button { border: none; border-right: 1px solid var(--border); border-radius: 0; background: transparent; padding: 4px 12px; color: var(--text); }
@@ -470,3 +489,23 @@ details.view-picker summary { cursor: pointer; padding: 5px 8px; border: 1px sol
details.view-picker[open] .view-picker-list { position: absolute; z-index: 20; top: calc(100% + 4px); left: 0; min-width: 14em; max-height: 260px; overflow-y: auto; display: flex; flex-direction: column; gap: 4px; padding: 8px 10px; background: var(--panel); border: 1px solid var(--border); border-radius: var(--radius); box-shadow: var(--shadow); } details.view-picker[open] .view-picker-list { position: absolute; z-index: 20; top: calc(100% + 4px); left: 0; min-width: 14em; max-height: 260px; overflow-y: auto; display: flex; flex-direction: column; gap: 4px; padding: 8px 10px; background: var(--panel); border: 1px solid var(--border); border-radius: var(--radius); box-shadow: var(--shadow); }
ul.plain li { padding: 3px 0; } ul.plain li { padding: 3px 0; }
.query-details { margin: 8px 0; } .query-details { margin: 8px 0; }
/* Internet exposure findings */
.findings { display: flex; flex-direction: column; gap: 8px; }
.finding { display: flex; gap: 12px; align-items: flex-start; padding: 10px 12px; border: 1px solid var(--line); border-left-width: 3px; border-radius: 6px; }
.finding.warning { border-left-color: var(--warn); }
.finding.error { border-left-color: var(--err); }
.finding.acked, .finding.info { border-left-color: var(--line); color: var(--muted); }
.finding .actions { flex-shrink: 0; }
.risk-confirm { margin-top: 8px; }
a.card.link { color: inherit; text-decoration: none; }
a.card.link:hover { border-color: var(--accent, var(--link)); }
/* An option's value as shown: laid out like the editor (no wrapping, a
long line scrolls; same padding and an invisible border), so the text
stays put when Edit opens it. */
pre.optval { padding: 5px 8px; border: 1px solid transparent; background: none; white-space: pre; overflow-x: auto; max-width: min(72ch, 50vw); }
/* ...and the editor in a table cell gets the same width, so the column
doesn't jump when Edit opens. */
td textarea.code { min-width: min(72ch, 50vw); }
+155 -2
View File
@@ -13,7 +13,7 @@ set -u
ROOT=$(cd "$(dirname "$0")/.." && pwd) ROOT=$(cd "$(dirname "$0")/.." && pwd)
WORK=${WORK:-$(mktemp -d /tmp/jarl-e2e.XXXXXX)} WORK=${WORK:-$(mktemp -d /tmp/jarl-e2e.XXXXXX)}
E=$WORK E=$WORK
DNS=${DNS_PORT:-25353}; CTL=${CTL_PORT:-25953}; HTTP=${HTTP_PORT:-28053}; LISTS=${LISTS_PORT:-28054} DNS=${DNS_PORT:-25353}; CTL=${CTL_PORT:-25953}; HTTP=${HTTP_PORT:-28053}; LISTS=${LISTS_PORT:-28054}; METRICS=${METRICS_PORT:-28055}
if [ -n "${JARL_BIND_ROOT:-}" ]; then if [ -n "${JARL_BIND_ROOT:-}" ]; then
B=$JARL_BIND_ROOT B=$JARL_BIND_ROOT
export LD_LIBRARY_PATH=$(echo $B/usr/lib/*-linux-gnu | cut -d' ' -f1) export LD_LIBRARY_PATH=$(echo $B/usr/lib/*-linux-gnu | cut -d' ' -f1)
@@ -108,6 +108,10 @@ bind:
rndc_args: ["-k", "$E/legacy/rndc.key", "-s", "127.0.0.1", "-p", "$CTL"] rndc_args: ["-k", "$E/legacy/rndc.key", "-s", "127.0.0.1", "-p", "$CTL"]
backup: backup:
dir: $E/backups dir: $E/backups
metrics:
enabled: true
listen: ["127.0.0.1:$METRICS"]
token: e2e-metrics-token-0123456789
EOF EOF
cd $E cd $E
echo "secret-pass-123" | $JARLD -c jarl.yaml init --password-stdin --token --nameservers ns1.adbraun.test. --contact admin@adbraun.test > init.out 2>&1 || { cat init.out; exit 1; } echo "secret-pass-123" | $JARLD -c jarl.yaml init --password-stdin --token --nameservers ns1.adbraun.test. --contact admin@adbraun.test > init.out 2>&1 || { cat init.out; exit 1; }
@@ -223,6 +227,37 @@ run "changeset apply" 0 $J changeset apply $CS
expect_dig "staged now live" "192.168.1.80" staged.adbraun.test A expect_dig "staged now live" "192.168.1.80" staged.adbraun.test A
REV=$($J changeset revert $CS -q) REV=$($J changeset revert $CS -q)
run "apply revert changeset" 0 $J changeset apply $REV run "apply revert changeset" 0 $J changeset apply $REV
# JARL_CHANGESET: staging and the changeset commands default to it, and
# it is checked before it is used.
export JARL_CHANGESET=$($J changeset create "e2e env" -q)
ENVCS=$JARL_CHANGESET
run "stage via JARL_CHANGESET" 0 $J record add adbraun.test envstaged A 192.168.1.81
contains "changeset $ENVCS (e2e env, 0 operations) from JARL_CHANGESET"
expect_dig "env-staged not live yet" "" envstaged.adbraun.test A
run "changeset show defaults to it" 0 $J changeset show
contains "envstaged"
run "changeset diff defaults to it" 0 $J changeset diff
contains "apply with 'jarl changeset apply'"
OTHER=$($J changeset create "e2e other" -q)
run "-C wins over JARL_CHANGESET" 0 $J changeset show -C $OTHER
contains "e2e other"
run "an ID argument wins too" 0 $J changeset discard $OTHER
run "changeset apply defaults to it" 0 $J changeset apply
contains "unset JARL_CHANGESET"
expect_dig "env-staged live" "192.168.1.81" envstaged.adbraun.test A
run "staging into the applied changeset is refused" 1 $J record add adbraun.test envlate A 192.168.1.82
contains "changeset $ENVCS is applied"
run "applying it again is refused" 1 $J changeset apply
contains "is applied"
export JARL_CHANGESET=999999
run "a changeset that doesn't exist is explained" 1 $J record add adbraun.test envlate A 192.168.1.82
contains "there is no changeset 999999"
run "reads don't care about a stale JARL_CHANGESET" 0 $J zone list
unset JARL_CHANGESET
expect_dig "nothing was staged or applied" "" envlate.adbraun.test A
run "changeset apply without an ID or JARL_CHANGESET" 1 $J changeset apply
contains "which changeset?"
run "clean up envstaged" 0 $J record delete adbraun.test envstaged A --yes
expect_dig "staged reverted" "" staged.adbraun.test A expect_dig "staged reverted" "" staged.adbraun.test A
run "history" 0 $J history run "history" 0 $J history
contains "e2e batch" contains "e2e batch"
@@ -235,6 +270,26 @@ contains "172.16.0.0/12"
run "acl delete in-use refused" 5 sh -c "$J option set allow-transfer '{ trusted; }' --zone adbraun.test && $J acl delete trusted" run "acl delete in-use refused" 5 sh -c "$J option set allow-transfer '{ trusted; }' --zone adbraun.test && $J acl delete trusted"
run "option unset" 0 $J option unset allow-transfer --zone adbraun.test run "option unset" 0 $J option unset allow-transfer --zone adbraun.test
run "acl delete" 0 $J acl delete trusted run "acl delete" 0 $J acl delete trusted
# Address ranges: kept as written, written to named.conf as prefixes.
run "acl with ranges" 0 $J acl create ranged "127.0.0.2 - 127.0.0.3" 2001:db8::100-2001:db8::1ff
run "acl list keeps the ranges" 0 $J acl list
contains "127.0.0.2-127.0.0.3"
run "named.conf has the prefixes" 0 grep -A4 'acl "ranged"' $E/etc-jarl/named.conf
contains "127.0.0.2/31;"
contains "2001:db8::100/120;"
contains "// 127.0.0.2-127.0.0.3"
run "a reversed range is refused (exit 6)" 6 $J acl create badrange 127.0.0.9-127.0.0.1
contains "start is after the end"
run "zone allow-query by the range" 0 $J option set allow-query '{ ranged; }' --zone adbraun.test
range_dig() { $DIG +tries=1 +time=3 -b "$1" -p $DNS @127.0.0.1 ns1.adbraun.test A | awk '/status:/ {sub(",", "", $6); print $6}'; }
for src in 127.0.0.2 127.0.0.3; do
if [ "$(range_dig $src)" = NOERROR ]; then ok "range: $src may query"; else bad "range: $src got $(range_dig $src)"; fi
done
for src in 127.0.0.1 127.0.0.4; do
if [ "$(range_dig $src)" = REFUSED ]; then ok "range: $src is refused"; else bad "range: $src got $(range_dig $src)"; fi
done
run "unset the zone's allow-query" 0 $J option unset allow-query --zone adbraun.test
run "acl delete ranged" 0 $J acl delete ranged
run "tsig create" 0 $J tsig create xfer-test run "tsig create" 0 $J tsig create xfer-test
contains "secret:" contains "secret:"
run "tsig rotate" 0 $J tsig rotate xfer-test run "tsig rotate" 0 $J tsig rotate xfer-test
@@ -261,6 +316,13 @@ run "acme set again (idempotent)" 0 $J $ACME acme set adbraun.test value-one
run "acme set wildcard second value" 0 $J $ACME acme set '*.adbraun.test' value-two run "acme set wildcard second value" 0 $J $ACME acme set '*.adbraun.test' value-two
expect_dig "acme TXT both" '"value-one" "value-two"' _acme-challenge.adbraun.test TXT expect_dig "acme TXT both" '"value-one" "value-two"' _acme-challenge.adbraun.test TXT
run "acme token cannot touch A records (exit 3)" 3 $J $ACME record add adbraun.test evil A 192.168.1.66 run "acme token cannot touch A records (exit 3)" 3 $J $ACME record add adbraun.test evil A 192.168.1.66
# Someone else's changeset in JARL_CHANGESET isn't used, even by an admin.
THEIRS=$($J $ACME changeset create "certbot's" -q)
run "another user's changeset from JARL_CHANGESET is refused" 1 env JARL_CHANGESET=$THEIRS $J record add adbraun.test notmine A 192.168.1.83
contains "is certbot's"
run "applying it from JARL_CHANGESET is refused" 1 env JARL_CHANGESET=$THEIRS $J changeset apply
run "-C uses it anyway (admin)" 0 $J changeset show -C $THEIRS
run "discard certbot's changeset" 0 $J changeset discard $THEIRS
run "certbot hooks" 0 env CERTBOT_DOMAIN=nas.adbraun.test CERTBOT_VALIDATION=cb-value $J $ACME acme certbot-auth run "certbot hooks" 0 env CERTBOT_DOMAIN=nas.adbraun.test CERTBOT_VALIDATION=cb-value $J $ACME acme certbot-auth
expect_dig "certbot TXT" '"cb-value"' _acme-challenge.nas.adbraun.test TXT expect_dig "certbot TXT" '"cb-value"' _acme-challenge.nas.adbraun.test TXT
run "certbot cleanup" 0 env CERTBOT_DOMAIN=nas.adbraun.test CERTBOT_VALIDATION=cb-value $J $ACME acme certbot-cleanup run "certbot cleanup" 0 env CERTBOT_DOMAIN=nas.adbraun.test CERTBOT_VALIDATION=cb-value $J $ACME acme certbot-cleanup
@@ -304,6 +366,45 @@ expect_dig "unsigned from here: the internal view" "127.0.0.1" ns1.adbraun.test
expect_dig "signed with the external view's key" "203.0.113.1" -k $E/external.key ns1.adbraun.test A expect_dig "signed with the external view's key" "203.0.113.1" -k $E/external.key ns1.adbraun.test A
run "view key shows a dig command" 0 $J view key external run "view key shows a dig command" 0 $J view key external
contains "dig -y hmac-sha256:jarl-view-external:" contains "dig -y hmac-sha256:jarl-view-external:"
# Internet exposure (docs/exposure.md): external lets in anyone, internal
# only homelab.
run "exposure" 0 $J exposure
contains "^internal *private"
contains "^external *public *off"
contains "exposure_rate_limit "
contains "fix: jarl exposure fix external exposure_rate_limit"
run "fix: rate limiting, log-only first" 0 $J exposure fix external exposure_rate_limit
run "named.conf has it" 0 grep -A2 'view "external"' $E/etc-jarl/named.conf
contains "rate-limit { responses-per-second 10; log-only yes; };"
run "exposure: log-only now" 0 $J exposure
contains "^external *public *log-only"
contains "exposure_rate_limit_log_only"
run "fix: start enforcing" 0 $J exposure fix external exposure_rate_limit_log_only
contains "responses-per-second 10; }"
# A burst of identical queries in the external view (signed with its key):
# some are dropped. The internal view has no rate limit.
DROPS=0
for i in $(seq 60); do
$DIG +tries=1 +time=1 +ignore -k $E/external.key -p $DNS @127.0.0.1 ns1.adbraun.test A | grep -q "status:" || DROPS=$((DROPS+1))
done
if [ $DROPS -gt 0 ]; then ok "rate limiting dropped $DROPS of 60"; else bad "rate limiting dropped nothing"; fi
INTERNAL=$(for i in $(seq 30); do $DIG +tries=1 +time=1 +short -p $DNS @127.0.0.1 ns1.adbraun.test A; done | grep -c 127.0.0.1)
if [ "$INTERNAL" = 30 ]; then ok "the internal view isn't limited"; else bad "the internal view answered $INTERNAL of 30"; fi
run "remove the rate limit again" 0 $J option unset rate-limit --view external
run "an open resolver on a public view needs confirming (exit 5)" 5 $J option set allow-recursion any --view external
contains "accept-risk"
run "with --accept-risk" 0 $J option set allow-recursion any --view external --accept-risk
run "acknowledge it" 0 $J exposure ack external security_open_resolver --reason "e2e: on purpose"
run "acknowledged: info, with the reason" 0 $J exposure
contains "info *external *security_open_resolver .*acknowledged by .*e2e: on purpose"
run "take it back" 0 $J exposure unack external security_open_resolver
run "close the resolver again" 0 $J option unset allow-recursion --view external
run "fix: hide the version" 0 $J exposure fix server exposure_version
expect_dig "version.bind is hidden" "" -c CH version.bind TXT
run "mark external private" 0 $J view update external --exposure private
run "exposure: private by hand" 0 $J exposure
contains "^external *private (set)"
run "back to auto" 0 $J view update external --exposure auto
# named-checkconf's own warnings stay on the dashboard after the apply. # named-checkconf's own warnings stay on the dashboard after the apply.
run "option set recursion no (external)" 0 $J option set recursion no --view external run "option set recursion no (external)" 0 $J option set recursion no --view external
run "allow-recursion without its braces" 0 $J option set allow-recursion 192.0.2.1 --view external run "allow-recursion without its braces" 0 $J option set allow-recursion 192.0.2.1 --view external
@@ -486,7 +587,7 @@ expect_rpz "an Adblock entry blocks the names below it" x.tracker.adbraun.test t
expect_rpz "a hosts entry blocks the name" ads.adbraun.test test.blocklist.jarl.invalid. expect_rpz "a hosts entry blocks the name" ads.adbraun.test test.blocklist.jarl.invalid.
expect_rpz "but not the names below it" sub.ads.adbraun.test none expect_rpz "but not the names below it" sub.ads.adbraun.test none
run "blocklist list" 0 $J blocklist list run "blocklist list" 0 $J blocklist list
contains "^test *ok *2 " contains "^test *ok *normal *2 "
run "block allow" 0 $J block allow ok.tracker.adbraun.test run "block allow" 0 $J block allow ok.tracker.adbraun.test
expect_rpz "an allow rule beats the list" deep.ok.tracker.adbraun.test none expect_rpz "an allow rule beats the list" deep.ok.tracker.adbraun.test none
expect_rpz "the rest of the list still blocks" x.tracker.adbraun.test test.blocklist.jarl.invalid. expect_rpz "the rest of the list still blocks" x.tracker.adbraun.test test.blocklist.jarl.invalid.
@@ -516,6 +617,42 @@ printf '<html>gone</html>\n' > $E/lists/list.txt
run "a broken download fails (exit 2)" 2 $J blocklist refresh test --force run "a broken download fails (exit 2)" 2 $J blocklist refresh test --force
contains "no domains found" contains "no domains found"
expect_rpz "and the previous version stays" x.new.adbraun.test test.blocklist.jarl.invalid. expect_rpz "and the previous version stays" x.new.adbraun.test test.blocklist.jarl.invalid.
# Client groups and steps: per-device blocking in one view, by client
# address. 127.0.0.2 and .3 are in homelab too, so they get view internal.
rpz_from() { # SOURCE NAME
$DIG +tries=1 +time=3 -b "$1" -p $DNS @127.0.0.1 "$2" A | awk '$4 == "SOA" && $1 ~ /jarl\.invalid\.$/ {print $1; f = 1} END {if (!f) print "none"}'
}
expect_rpz_from() { # DESC SOURCE NAME WANT
local got
for i in $(seq 30); do got=$(rpz_from "$2" "$3"); [ "$got" = "$4" ] && break; sleep 0.2; done
if [ "$got" = "$4" ]; then ok "$1: $3 from $2 -> $4"; else bad "$1: $3 from $2 -> $got, want $4"; fi
}
printf '||social.adbraun.test^\n' > $E/lists/strict.txt
run "block step add" 0 $J block step add strict
run "blocklist add at a step" 0 $J blocklist add social http://127.0.0.1:$LISTS/strict.txt --step strict
contains "applied"
run "a step no group reaches: warned" 0 $J status
contains "blocks nothing"
run "block group add (off)" 0 $J block group add work 127.0.0.2 --step off
run "block group add (strict, with an IPv6 network)" 0 $J block group add kids 127.0.0.3 2001:db8:77::/64 --step strict
run "block group list" 0 $J block group list
contains "kids *strict *127.0.0.3, 2001:db8:77::/64"
expect_rpz_from "normal: the normal list" 127.0.0.1 x.new.adbraun.test test.blocklist.jarl.invalid.
expect_rpz_from "normal: not the strict list" 127.0.0.1 x.social.adbraun.test none
expect_rpz_from "off: no lists" 127.0.0.2 x.new.adbraun.test none
expect_rpz_from "off: but your rules" 127.0.0.2 blocked.adbraun.test rules.jarl.invalid.
expect_rpz_from "strict: the strict list" 127.0.0.3 x.social.adbraun.test social.blocklist.jarl.invalid.
expect_rpz_from "strict: and the normal list" 127.0.0.3 x.new.adbraun.test test.blocklist.jarl.invalid.
run "block check names the step" 0 $J block check x.social.adbraun.test
contains "for clients at step strict and above"
run "a step in use can't be deleted (exit 5)" 5 $J block step delete strict
run "move the work laptop to normal" 0 $J block group update work --step normal
expect_rpz_from "now normal" 127.0.0.2 x.new.adbraun.test test.blocklist.jarl.invalid.
run "block group delete" 0 $J block group delete work
run "block group delete kids" 0 $J block group delete kids
run "blocklist delete social" 0 $J blocklist delete social --yes
run "block step delete" 0 $J block step delete strict
run "no gates left" 1 grep -q 'gate.jarl.invalid' $E/etc-jarl/named.conf
run "blocklist disable" 0 $J blocklist disable test run "blocklist disable" 0 $J blocklist disable test
expect_rpz "a disabled list doesn't block" x.new.adbraun.test none expect_rpz "a disabled list doesn't block" x.new.adbraun.test none
run "blocklist delete" 0 $J blocklist delete test --yes run "blocklist delete" 0 $J blocklist delete test --yes
@@ -525,6 +662,22 @@ run "block delete (allow rule)" 0 $J block delete ok.tracker.adbraun.test
run "no blocking left in named.conf" 1 grep -q 'response-policy' $E/etc-jarl/named.conf run "no blocking left in named.conf" 1 grep -q 'response-policy' $E/etc-jarl/named.conf
kill $LISTS_PID 2>/dev/null; LISTS_PID= kill $LISTS_PID 2>/dev/null; LISTS_PID=
# Prometheus metrics (docs/metrics.md)
run "metrics on the main listener" 0 curl -sf http://127.0.0.1:$HTTP/metrics
contains "^jarl_named_up 1$"
contains "^jarl_adopted 1$"
contains '^jarl_problems{severity="error"} 0$'
contains '^jarl_objects{kind="zones"} '
run "metrics on metrics.listen" 0 curl -sf http://127.0.0.1:$METRICS/metrics
contains "^jarl_build_info{version="
run "metrics.listen serves nothing else" 0 curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:$METRICS/api/v1/zones
contains "^404$"
run "metrics with the token" 0 curl -sf -H "Authorization: Bearer e2e-metrics-token-0123456789" http://127.0.0.1:$METRICS/metrics
run "metrics with a wrong token" 0 curl -s -o /dev/null -w '%{http_code}' -H "Authorization: Bearer wrong-token-0123456789" http://127.0.0.1:$METRICS/metrics
contains "^401$"
run "metrics on the socket" 0 curl -sf --unix-socket $SOCK http://jarl/metrics
contains "^jarl_open_changesets "
run "status at the end" 0 $J status run "status at the end" 0 $J status
contains "Drift:.*none" contains "Drift:.*none"