Compare commits

..
Author SHA1 Message Date
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
adamandClaude Opus 5.5 ecffc2d5c1 Let jarld recognize this host's addresses under systemd
release / release (push) Successful in 1m58s
The unit's RestrictAddressFamilies lacked AF_NETLINK, which listing the
host's addresses needs, so the name server checks didn't know this
server's own addresses: the Name servers tab queried them like another
server's, and the delegation check didn't sign them with the view key.
Allow AF_NETLINK, and fall back to /proc/net without it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 18:35:06 +01:00
adamandClaude Opus 5.5 9d9ae37367 Release 0.4.0-rc.3
release / release (push) Successful in 1m56s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 18:19:56 +01:00
adamandClaude Opus 5.5 384f6b3060 Give every view a TSIG key that selects it
Each view's match-clients starts with "key jarl-view-NAME" (after
"!key" for the views listed after it), so a query signed with a view's
key lands in that view from any address. The secrets are derived from a
random seed in Jarl's database. The delegation check signs its queries
to this server's own addresses with the key of the zone's view: before,
they got whichever view matched the server's address, usually internal.
jarl view key / GET /views/{id}/key show a key for dig -k (tsig:write,
audited). jarl-view- key names are reserved; the importer drops view
keys from a configuration Jarl wrote.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 18:19:10 +01:00
adamandClaude Opus 5.5 c696d02513 Give each view its own page, and add a CNAME from an A record
Views open a page like a zone's, with Settings, Options and Zones tabs
(and New zone in that view); the Views page lists and orders them. A
and AAAA records get a + CNAME button that fills in the add form.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 18:12:56 +01:00
adamandClaude Opus 5.5 7be0762f48 Server statistics, multi-view record commands, and rc.3 fixes
Added:
- Server statistics: named's memory and CPU (from /proc), cache size,
  sockets and client counts against their limits (statistics channel,
  rndc status), host memory and load, and free disk space. Memory and
  CPU are kept over time as gauges (sum and sample count, averaged in
  timelines). Statistics page Server section, jarl stats server,
  GET /stats/server.
- jarl record add|set|delete|disable|enable --view a,b | all: the same
  change in each view, applied as one changeset.
- Blocking can be paused until resumed (jarl block pause indefinitely,
  "Until I resume", {"indefinitely": true}).

Changed:
- List options (allow-transfer, forwarders, ...) get their braces added
  when given without them.
- The dashboard lists named-checkconf's warnings about the running
  configuration; the CLI prints them after an apply.
- Stats page: tile and card rows fill their width, and the queries and
  failed answers charts share margins so their time axes line up.
- Dashboard: options have their own tile.
- 24-hour times in the web UI.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 18:03:05 +01:00
adamandClaude Opus 5.5 b097e10bef Keep options in a fixed order, and say which block a diff hunk is in
Options of a block (server options, a view, a zone) are listed and
written grouped by topic, alphabetical within a group, so the same
option is in the same place everywhere. Top-level statements keep the
order they were added in.

named.conf diffs add the enclosing blocks to each hunk header, like
git's function context: @@ -50,6 +49,8 @@ view "internal" {.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 17:06:22 +01:00
adamandClaude Opus 5.5 73d97ecc0a Release 0.4.0-rc.2
release / release (push) Successful in 1m55s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 16:51:29 +01:00
adamandClaude Opus 5.5 33060f86ce Don't count a per-view counter going down as a restart of named
After a reconfig that changed named's views, the cache and resolver
counters (summed over views) went down, which was taken for a restart:
every counter then counted its total since named started for one minute.
Only a new boot time means a restart now; a counter that goes down
counts 0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 16:51:24 +01:00
adamandClaude Opus 5.5 89663e366b Disable zones instead of deleting them
A disabled zone stays in Jarl with its records, options and history,
still editable, but named doesn't load it; enabling it serves it as it is
then. Signed zones, and zones another view shares, can't be disabled.
jarl zone disable|enable, the zone page's Disable/Enable button.
Database schema 7.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 13:45:55 +00:00
adamandClaude Opus 5.5 59baccd636 Release 0.4.0-rc.1
release / release (push) Successful in 1m53s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 12:56:15 +00:00
adamandClaude Opus 5.5 02c393a6bb Apply pending changes from the banner
An Apply now button on the pending-changes banner applies the draft and
stays on the page; if the changes are refused, a dialog says why.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 12:56:10 +00:00
adamandClaude Opus 5.5 d4756444ac Edit blocklists and rules in the web UI
An Edit button opens a dialog for a list's name, URL, views and comment,
or a rule's action, scope, views and comment. The download-now button is
called Refresh. Checkboxes in dialogs are no longer stretched.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 12:50:41 +00:00
adamandClaude Opus 5.5 67f14b030f Make disabled records obvious, and offer the right fix when checking a blocked name
Disabled records are hatched and badged next to their name. Checking a
name blocked by your own rule for that name offers to remove the rule,
instead of an allow rule that would be refused as a duplicate.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 12:45:10 +00:00
adamandClaude Opus 5.5 ad8eb3fc3d Show and edit option values laid out as in named.conf
Values with blocks (logging, controls, ...) are shown over several lines
with indentation, and edited that way: the editor grows with its content,
Tab indents, Enter keeps the indentation. With a draft open, option tables
show the draft's values.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 11:55:41 +00:00
adamandClaude Opus 5.5 27d1f9e538 Pause blocking, blocked clients and a blocked line in the statistics, compact view picker
jarl block pause|resume (and the Blocking page) lets every name through
for a while; jarld resumes on time. The lists stay loaded: the rules zone
just passes everything. Statistics gain the clients with the most blocked
queries and blocked queries on the queries chart. The view choice in the
blocking forms is a dropdown.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 11:10:44 +00:00
adamandClaude Opus 5.5 9fc23310bd Add blocking: blocklists and block/allow rules with BIND response policy zones
jarld downloads blocklists (Adblock, hosts or plain domains), checks them
daily and reloads named only when a list changed; your own block and
allow rules take precedence, and the most specific rule wins. Lists and
rules apply per view (default: every view that offers recursion). Web UI
Blocking page with name checks, jarl blocklist and jarl block, the
/blocking API, blocked counts in the statistics. Database schema 6.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 09:24:19 +00:00
adamandClaude Opus 5.5 436cd79ec9 Release 0.3.0
release / release (push) Successful in 1m49s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 23:39:15 +00:00
adamandClaude Opus 5.5 c645dae14f Release 0.3.0-rc.2
release / release (push) Successful in 1m50s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 23:27:56 +00:00
adamandClaude Opus 5.5 403f85486d Mark signed zones with a padlock, and name clients in statistics
The zones list shows a padlock after signed zones, linking to their
DNSSEC tab. Client lists in the statistics show the host name Jarl's
records have for each address (PTR, else A/AAAA); no DNS queries.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 23:27:53 +00:00
adamandClaude Opus 5.5 4113d5d6fc Release 0.3.0-rc.1
release / release (push) Successful in 1m47s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 22:46:55 +00:00
adamandClaude Opus 5.5 f716fb8dd1 Script-friendly CLI output, and keep record list rows on one line
-o id and -o field=NAME,... print just the IDs or chosen fields; -q on
show and list prints the IDs. jarl record list shortens data too long
for the terminal (--wide shows it in full).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 22:46:43 +00:00
adamandClaude Opus 5.5 113a6913a2 Give the DS command for a parent zone in Jarl, and allow port 443
The DNSSEC tab and 'jarl zone dnssec status' give the jarl record add
command (and an Add to parent button) when the parent zone is in Jarl.
jarl.service grants CAP_NET_BIND_SERVICE so the web UI can listen on 443.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 22:42:16 +00:00
adamandClaude Opus 5.5 295c549479 Disable records instead of deleting them
A disabled record stays in Jarl with its ID, TTL and comment but is
left out of the zone; the zone file lists it as a comment. Web UI
toggle, jarl record disable|enable, record.disable|enable operations
and API endpoints. Database schema 5.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 22:33:02 +00:00
adamandClaude Opus 5.5 5f04ab523b Separate record groups with lines instead of greying repeated names
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 22:09:55 +00:00
adamandClaude Opus 5.5 c9590cc3d4 Release 0.2.0
release / release (push) Successful in 1m43s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 21:15:13 +00:00
146 changed files with 13419 additions and 476 deletions

No files matched your search

+294
View File
@@ -6,6 +6,300 @@ All notable changes to Jarl are documented here. The format follows
## [Unreleased]
### Upgrading from 0.4
- Once you use address ranges, the database can't go back to 0.4: its
parser doesn't know them. Remove the ranges first if you need to
downgrade.
### Fixed
- A changeset with a staged record delete could not be applied after that
record was deleted elsewhere (e.g. with the CLI), and it was hard to see
which operation to remove: the error only said "record 832 does not
exist". A staged delete of a record that is already gone now counts as
done (with a warning), and the other operations that no longer apply
(an edit or disable of a deleted record, a change to an object changed
since) are named with their number and summary. The Changes page marks
that operation and offers to remove it right in the error.
### Added
- `jarl changeset show`, `diff`, `apply`, `discard` and `remove` take the
changeset from `-C` or `JARL_CHANGESET` when no ID is given
(`jarl changeset apply`). A changeset from `JARL_CHANGESET` is now
checked before it is used, for staging too: it must exist, be open and
be yours, so a stale export (an applied changeset, or an ID that later
turned into someone else's web UI draft) can't silently take your
changes; the error says what to do. Commands say on stderr which
changeset from `JARL_CHANGESET` they use, and `apply`/`discard` remind
you to `unset JARL_CHANGESET`. `-C` and ID arguments are used as given.
- Address ranges such as `10.0.10.101-10.0.10.199` or
`2001:db8::100-2001:db8::1ff` (also `!` negated, and with spaces around
the dash) wherever an address match list is used: ACLs, views'
match-clients and match-destinations, options such as allow-query, and
blocking's client groups ([docs/concepts.md](docs/concepts.md)). Jarl
keeps the range as written and writes the fewest prefixes that cover
exactly that range to named.conf, with the range as a comment. The web
UI shows what a range becomes while you type it (`GET
/catalog/ranges`). Mistakes are refused when entered: an end that isn't
a full address, the start after the end, IPv4 mixed with IPv6, a range
in an option that takes no address match list (forwarders). A range
covered by an earlier element is reported as never matching, a range of
every address counts as "any" for the open resolver and open transfer
checks, and an IPv6 range in the IPv4-mapped space gets a warning. ACL
names can no longer look like a range.
- Prometheus metrics at `/metrics` ([docs/metrics.md](docs/metrics.md)),
set up in the new `metrics:` section of jarl.yaml (off by default).
`metrics.allow` lists the addresses that may scrape (loopback by
default), `metrics.token` lets anyone who sends it as a bearer token
scrape, and `metrics.listen` adds addresses that serve only `/metrics`
(for a Prometheus server elsewhere). Root and jarld's own user can also
scrape on the local socket.
- Health, always: named up, recursive and TCP clients against their
limits, adopted, open changesets, last apply, validation problems by
severity, drifted files, object counts, the last backup (and failure,
and whether it is stale), blocking paused, each blocklist's entries,
last check and change and whether it fails, the DNSSEC phase of every
signed zone, and when API tokens expire.
- With query statistics on: named's counters (queries, result codes,
record types, UDP/TCP, authoritative/recursive, rate limiting, upstream
round-trip times, DNSSEC validation, cache hits) and Jarl's (logged
responses per view and zone, blocked queries per blocklist), as
counters since jarld started, plus named's memory, CPU, threads, cache
and sockets, and the collector's health.
- Example queries and alert rules, checked with promtool.
## [0.4.0] - 2026-10-11
### Upgrading from 0.3
- Back up first (`sudo jarld backup`).
- When jarld first starts, it rewrites named's configuration: options
move into their new fixed order, and every view gets its view key in
`match-clients` and `keys.conf`. It is checked with named-checkconf
before named reloads, like any apply. Files you changed by hand are left
alone; the log then says to run `jarl resync`.
- The systemd unit changed (it allows `AF_NETLINK`). The package replaces
it; an override that adds `AF_NETLINK` (`systemctl edit jarl`) does no
harm and can be removed.
- The database moves to schema 8 (disabled zones, client groups) as
jarld starts.
- If statistics showed a spike after views were created or changed, see
"Statistics" under Fixed: the bad minutes can be removed from
`/var/lib/jarl/stats.db` (minute rows have `res = 'm'`; the hours and
days that include them are `'h'` and `'d'`).
### Added
- Different blocking for different devices (docs/blocking.md), like
Pi-hole's groups, in one view and without loading any list twice.
Client groups put devices (addresses, networks or ACLs of them) at a
blocking step: `off` (only your rules), `normal` (the default), or steps
you add above it, each including the lists of the steps below. Lists
start at a step. Jarl puts a small "gate" policy zone in front of each
step's lists that lets the devices below that step through. Web UI: a
Devices panel on the Blocking page and a step on each list. CLI: `jarl
block step`, `jarl block group`, `jarl blocklist update --step`. API:
`/blocking/steps`, `/blocking/groups`. Database schema 8.
- View keys: every view gets a TSIG key, `jarl-view-NAME`, at the front of
its match-clients, so a query signed with it lands in that view from any
address. Jarl signs its delegation check's queries to this server's own
addresses with the key of the zone's view: before, they got whichever
view matched the server's own address (usually internal), so the check
could report the wrong serial for the external view. `jarl view key
VIEW` shows a key, for `dig -k` (docs/views.md). The first apply after
upgrading adds the keys to named.conf and keys.conf.
- Server statistics: named's memory (resident, and its own count) and
CPU use, its cache size, sockets, and recursive and TCP clients against
their limits; the host's memory and load; free space where zone files,
logs and Jarl's data are. Memory and CPU are kept over time like the
other statistics. Statistics page → Server, `jarl stats server`, API
`/stats/server`. Read from named's statistics channel, `rndc status` and
/proc, with no extra privileges.
- CLI: the record commands that change records take several views,
`--view internal,external`, or `--view all`, and make the change in each,
as one changeset (all views or none).
- Blocking can be paused until resumed, like Pi-hole's "indefinitely":
`jarl block pause indefinitely`, **Until I resume** on the Blocking page,
`{"indefinitely": true}` in the API.
- Disabled zones: a zone can be disabled instead of deleted. named stops
loading it (its zone statement is left out of named.conf), while Jarl
keeps it with its records, options and history, still editable;
enabling it serves it as it is then. Disabling says what clients get
instead (recursion, or REFUSED). Signed zones can't be disabled. Web
UI: Disable/Enable on the zone page, a badge in the zone list. CLI:
`jarl zone disable|enable`. API: `PATCH /zones/{zone}` with
`{"disabled": true}`. Database schema 7.
- Blocking (docs/blocking.md): named answers "doesn't exist" for ads,
trackers and malware, using BIND's response policy zones.
- Blocklists in Adblock, hosts or plain-domain format, downloaded by jarld
and checked daily (`blocking.refresh`). A list is downloaded only when
the server says it changed, checked with named-checkzone, and reloaded
only when its content differs; a failed update keeps the previous
version and shows on the dashboard.
- Your own block and allow rules, for a domain and the names below it or
the exact name. Rules take precedence over every list, and the most
specific rule wins.
- Lists and rules apply to chosen views, or to every view that offers
recursion. They are configuration: staged, reviewed and reverted like
any change.
- Checking a name says whether it is blocked and by which rule or list
entry, with an Allow button in the web UI.
- Web UI: a Blocking page, with well-known lists to pick and named's
memory per list. CLI: `jarl blocklist` and `jarl block`. API:
`/blocking/lists`, `/blocking/rules`, `/blocking/check`.
- Pausing: `jarl block pause [DURATION]` or the Blocking page lets every
name through for a while (at most a day); blocking resumes by itself.
The lists stay loaded, so it takes effect at once.
- Statistics: blocked queries over time (a line on the queries chart)
and their share, blocked per list, the most blocked names and the
clients with the most blocked queries (`jarl stats top blocked`,
`top blocked-clients`). Blocked answers no longer count as names that
don't exist.
- The view choice in the web UI's forms is a compact dropdown, however
many views there are.
- A `blocking` permission; database schema 6.
### Changed
- Web UI: each view has its own page, like a zone: Settings (match lists,
comment, colour), Options and Zones tabs (with **New zone in VIEW**),
and Delete. The Views page lists them in order, with the arrows that
reorder them; view links everywhere open the view's page.
- Web UI: A and AAAA records have a **+ CNAME** button that fills in the
add form with a CNAME pointing to the record's name, leaving only the
alias's name to type. It shows when the row is hovered or focused
(always on touch screens).
- Blocking page: how many queries each blocklist blocked in the last 24
hours, and your rules together, with statistics on (linking to the
Statistics page).
- Options are kept in one fixed order, in named.conf, the web UI and the
CLI: grouped by topic (files, listening, access control, recursion and
forwarding, DNSSEC, transfers and notify, then the rest), alphabetical
within a group. The same option is now in the same place in every view
and zone. The first apply after upgrading reorders named.conf (a diff
that only moves lines). Top-level statements keep their order.
- Options that end in a list (`allow-transfer`, `forwarders`,
`listen-on`, ...) can be given without their braces, in the web UI, CLI
and API: `10.0.10.53; key xfer` becomes `{ 10.0.10.53; key xfer; }`.
Before, only some server options (those edited one element per line)
added them.
- The dashboard's configuration checks include named-checkconf's own
warnings about the running configuration (such as `both "recursion no;"
and "allow-recursion" active`), which before only showed while applying.
The CLI now prints them after an apply too.
- Statistics page: cards and tiles fill their rows instead of leaving one
alone beside an empty space, and the queries and failed answers charts
have the same margins, so their time axes line up.
- Dashboard: options have their own tile; "0 TSIG keys · 22 options" read
as if the options belonged to the keys.
- Web UI times use the 24-hour clock.
- Diffs of named.conf say which block each change is in, after the `@@`
line numbers (`@@ -50,6 +49,8 @@ view "internal" {`), like git does
with function names. Moving an option from the server's options into a
view no longer looks like two unrelated edits.
- Web UI options (server, top-level, view and zone): values with blocks,
such as logging, are shown and edited laid out as in named.conf, over
several lines with indentation. The editor grows with its content, Tab
indents and Enter keeps the indentation (Esc, then Tab, moves on). With
a draft open, the tables show the draft's values, so a staged edit stays
visible after Save.
- The pending-changes banner has an Apply now button: it applies your
draft without going to the Changes page, and stays on the page you're
on. If the changes are refused, a dialog says why and links to Changes.
- Blocking page: blocklists and rules have an Edit button (name, URL,
views, comment; a rule's action and scope). The list button that
downloads now is called Refresh, like `jarl blocklist refresh`.
- Records table: disabled records are hatched and have a "disabled" badge
next to their name, instead of only a muted colour and a badge in the
comment column.
### Fixed
- Reverting a changeset added needless "restore" operations for
blocklists, rules and views that hadn't changed: copying the state
turned their empty view lists into missing ones.
- Under the shipped systemd unit, jarld couldn't list the host's
addresses (it needs a netlink socket, which `RestrictAddressFamilies`
didn't allow), so the name server checks didn't recognize this server's
own addresses. The Name servers tab then queried them like another
server's, and the delegation check didn't sign those queries with the
view's key: with views, both got the view matching the server's own
address (usually internal) and reported a wrong serial. The unit now
allows `AF_NETLINK`, and without it jarld reads the addresses from
/proc.
- Statistics: after a reconfig that added, removed or recreated views
(creating the first view, for instance), a minute could count everything
since named started: millions of queries in an hour. A per-view counter
(cache, upstream times, validation) going down was taken for a restart
of named. Now only a new boot time means a restart, and a counter that
goes down counts 0 for that minute.
- Blocking page, checking a name: when your own block rule is for that very
name, it offers to remove the rule instead of an allow rule, which was
refused as a second rule for the same name. It also names the rule that
matched rather than the policy zone entry.
- docs/validation.md lacked the `disabled_conflict` warning added in 0.3.0.
## [0.3.0] - 2026-10-09
### Added
- Disabled records: a record can be disabled instead of deleted. It stays
in Jarl with its ID, TTL and comment but is left out of the zone (the
zone file lists it as a comment), and enabling it puts it back exactly
as it was. Web UI: a Disable/Enable button on each record, disabled
records greyed out with a badge, a filter for them, and a count in the
zone list. CLI: `jarl record disable|enable` (by ID or by name, type and
data, `--ptr` for the PTR record, `--missing-ok`), `record list
--disabled`, and `disable:`/`enable:` entries for `jarl apply -f`. API:
`POST /records/{id}/disable|enable`, `POST
/zones/{zone}/records/disable|enable`, a `disabled` field on records,
`?disabled=` on record lists and `record.disable|enable` operations.
Validation checks the zone as it is served: the last NS record can't be
disabled, enabling a record is checked like adding it, and a disabled
record that a CNAME now blocks gets a warning. Database schema 5.
- DNSSEC for a zone whose parent zone is in Jarl: the DNSSEC tab gives the
`jarl record add PARENT NAME DS ...` command to copy and an "Add to
PARENT" button (staged like any change), links to the parent zone, and
says when the parent has the DS record. `jarl zone dnssec status` prints
the command too, and turning signing off gives the matching `jarl record
delete`.
- CLI output for scripts: `-o id` prints just the IDs of what a command
shows or lists (`jarl zone show adbraun.lol -o id`), `-o
field=view,soa.serial` just those fields, tab separated. `-q` on `show`
and `list` prints the IDs too. With a change (`jarl record add ... -o
id`) they apply to the changed object.
- Statistics: client lists (busiest and refused clients, in the web UI,
`jarl stats top clients|refused` and `jarl stats summary`) show the host
name Jarl's records have for each address: the PTR record's target, or
else the name of an A/AAAA record. Looked up in Jarl's own data, so no
DNS queries; an address without a record shows as before.
- Zones list: a padlock after each signed zone's name, linking to its
DNSSEC tab (open and amber while signing is being turned off).
### Changed
- The web UI and API can listen on ports below 1024, such as 443:
`jarl.service` now gives jarld the `CAP_NET_BIND_SERVICE` capability
(and only that). With a unit that doesn't, jarld's error says what's
missing. See docs/configuration.md, "HTTPS on port 443".
- Records table: names are no longer greyed out when they repeat the row
above (it made those records look inactive). Records of the same name
are separated by a faint line instead, and a full line marks where the
next name starts.
### Fixed
- `jarl record list`: one long value (a DKIM key) padded every row to its
length, so on a terminal each row wrapped onto a second, blank-looking
line. Long data is now shortened to fit the terminal; `--wide` shows it
in full, as does output to a pipe or file.
## [0.2.0] - 2026-10-09
### Added
- DNSSEC signing per zone (docs/dnssec.md): `jarl zone dnssec
+19 -3
View File
@@ -71,6 +71,15 @@ error: www.adbraun.lol has a CNAME, so it can't have any other records (found A)
queries, record types, cache hit rate. Shown in the web UI and the CLI,
and exportable as JSON. Stored as aggregates in a separate database with
retention you set.
* **Prometheus metrics** (optional): health to alert on (named down,
configuration problems, drift, failing blocklists, stale backups, DNSSEC
steps waiting at the parent, expiring tokens) and query counters per
result code, view, zone and blocklist, with example alerts. Set up in
jarl.yaml, with an address allow list and/or a bearer token.
* **Blocking** like Pi-hole, done by named itself: blocklists (Adblock,
hosts or plain domains) downloaded and kept up to date, your own block
and allow rules that beat every list, per view, with "why is this
blocked?" checks and blocked counts in the statistics.
* **Let's Encrypt DNS-01** built in: `jarl acme` plus certbot, acme.sh and lego
hooks, an HTTPS-only endpoint for lego's and Traefik's `httpreq` provider,
and CNAME delegation to a dedicated ACME zone.
@@ -116,6 +125,8 @@ in `/etc/jarl/jarl.yaml` to reach it from your management network.
| [docs/dnssec.md](docs/dnssec.md) | DNSSEC signing, going public with your own name servers, Porkbun |
| [docs/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/metrics.md](docs/metrics.md) | Prometheus metrics: access, what is exported, example alerts |
| [docs/blocking.md](docs/blocking.md) | Blocking ads, trackers and malware: blocklists, your own rules, views, memory |
| [docs/validation.md](docs/validation.md) | Every check Jarl makes |
| [docs/operations.md](docs/operations.md) | Backups, drift, failures, recovery, monitoring |
| [docs/development.md](docs/development.md) | Code layout, tests against real BIND, conventions |
@@ -166,9 +177,14 @@ review step.
## Status
Version 0.2.0 is in testing (0.2.0-rc.2); 0.1.0 is the current release.
0.2.0 adds DNSSEC signing, Porkbun support, two-factor login and view
colours. Jarl is feature-complete for one server's configuration.
Version 0.4.0 is the current release, and 0.4.1 is in testing
(0.4.1-rc.1): it adds Prometheus metrics, address ranges in ACLs and
other address match lists, and safer changeset handling. 0.4.0 added blocking (blocklists and
your own rules, like Pi-hole, done by named, with different blocking for
different devices), disabling zones, server statistics and view keys, on
top of 0.3.0's disabled records, script-friendly CLI output and client
names in the statistics, and 0.2.0's DNSSEC signing, Porkbun support,
two-factor login and view colours. Jarl is feature-complete for one server's configuration.
Managing several servers from one jarld and dynamic updates are not
handled yet ([concepts.md](docs/concepts.md#what-jarl-deliberately-doesnt-do-yet)).
+1 -1
View File
@@ -1 +1 @@
0.2.0-rc.2
0.4.1-rc.1
+508
View File
@@ -0,0 +1,508 @@
package main
import (
"context"
"fmt"
"io"
"net/url"
"strings"
"time"
"github.com/spf13/cobra"
"jarl/internal/api"
"jarl/internal/blocking"
)
// ---- blocking (docs/blocking.md)
// viewsBody adds the --view flags to a request body: names, or "auto" for
// every view that offers recursion.
func viewsBody(body map[string]any, views []string) {
if len(views) == 1 && strings.EqualFold(views[0], "auto") {
body["views"] = []string{}
} else if len(views) > 0 {
body["views"] = views
}
}
func viewsText(views []string, auto bool) string {
switch {
case auto:
return "auto (" + strings.Join(views, ", ") + ")"
case len(views) == 0:
return "-"
}
return strings.Join(views, ", ")
}
func blocklistCmd() *cobra.Command {
cmd := &cobra.Command{Use: "blocklist", Aliases: []string{"blocklists"}, Short: "Blocklists: domains to block, downloaded and kept up to date",
Long: `Blocklists are downloaded by jarld and turned into response policy zones, so
named answers "doesn't exist" for the domains on them (docs/blocking.md).
Hosts, plain domain and Adblock (||domain^) lists work. Your own block and
allow rules ('jarl block') take precedence over every list.`}
var views []string
var comment, step string
list := &cobra.Command{
Use: "list",
Short: "List blocklists and their state",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
var lists []api.Blocklist
if err := c.Get(ctx(), "/blocking/lists", nil, &lists); err != nil {
return err
}
return output(lists, func(w io.Writer) {
fmt.Fprintln(w, "NAME\tSTATE\tSTEP\tDOMAINS\tVIEWS\tUPDATED\tMEMORY\tURL")
for _, b := range lists {
domains, updated, mem := "-", "-", "-"
if s := b.Status; s != nil && s.UpdatedAt != nil {
domains, updated = fmt.Sprint(s.Entries), s.UpdatedAt.Local().Format("2006-01-02 15:04")
}
if b.MemoryMB > 0 {
mem = fmt.Sprintf("~%d MB", b.MemoryMB)
}
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n", b.Name, b.State, b.Step, domains, viewsText(b.Views, b.AutoViews), updated, mem, b.URL)
}
for _, b := range lists {
if b.Status != nil && b.Status.Error != "" {
fmt.Fprintf(w, "\n%s: %s\n", b.Name, b.Status.Error)
}
}
})
},
}
add := &cobra.Command{
Use: "add NAME URL",
Short: "Add a blocklist (jarld downloads it right away)",
Long: `Add a blocklist. NAME is a short label (letters, digits, hyphens). Without
--view the list applies to every view that offers recursion.
jarl blocklist add hagezi-pro https://raw.githubusercontent.com/hagezi/dns-blocklists/main/adblock/pro.txt
jarl blocklist add stevenblack https://raw.githubusercontent.com/StevenBlack/hosts/master/hosts --view internal`,
Args: cobra.ExactArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
body := map[string]any{"name": args[0], "url": args[1]}
viewsBody(body, views)
if step != "" {
body["step"] = step
}
if comment != "" {
body["comment"] = comment
}
resp, err := c.Mutate(ctx(), "POST", "/blocking/lists", nil, body)
if err != nil {
return err
}
if err := report(resp); err != nil || flagQuiet || !resp.Applied {
return err
}
fmt.Println("jarld downloads the list in the background; 'jarl blocklist list' shows when it's in use.")
return nil
},
}
add.Flags().StringArrayVar(&views, "view", nil, "a view it applies to (repeat for several; default: every view that offers recursion)")
add.Flags().StringVar(&comment, "comment", "", "comment")
add.Flags().StringVar(&step, "step", "", "the blocking step it starts at (default: normal; see 'jarl block step')")
var rename, newURL string
update := &cobra.Command{
Use: "update LIST",
Short: "Change a blocklist's name, URL, views or comment",
Example: ` jarl blocklist update hagezi --url https://example.org/new-list.txt
jarl blocklist update hagezi --view internal --view guest
jarl blocklist update hagezi --view auto # every view that offers recursion`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
body := map[string]any{}
f := cmd.Flags()
if f.Changed("name") {
body["name"] = rename
}
if f.Changed("url") {
body["url"] = newURL
}
if f.Changed("comment") {
body["comment"] = comment
}
if f.Changed("step") {
body["step"] = step
}
viewsBody(body, views)
if len(body) == 0 {
return fmt.Errorf("nothing to change (see --help)")
}
resp, err := c.Mutate(ctx(), "PATCH", "/blocking/lists/"+url.PathEscape(args[0]), nil, body)
if err != nil {
return err
}
return report(resp)
},
}
update.Flags().StringVar(&rename, "name", "", "new name")
update.Flags().StringVar(&newURL, "url", "", "new URL")
update.Flags().StringArrayVar(&views, "view", nil, `a view it applies to (repeat for several; "auto": every view that offers recursion)`)
update.Flags().StringVar(&comment, "comment", "", "new comment")
update.Flags().StringVar(&step, "step", "", "the blocking step it starts at (normal, or a step above it)")
toggle := func(disable bool) *cobra.Command {
verb := map[bool]string{true: "disable", false: "enable"}[disable]
return &cobra.Command{
Use: verb + " LIST",
Short: map[bool]string{true: "Stop using a blocklist (it is kept, and re-enabled without downloading)", false: "Use a disabled blocklist again"}[disable],
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
resp, err := c.Mutate(ctx(), "PATCH", "/blocking/lists/"+url.PathEscape(args[0]), nil, map[string]any{"disabled": disable})
if err != nil {
return err
}
return report(resp)
},
}
}
var yes bool
del := &cobra.Command{
Use: "delete LIST",
Short: "Delete a blocklist",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
if !confirm("Delete blocklist "+args[0]+"?", yes) {
return fmt.Errorf("canceled")
}
c, err := newClient()
if err != nil {
return err
}
resp, err := c.Mutate(ctx(), "DELETE", "/blocking/lists/"+url.PathEscape(args[0]), nil, nil)
if err != nil {
return err
}
return report(resp)
},
}
del.Flags().BoolVarP(&yes, "yes", "y", false, "don't ask")
var force bool
refresh := &cobra.Command{
Use: "refresh [LIST...]",
Short: "Download blocklists now (all enabled ones without LIST)",
Long: `Download blocklists now instead of waiting for their next check
(blocking.refresh in jarl.yaml, daily by default). A list is only replaced
when its content changed; --force downloads it even when the server says
it hasn't.`,
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
names := args
if len(names) == 0 {
var lists []api.Blocklist
if err := c.Get(ctx(), "/blocking/lists", nil, &lists); err != nil {
return err
}
for _, b := range lists {
if b.State != "disabled" && b.State != "idle" {
names = append(names, b.Name)
}
}
}
if flagDryRun {
fmt.Printf("would download: %s\n", strings.Join(names, ", "))
return nil
}
failed := 0
for _, n := range names {
var b api.Blocklist
start := time.Now()
err := c.Do(ctx(), "POST", "/blocking/lists/"+url.PathEscape(n)+"/refresh", q("force", map[bool]string{true: "1"}[force]), nil, &b)
switch {
case err != nil:
failed++
fmt.Printf("%s: %v\n", n, err)
case b.Status != nil && b.Status.Error != "":
failed++
fmt.Printf("%s: %s (the previous version stays in use)\n", n, b.Status.Error)
case b.Status != nil:
fmt.Printf("%s: %d domains (%d lines skipped), checked in %s\n", n, b.Status.Entries, b.Status.Skipped, time.Since(start).Round(100*time.Millisecond))
}
}
if failed > 0 {
return &checkFailed{fmt.Sprintf("%d of %d lists could not be updated", failed, len(names))}
}
return nil
},
}
refresh.Flags().BoolVar(&force, "force", false, "download even when the server says the list hasn't changed")
cmd.AddCommand(list, add, update, toggle(true), toggle(false), del, refresh)
return cmd
}
func blockCmd() *cobra.Command {
cmd := &cobra.Command{Use: "block", Short: "Your own block and allow rules, and checking what is blocked",
Long: `Rules block a domain, or allow it despite the blocklists ('jarl blocklist').
A rule covers the domain and every name below it, unless --exact. Rules
take precedence over every list, and the most specific rule wins: allow
cdn.example.com overrides block example.com for cdn.example.com and the
names below it (docs/blocking.md).`}
var views []string
var comment, view string
var exact bool
addRule := func(action string) *cobra.Command {
c := &cobra.Command{
Use: map[string]string{"block": "add DOMAIN", "allow": "allow DOMAIN"}[action],
Short: map[string]string{"block": "Block a domain (and the names below it)", "allow": "Allow a domain (and the names below it) that a blocklist blocks"}[action],
Example: map[string]string{
"block": " jarl block add doubleclick.net\n jarl block add telemetry.example.com --exact --view internal",
"allow": " jarl block allow cdn.example.com\n jarl block allow login.example.com --exact",
}[action],
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
body := map[string]any{"name": args[0], "action": action}
if exact {
body["exact"] = true
}
viewsBody(body, views)
if comment != "" {
body["comment"] = comment
}
resp, err := c.Mutate(ctx(), "POST", "/blocking/rules", nil, body)
if err != nil {
return err
}
return report(resp)
},
}
c.Flags().BoolVar(&exact, "exact", false, "only this name, not the names below it")
c.Flags().StringArrayVar(&views, "view", nil, "a view it applies to (repeat for several; default: every view that offers recursion)")
c.Flags().StringVar(&comment, "comment", "", "comment")
return c
}
list := &cobra.Command{
Use: "list",
Short: "List your block and allow rules",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
var rules []api.BlockRule
if err := c.Get(ctx(), "/blocking/rules", nil, &rules); err != nil {
return err
}
note := pausedNote(c)
return output(rules, func(w io.Writer) {
if note != "" {
fmt.Fprintln(w, note)
}
fmt.Fprintln(w, "ID\tACTION\tDOMAIN\tCOVERS\tVIEWS\tCOMMENT")
for _, r := range rules {
covers := "and below"
if r.Exact {
covers = "exact"
}
fmt.Fprintf(w, "%d\t%s\t%s\t%s\t%s\t%s\n", r.ID, r.Action, r.Name, covers, viewsText(r.Views, r.AutoViews), r.Comment)
}
})
},
}
var id int64
var missingOK bool
del := &cobra.Command{
Use: "delete DOMAIN | delete --id ID",
Short: "Delete the rules for a domain (or one rule by ID)",
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
var resp *api.MutationResponse
switch {
case id > 0:
resp, err = c.Mutate(ctx(), "DELETE", fmt.Sprintf("/blocking/rules/%d", id), nil, nil)
case len(args) == 1:
qv := q("name", args[0], "view", view)
if missingOK {
qv.Set("missing_ok", "1")
}
resp, err = c.Mutate(ctx(), "DELETE", "/blocking/rules", qv, nil)
default:
return fmt.Errorf("give DOMAIN, or --id")
}
if err != nil {
return err
}
if !resp.Applied && !resp.Staged && !resp.DryRun {
fmt.Println("nothing to do")
return nil
}
return report(resp)
},
}
del.Flags().Int64Var(&id, "id", 0, "delete the rule with this ID")
del.Flags().StringVar(&view, "view", "", "only the rules that apply in this view")
del.Flags().BoolVar(&missingOK, "missing-ok", false, "succeed when there is no such rule")
check := &cobra.Command{
Use: "check DOMAIN",
Short: "Show whether a name is blocked, and by which rule or list",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
var matches []blocking.Match
if err := c.Get(ctx(), "/blocking/check", q("name", args[0]), &matches); err != nil {
return err
}
note := pausedNote(c)
return output(matches, func(w io.Writer) {
if note != "" {
fmt.Fprint(w, note+"While it is, every name is answered. Otherwise:\n")
}
if len(matches) == 0 {
fmt.Fprintln(w, "Blocking isn't set up in any view.")
return
}
for _, m := range matches {
where := ""
if m.View != "" {
where = "view " + m.View + ":\t"
}
switch {
case m.Result == "none":
fmt.Fprintf(w, "%snot blocked\n", where)
case m.List != "" && m.Step != "":
fmt.Fprintf(w, "%sblocked for clients at step %s and above, by blocklist %s (entry %s); not for others\n", where, m.Step, m.List, m.Entry)
case m.List != "":
fmt.Fprintf(w, "%sblocked by blocklist %s (entry %s)\n", where, m.List, m.Entry)
default:
fmt.Fprintf(w, "%s%s by your rule %d (entry %s)\n", where, m.Result, m.RuleID, m.Entry)
}
}
})
},
}
pause := &cobra.Command{
Use: "pause [DURATION | indefinitely]",
Short: "Let every name through for a while (default 10m, at most 24h) or until resumed",
Long: `Pause blocking: every name is answered normally until the time is up,
then blocking resumes by itself. Useful to see whether blocking is what
breaks a site. The lists stay loaded, so pausing and resuming take effect
at once. "indefinitely" pauses until 'jarl block resume'.
jarl block pause # 10 minutes
jarl block pause 1h
jarl block pause indefinitely
jarl block resume`,
Args: cobra.MaximumNArgs(1),
ValidArgs: []string{"indefinitely", "5m", "10m", "30m", "1h", "24h"},
RunE: func(cmd *cobra.Command, args []string) error {
if len(args) == 1 && args[0] == "indefinitely" {
c, err := newClient()
if err != nil {
return err
}
resp, err := c.Mutate(ctx(), "POST", "/blocking/pause", nil, map[string]any{"indefinitely": true})
if err != nil {
return err
}
if err := report(resp); err != nil || flagQuiet || !resp.Applied {
return err
}
fmt.Println("Blocking is paused until you resume it ('jarl block resume').")
return nil
}
d := 10 * time.Minute
if len(args) == 1 {
var err error
if d, err = time.ParseDuration(args[0]); err != nil || d < time.Minute {
return fmt.Errorf("invalid duration %q (like 10m or 2h, or indefinitely)", args[0])
}
}
c, err := newClient()
if err != nil {
return err
}
resp, err := c.Mutate(ctx(), "POST", "/blocking/pause", nil, map[string]any{"minutes": int(d.Round(time.Minute) / time.Minute)})
if err != nil {
return err
}
if err := report(resp); err != nil || flagQuiet || !resp.Applied {
return err
}
fmt.Printf("Blocking is paused until %s.\n", time.Now().Add(d.Round(time.Minute)).Format("15:04"))
return nil
},
}
resume := &cobra.Command{
Use: "resume",
Short: "End a pause: block again now",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
resp, err := c.Mutate(ctx(), "POST", "/blocking/resume", nil, nil)
if err != nil {
return err
}
return report(resp)
},
}
cmd.AddCommand(addRule("block"), addRule("allow"), list, del, check, pause, resume, blockStepCmd(), blockGroupCmd())
return cmd
}
// pausedNote returns a line about a pause of blocking, or "".
func pausedNote(c interface {
Get(context.Context, string, url.Values, any) error
}) string {
var st struct {
PausedUntil *time.Time `json:"paused_until"`
PausedIndefinitely bool `json:"paused_indefinitely"`
}
if c.Get(ctx(), "/blocking/status", nil, &st) != nil {
return ""
}
if st.PausedIndefinitely {
return "Blocking is paused until you resume it ('jarl block resume').\n"
}
if st.PausedUntil == nil {
return ""
}
return "Blocking is paused until " + st.PausedUntil.Local().Format("2006-01-02 15:04") + " ('jarl block resume' ends it).\n"
}
+88 -37
View File
@@ -26,6 +26,10 @@ type batchEntry struct {
Delete map[string]any `yaml:"delete,omitempty" json:"delete,omitempty"`
Set map[string]any `yaml:"set,omitempty" json:"set,omitempty"`
Update map[string]any `yaml:"update,omitempty" json:"update,omitempty"`
// Disable and Enable find records like Delete: by id, or by zone,
// name, type and optionally data.
Disable map[string]any `yaml:"disable,omitempty" json:"disable,omitempty"`
Enable map[string]any `yaml:"enable,omitempty" json:"enable,omitempty"`
// Any API operation: op, id, data.
Op string `yaml:"op,omitempty" json:"op,omitempty"`
ID int64 `yaml:"id,omitempty" json:"id,omitempty"`
@@ -57,25 +61,23 @@ func (b *batchFile) requests() ([]api.ChangeRequest, error) {
var out []api.ChangeRequest
for i, e := range b.Changes {
n := 0
for _, set := range []bool{e.Add != nil, e.Delete != nil, e.Set != nil, e.Update != nil, e.Op != ""} {
for _, set := range []bool{e.Add != nil, e.Delete != nil, e.Set != nil, e.Update != nil, e.Disable != nil, e.Enable != nil, e.Op != ""} {
if set {
n++
}
}
if n != 1 {
return nil, fmt.Errorf("change %d: use exactly one of add, delete, set, update or op", i+1)
return nil, fmt.Errorf("change %d: use exactly one of add, delete, set, update, disable, enable or op", i+1)
}
switch {
case e.Add != nil:
out = append(out, api.ChangeRequest{Op: "record.create", Data: rawJSON(e.Add)})
case e.Delete != nil:
d := e.Delete
if id, ok := toInt64(d["id"]); ok {
delete(d, "id")
out = append(out, api.ChangeRequest{Op: "record.delete", ID: id, Data: rawJSON(d)})
} else {
out = append(out, api.ChangeRequest{Op: "record.delete", Data: rawJSON(d)})
}
out = append(out, byIDOrMatch("record.delete", e.Delete))
case e.Disable != nil:
out = append(out, byIDOrMatch("record.disable", e.Disable))
case e.Enable != nil:
out = append(out, byIDOrMatch("record.enable", e.Enable))
case e.Set != nil:
s := e.Set
switch d := s["data"].(type) {
@@ -104,12 +106,26 @@ func (b *batchFile) requests() ([]api.ChangeRequest, error) {
return out, nil
}
// byIDOrMatch is a request for the record with d's id, or for the records
// d matches.
func byIDOrMatch(op string, d map[string]any) api.ChangeRequest {
if id, ok := toInt64(d["id"]); ok {
delete(d, "id")
return api.ChangeRequest{Op: op, ID: id, Data: rawJSON(d)}
}
return api.ChangeRequest{Op: op, Data: rawJSON(d)}
}
// submitBatch applies requests as one changeset, or stages them into the
// changeset given with --changeset.
func submitBatch(c *client.Client, description string, reqs []api.ChangeRequest) error {
var resp api.MutationResponse
if c.Changeset > 0 {
if err := c.Do(ctx(), "POST", fmt.Sprintf("/changesets/%d/ops", c.Changeset), nil, map[string]any{"ops": reqs}, &resp); err != nil {
cs, err := c.UseChangeset(ctx())
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 report(&resp)
@@ -218,8 +234,9 @@ func changesetCmd() *cobra.Command {
Example: ` export JARL_CHANGESET=$(jarl changeset create "move web servers" -q)
jarl record add example.com www2 A 192.0.2.20
jarl record delete example.com www A
jarl changeset diff $JARL_CHANGESET
jarl changeset apply $JARL_CHANGESET`,
jarl changeset diff # JARL_CHANGESET's
jarl changeset apply
unset JARL_CHANGESET`,
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
@@ -233,21 +250,25 @@ func changesetCmd() *cobra.Command {
fmt.Println(cs.ID)
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
},
}
show := &cobra.Command{
Use: "show ID",
Short: "Show a changeset's operations",
Args: cobra.ExactArgs(1),
Use: "show [ID]",
Short: "Show a changeset's operations (default: -C or JARL_CHANGESET)",
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
id, _, err := changesetArg(c, args, false)
if err != nil {
return err
}
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 output(cs, func(w io.Writer) {
@@ -268,16 +289,20 @@ func changesetCmd() *cobra.Command {
},
}
diff := &cobra.Command{
Use: "diff ID",
Short: "Validate an open changeset and show what applying it would change",
Args: cobra.ExactArgs(1),
Use: "diff [ID]",
Short: "Validate an open changeset and show what applying it would change (default: -C or JARL_CHANGESET)",
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
id, fromEnv, err := changesetArg(c, args, true)
if err != nil {
return err
}
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
}
if flagOutput != "table" {
@@ -300,56 +325,82 @@ func changesetCmd() *cobra.Command {
fmt.Println()
fmt.Print(r.Diff)
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 fmt.Errorf("the changeset can't be applied as it is")
},
}
apply := &cobra.Command{
Use: "apply ID",
Short: "Apply an open changeset",
Args: cobra.ExactArgs(1),
Use: "apply [ID]",
Short: "Apply an open changeset (default: -C or JARL_CHANGESET)",
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
id, fromEnv, err := changesetArg(c, args, true)
if err != nil {
return err
}
var resp api.MutationResponse
if err := c.Do(ctx(), "POST", "/changesets/"+args[0]+"/apply", nil, map[string]any{"force": c.Force, "dry_run": c.DryRun}, &resp); err != nil {
if err := c.Do(ctx(), "POST", "/changesets/"+id+"/apply", nil, map[string]any{"force": c.Force, "dry_run": c.DryRun}, &resp); err != nil {
return err
}
return report(&resp)
if err := report(&resp); err != nil {
return err
}
if fromEnv && resp.Applied {
envChangesetDone(id, "applied")
}
return nil
},
}
discard := &cobra.Command{
Use: "discard ID",
Short: "Throw away an open changeset",
Args: cobra.ExactArgs(1),
Use: "discard [ID]",
Short: "Throw away an open changeset (default: -C or JARL_CHANGESET)",
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
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
}
if !flagQuiet {
fmt.Println("discarded changeset", args[0])
fmt.Println("discarded changeset", id)
}
if fromEnv {
envChangesetDone(id, "discarded")
}
return nil
},
}
remove := &cobra.Command{
Use: "remove ID SEQ",
Short: "Remove one operation from an open changeset",
Args: cobra.ExactArgs(2),
Use: "remove [ID] SEQ",
Short: "Remove one operation from an open changeset (default: -C or JARL_CHANGESET)",
Args: cobra.RangeArgs(1, 2),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
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{
+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
}
+11 -1
View File
@@ -24,7 +24,7 @@ const completeTimeout = 3 * time.Second
// setupCompletion adds value completion to the command tree.
func setupCompletion(root *cobra.Command) {
root.RegisterFlagCompletionFunc("server", completeServers(true))
root.RegisterFlagCompletionFunc("output", cobra.FixedCompletions([]string{"table", "json", "yaml"}, cobra.ShellCompDirectiveNoFileComp))
root.RegisterFlagCompletionFunc("output", cobra.FixedCompletions([]string{"table", "json", "yaml", "id", "field="}, cobra.ShellCompDirectiveNoFileComp|cobra.ShellCompDirectiveNoSpace))
var walk func(c *cobra.Command)
walk = func(c *cobra.Command) {
for _, name := range []string{"view", "user", "zone"} {
@@ -76,6 +76,8 @@ func argCompletion(c *cobra.Command) cobra.CompletionFunc {
return append([]string{"default"}, model.ViewColors...), cobra.ShellCompDirectiveNoFileComp
case p == "ACL":
return lookup(cmd, "acl")
case p == "LIST":
return lookup(cmd, "blocklist")
case p == "USER":
return lookup(cmd, "user")
case p == "KEY" && parent == "tsig":
@@ -199,6 +201,14 @@ func lookup(cmd *cobra.Command, kind string) ([]cobra.Completion, cobra.ShellCom
for _, v := range views {
addName(v.Name, v.Comment)
}
case "blocklist":
var lists []api.Blocklist
if c.Get(ctx(), "/blocking/lists", nil, &lists) != nil {
return nil, none
}
for _, b := range lists {
addName(b.Name, b.URL)
}
case "acl":
var acls []api.ACL
if c.Get(ctx(), "/acls", nil, &acls) != nil {
+39 -1
View File
@@ -201,7 +201,45 @@ func viewCmd() *cobra.Command {
return nil
},
}
cmd.AddCommand(list, create, update, del, order, color)
var keyNamed bool
key := &cobra.Command{
Use: "key VIEW",
Short: "Show the TSIG key that selects a view, to query it from anywhere",
Long: `Every view has a TSIG key (jarl-view-NAME) at the front of its
match-clients: a query signed with it lands in that view, whatever address
it comes from. Jarl signs its own checks of this server with it; use it to
see what a view answers:
jarl view key external # shows a ready dig -y command
jarl view key external --named-conf > external.key
dig -k external.key @SERVER adbraun.lol SOA
Showing it needs tsig:write and is recorded in the audit log.`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
var k struct {
Name string `json:"name"`
Algorithm string `json:"algorithm"`
Secret string `json:"secret"`
}
if err := c.Get(ctx(), "/views/"+url.PathEscape(args[0])+"/key", nil, &k); err != nil {
return err
}
if keyNamed {
fmt.Printf("key %q {\n\talgorithm %s;\n\tsecret %q;\n};\n", k.Name, k.Algorithm, k.Secret)
return nil
}
return output(k, func(w io.Writer) {
fmt.Fprintf(w, "Key:\t%s\nAlgorithm:\t%s\nSecret:\t%s\ndig:\tdig -y %s:%s:%s @SERVER NAME\n", k.Name, k.Algorithm, k.Secret, k.Algorithm, k.Name, k.Secret)
})
},
}
key.Flags().BoolVar(&keyNamed, "named-conf", false, "print a key statement (for dig -k FILE)")
cmd.AddCommand(list, create, update, del, order, color, key)
return cmd
}
+27 -3
View File
@@ -4,6 +4,7 @@ import (
"fmt"
"io"
"os"
"slices"
"strings"
"time"
@@ -110,7 +111,7 @@ A zone whose DS never reached the parent can be wiped at once instead
}
return nil
case st.Policy != "insecure":
if !confirm(fmt.Sprintf("Start turning DNSSEC off for %s? named keeps it signed until you've removed the DS record at your registrar.", displayName(st.Zone)), yes) {
if !confirm(fmt.Sprintf("Start turning DNSSEC off for %s? named keeps it signed until you've removed the DS record from the parent zone.", displayName(st.Zone)), yes) {
return fmt.Errorf("canceled")
}
next = "insecure"
@@ -133,7 +134,9 @@ A zone whose DS never reached the parent can be wiped at once instead
if err := report(resp); err != nil || !resp.Applied || flagQuiet {
return err
}
if next == "insecure" {
if next == "insecure" && st.ParentZone != "" {
fmt.Printf("\nNext: remove the DS record for %s from %s:\n %s\nthen run\n jarl zone dnssec check-ds %s\nRun 'jarl zone dnssec disable %s' again when 'status' says it has finished.\n", displayName(st.Zone), displayName(st.ParentZone), parentDSCommand(st, "delete", ""), args[0]+viewFlag(view), args[0]+viewFlag(view))
} else if next == "insecure" {
fmt.Printf("\nNext: remove the DS record for %s at your registrar, then run\n jarl zone dnssec check-ds %s\nRun 'jarl zone dnssec disable %s' again when 'status' says it has finished.\n", displayName(st.Zone), args[0]+viewFlag(view), args[0]+viewFlag(view))
} else {
fmt.Println("\nDNSSEC is off. The old keys stay in the key directory until you wipe them ('jarl zone dnssec wipe').")
@@ -344,7 +347,7 @@ func printDNSSEC(w io.Writer, r *api.DNSSECResponse) {
if len(ds) > 0 && r.Policy != "insecure" {
where := "at your registrar"
if r.ParentZone != "" {
where = "in the parent zone " + displayName(r.ParentZone) + " (in Jarl: add it as a DS record there)"
where = "in the parent zone " + displayName(r.ParentZone) + ", which is in Jarl: add it as a DS record there"
}
fmt.Fprintf(w, "\nDS record for the parent zone (%s):\n", where)
if !r.Phase.CanPublishDS && r.Phase.DSReadyAt != nil {
@@ -353,10 +356,31 @@ func printDNSSEC(w io.Writer, r *api.DNSSECResponse) {
for _, k := range ds {
fmt.Fprintf(w, " %s\n", strings.ReplaceAll(k.DS.Record, "\t", " "))
fmt.Fprintf(w, " key tag %d, algorithm %d (%s), digest type %d (SHA-256), digest %s\n", k.DS.KeyTag, k.DS.Algorithm, k.AlgorithmName, k.DS.DigestType, k.DS.Digest)
if r.ParentZone != "" {
data := fmt.Sprintf("%d %d %d %s", k.DS.KeyTag, k.DS.Algorithm, k.DS.DigestType, k.DS.Digest)
if slices.ContainsFunc(r.ParentDS, func(d string) bool { return strings.EqualFold(d, data) }) {
fmt.Fprintf(w, " %s has it.\n", displayName(r.ParentZone))
} else {
fmt.Fprintf(w, " %s\n", parentDSCommand(r, "add", data))
}
}
}
}
}
// parentDSCommand is the jarl command that adds (or deletes) the zone's DS
// record in its parent zone in Jarl.
func parentDSCommand(r *api.DNSSECResponse, verb, data string) string {
parts := []string{"jarl record", verb, displayName(r.ParentZone), strings.TrimSuffix(r.Zone, "."+r.ParentZone), "DS"}
if data != "" {
parts = append(parts, data)
}
if r.ParentView != "" {
parts = append(parts, "--view", r.ParentView)
}
return strings.Join(parts, " ")
}
func dash(s string) string {
if s == "" {
return "-"
+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)
}
}
+118 -11
View File
@@ -3,6 +3,7 @@
package main
import (
"bytes"
"context"
"encoding/json"
"errors"
@@ -11,7 +12,7 @@ import (
"net/url"
"os"
"path/filepath"
"strconv"
"slices"
"strings"
"sync"
"text/tabwriter"
@@ -147,12 +148,18 @@ func newClient() (*client.Client, error) {
}
c.Changeset = flagChangeset
if c.Changeset == 0 {
if v := os.Getenv("JARL_CHANGESET"); v != "" {
n, err := strconv.ParseInt(v, 10, 64)
if err != nil {
return nil, fmt.Errorf("invalid JARL_CHANGESET %q", v)
}
n, err := envChangeset()
if err != nil {
return nil, err
}
if n > 0 {
// Checked when a change is first staged into it
// (envchangeset.go).
c.Changeset = n
c.CheckChangeset = func(context.Context) error {
_, err := checkEnvChangeset(c, n, true)
return err
}
}
}
c.DryRun, c.Force = flagDryRun || c.Preflight, flagForce
@@ -237,10 +244,10 @@ 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.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.StringVarP(&flagOutput, "output", "o", "table", "output format: table, json or yaml")
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.StringVar(&flagCACert, "ca-cert", "", "PEM file with a CA to trust for the server's certificate")
pf.BoolVarP(&flagQuiet, "quiet", "q", false, "only print errors (and IDs where useful)")
pf.BoolVarP(&flagQuiet, "quiet", "q", false, "only print errors and IDs (with show and list: just the IDs, like -o id)")
root.AddGroup(&cobra.Group{ID: "dns", Title: "DNS commands:"}, &cobra.Group{ID: "changes", Title: "Changes:"}, &cobra.Group{ID: "admin", Title: "Administration:"})
add := func(group string, cmds ...*cobra.Command) {
for _, c := range cmds {
@@ -248,7 +255,7 @@ Exit codes: 0 ok, 1 error, 3 not authenticated or not allowed, 4 not found,
root.AddCommand(c)
}
}
add("dns", zoneCmd(), recordCmd(), viewCmd(), aclCmd(), tsigCmd(), optionCmd(), settingsCmd(), acmeCmd(), searchCmd(), statsCmd())
add("dns", zoneCmd(), recordCmd(), viewCmd(), aclCmd(), tsigCmd(), optionCmd(), settingsCmd(), blockCmd(), blocklistCmd(), acmeCmd(), searchCmd(), statsCmd())
add("changes", applyCmd(), changesetCmd(), historyCmd())
add("admin", statusCmd(), whoamiCmd(), userCmd(), tokenCmd(), auditCmd(), checkCmd(), resyncCmd(), configCmd())
setupCompletion(root)
@@ -262,7 +269,15 @@ func ctx() context.Context { return context.Background() }
// ---- output
func output(v any, table func(w io.Writer)) error {
switch flagOutput {
format := flagOutput
quietIDs := flagQuiet && (format == "table" || format == "")
if quietIDs {
format = "id"
}
if fieldFormat(format) {
return outputFields(v, format, quietIDs)
}
switch format {
case "json":
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
@@ -286,7 +301,79 @@ func output(v any, table func(w io.Writer)) error {
table(tw)
return tw.Flush()
}
return fmt.Errorf("unknown output format %q (use table, json or yaml)", flagOutput)
return fmt.Errorf("unknown output format %q (use table, json, yaml, id or field=NAME)", flagOutput)
}
// outputFields prints fields of each object (or of the one object), one
// line per object with the fields separated by tabs, for scripts:
// "id" prints the IDs, "field=view,soa.serial" those fields. Names are the
// JSON field names (see -o json); a dot reaches into nested objects. A
// field an object doesn't have prints as empty (the API leaves out empty
// fields).
func outputFields(v any, format string, quiet bool) error {
fields := []string{"id"}
if f, ok := strings.CutPrefix(format, "field="); ok {
fields = strings.Split(f, ",")
}
b, err := json.Marshal(v)
if err != nil {
return err
}
dec := json.NewDecoder(bytes.NewReader(b))
dec.UseNumber()
var x any
if err := dec.Decode(&x); err != nil {
return err
}
items, ok := x.([]any)
if !ok {
items = []any{x}
}
if quiet && !slices.ContainsFunc(items, func(it any) bool { _, ok := lookupField(it, "id"); return ok }) {
return nil // -q on something without IDs: only errors
}
var out strings.Builder
for _, it := range items {
var vals []string
for _, f := range fields {
val, _ := lookupField(it, strings.TrimSpace(f))
vals = append(vals, fieldText(val))
}
out.WriteString(strings.Join(vals, "\t") + "\n")
}
_, err = io.WriteString(os.Stdout, out.String())
return err
}
// fieldFormat reports whether an -o format prints fields (id, field=...).
func fieldFormat(format string) bool {
return format == "id" || strings.HasPrefix(format, "field=")
}
func lookupField(v any, path string) (any, bool) {
for _, part := range strings.Split(path, ".") {
m, ok := v.(map[string]any)
if !ok {
return nil, false
}
if v, ok = m[part]; !ok {
return nil, false
}
}
return v, true
}
func fieldText(v any) string {
switch x := v.(type) {
case nil:
return ""
case string:
return x
case json.Number, bool:
return fmt.Sprint(x)
}
b, _ := json.Marshal(v)
return string(b)
}
func dot(name string) string {
@@ -391,6 +478,14 @@ func report(resp *api.MutationResponse) error {
if flagOutput == "json" || flagOutput == "yaml" {
return output(resp, nil)
}
if fieldFormat(flagOutput) {
// The changed object, if the response has it ("jarl record add
// ... -o id" prints the new record's ID).
if len(resp.Object) > 0 {
return outputFields(resp.Object, flagOutput, false)
}
return outputFields(resp, flagOutput, false)
}
w := os.Stdout
r := client.ParseResult(resp.Result)
switch {
@@ -440,6 +535,18 @@ func report(resp *api.MutationResponse) error {
json.Unmarshal(resp.Problems, &introduced)
}
printProblems(os.Stderr, introduced)
// named-checkconf's own warnings about the new configuration.
if r != nil && (resp.Applied || resp.DryRun) {
for _, m := range r.Check {
if !m.Error && !strings.HasPrefix(m.Text, "loaded serial") && m.Text != "OK" {
where := ""
if m.Object != "" {
where = " (" + m.Object + ")"
}
fmt.Fprintf(os.Stderr, "warning: named-checkconf: %s%s\n", m.Text, where)
}
}
}
for _, warn := range resp.Warnings {
fmt.Fprintln(os.Stderr, "warning:", warn)
}
+78
View File
@@ -5,6 +5,7 @@ import (
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
@@ -22,6 +23,8 @@ changes:
- set: {zone: example.com, name: api, type: A, data: 192.0.2.5}
- set: {zone: example.com, name: gone, type: TXT}
- update: {id: 13, data: 192.0.2.9}
- disable: {zone: example.com, name: www, type: A, data: 192.0.2.1, ptr: true}
- enable: {id: 14}
- op: zone.create
data: {name: lab.example.com}
`
@@ -44,6 +47,8 @@ changes:
{"rrset.replace", 0, `{"data":["192.0.2.5"],"name":"api","type":"A","zone":"example.com"}`},
{"rrset.replace", 0, `{"data":[],"name":"gone","type":"TXT","zone":"example.com"}`},
{"record.update", 13, `{"data":"192.0.2.9"}`},
{"record.disable", 0, `{"data":"192.0.2.1","name":"www","ptr":true,"type":"A","zone":"example.com"}`},
{"record.enable", 14, `{}`},
{"zone.create", 0, `{"name":"lab.example.com"}`},
}
if len(reqs) != len(want) {
@@ -112,6 +117,38 @@ func TestListValue(t *testing.T) {
// filters they send: flags bound to variables shared between subcommands
// must not leak defaults (zone create's --type primary once hid every
// non-primary zone from 'zone list').
func TestDisableCommands(t *testing.T) {
var got string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
got = r.Method + " " + r.URL.Path + "?" + r.URL.RawQuery
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"applied":true,"changeset_id":1}`))
}))
defer srv.Close()
t.Setenv("JARL_CLI_CONFIG", filepath.Join(t.TempDir(), "cli.yaml"))
for _, tc := range []struct {
args []string
want string
}{
{[]string{"record", "disable", "example.com", "www", "A"}, "POST /api/v1/zones/example.com/records/disable?name=www&type=A"},
{[]string{"record", "disable", "example.com", "www", "A", "192.0.2.1", "--ptr", "--view", "internal"}, "POST /api/v1/zones/example.com/records/disable?data=192.0.2.1&name=www&ptr=1&type=A&view=internal"},
{[]string{"record", "enable", "--id", "12", "--ptr"}, "POST /api/v1/records/12/enable?ptr=1"},
{[]string{"record", "enable", "example.com", "old", "CNAME", "--missing-ok"}, "POST /api/v1/zones/example.com/records/enable?missing_ok=1&name=old&type=CNAME"},
} {
got = ""
root := rootCmd()
root.SetArgs(append(tc.args, "--server", srv.URL, "--token", "jarl_test_token", "-o", "json"))
root.SetOut(io.Discard)
if err := root.Execute(); err != nil {
t.Errorf("%v: %v", tc.args, err)
continue
}
if got != tc.want {
t.Errorf("%v sent %q, want %q", tc.args, got, tc.want)
}
}
}
func TestListQueries(t *testing.T) {
var got string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -154,3 +191,44 @@ func TestListQueries(t *testing.T) {
}
}
}
func TestOutputFields(t *testing.T) {
capture := func(f func() error) string {
t.Helper()
r, w, _ := os.Pipe()
old := os.Stdout
os.Stdout = w
err := f()
os.Stdout = old
w.Close()
b, _ := io.ReadAll(r)
if err != nil {
t.Fatal(err)
}
return string(b)
}
zones := []map[string]any{
{"id": 12, "name": "adbraun.lol.", "view": "external", "soa": map[string]any{"serial": 2026100901}},
{"id": 3, "name": "lab.adbraun.lol.", "soa": map[string]any{"serial": 7}},
}
defer func(o string, q bool) { flagOutput, flagQuiet = o, q }(flagOutput, flagQuiet)
for _, tc := range []struct {
format string
quiet bool
v any
want string
}{
{"id", false, zones, "12\n3\n"},
{"id", false, zones[0], "12\n"},
{"field=name,view,soa.serial", false, zones, "adbraun.lol.\texternal\t2026100901\nlab.adbraun.lol.\t\t7\n"},
{"field=soa", false, zones[1], "{\"serial\":7}\n"},
{"table", true, zones, "12\n3\n"},
{"table", true, map[string]any{"status": "ok"}, ""}, // -q without IDs: nothing
} {
flagOutput, flagQuiet = tc.format, tc.quiet
got := capture(func() error { return output(tc.v, func(io.Writer) { t.Error("table printed") }) })
if got != tc.want {
t.Errorf("-o %s (quiet %v) = %q, want %q", tc.format, tc.quiet, got, tc.want)
}
}
}
+101
View File
@@ -0,0 +1,101 @@
package main
import (
"fmt"
"os"
"strings"
"jarl/internal/api"
"jarl/internal/client"
)
// zoneViews expands a --view value for a record command: "" or one view
// as it is, "a,b" to those views, and "all" to every view that has the
// zone.
func zoneViews(c *client.Client, zone, view string) ([]string, error) {
if view != "all" && !strings.Contains(view, ",") {
return []string{view}, nil
}
if view != "all" {
var out []string
for _, v := range strings.Split(view, ",") {
if v = strings.TrimSpace(v); v != "" {
out = append(out, v)
}
}
return out, nil
}
var zones []api.Zone
if err := c.Get(ctx(), "/zones", nil, &zones); err != nil {
return nil, err
}
want := strings.ToLower(strings.TrimSuffix(zone, "."))
var out []string
for _, z := range zones {
if strings.ToLower(strings.TrimSuffix(z.Display, ".")) == want && z.View != "" {
out = append(out, z.View)
}
}
if len(out) == 0 {
return nil, fmt.Errorf("zone %s is in no view", zone)
}
return out, nil
}
// perView runs a record command once per view. With several views, the
// changes are staged into one changeset and applied together, so either
// every view gets the change or none does (with --changeset, they are
// staged there).
//
// do returns the server's response, or nil when there was nothing to do.
func perView(c *client.Client, views []string, description string, do func(c *client.Client, view string) (*api.MutationResponse, error)) error {
if len(views) == 1 || c.Changeset > 0 {
for _, v := range views {
resp, err := do(c, v)
if err != nil {
if len(views) > 1 {
err = fmt.Errorf("view %s: %w", v, err)
}
return err
}
if resp != nil {
if err := report(resp); err != nil {
return err
}
}
}
return nil
}
var cs api.Changeset
if err := c.Do(ctx(), "POST", "/changesets", nil, map[string]any{"description": description + " (views " + strings.Join(views, ", ") + ")"}, &cs); err != nil {
return err
}
discard := func() { c.Do(ctx(), "POST", fmt.Sprintf("/changesets/%d/discard", cs.ID), nil, nil, nil) }
staging := *c
staging.Changeset, staging.DryRun = cs.ID, false
staged := 0
for _, v := range views {
resp, err := do(&staging, v)
if err != nil {
discard()
fmt.Fprintf(os.Stderr, "view %s failed, so nothing was changed in any view\n", v)
return err
}
if resp != nil {
staged++
}
}
if staged == 0 {
discard()
return nil
}
var resp api.MutationResponse
err := c.Do(ctx(), "POST", fmt.Sprintf("/changesets/%d/apply", cs.ID), nil, map[string]any{"force": c.Force, "dry_run": c.DryRun}, &resp)
if err != nil || c.DryRun {
discard()
}
if err != nil {
return err
}
return report(&resp)
}
+109 -10
View File
@@ -4,6 +4,7 @@ import (
"fmt"
"io"
"math"
"slices"
"strconv"
"strings"
"time"
@@ -91,6 +92,9 @@ export the data.`,
if s.NXDomainRatio != nil {
fmt.Fprintf(w, "NXDOMAIN:\t%.1f%% of queries\n", *s.NXDomainRatio*100)
}
if s.BlockedRatio != nil {
fmt.Fprintf(w, "Blocked:\t%d (%.1f%% of queries): %s\n", s.Blocked, *s.BlockedRatio*100, entryLine(s.Blocklists, s.Blocked, 6))
}
fmt.Fprintf(w, "Result codes:\t%s\n", entryLine(s.RCodes, s.Queries, 6))
fmt.Fprintf(w, "Record types:\t%s\n", entryLine(s.QTypes, s.Queries, 8))
if len(s.Answers) > 0 {
@@ -102,14 +106,18 @@ export the data.`,
if len(s.Zones) > 0 {
fmt.Fprintf(w, "Zones:\t%s\n", strings.ReplaceAll(entryLine(s.Zones, 0, 10), stats.OtherZone+" ", "other "))
}
for _, k := range []string{stats.KindName, stats.KindClient, stats.KindNXDomain} {
for _, k := range []string{stats.KindName, stats.KindClient, stats.KindNXDomain, stats.KindBlocked} {
es := s.Tops[k]
if len(es) == 0 {
continue
}
fmt.Fprintf(w, "\n%s\t\n", map[string]string{stats.KindName: "Top names", stats.KindClient: "Top clients", stats.KindNXDomain: "Top NXDOMAIN names"}[k])
fmt.Fprintf(w, "\n%s\t\n", map[string]string{stats.KindName: "Top names", stats.KindClient: "Top clients", stats.KindNXDomain: "Top NXDOMAIN names", stats.KindBlocked: "Top blocked names"}[k])
for _, e := range es {
fmt.Fprintf(w, " %s\t%d\n", e.Key, e.Count)
key := e.Key
if e.Name != "" {
key += " (" + e.Name + ")"
}
fmt.Fprintf(w, " %s\t%d\n", key, e.Count)
}
}
})
@@ -120,12 +128,14 @@ export the data.`,
var limit int
top := &cobra.Command{
Use: "top [names|clients|nxdomain|servfail|refused]",
Short: "The most queried names, busiest clients, names with errors, refused clients",
Use: "top [names|clients|nxdomain|servfail|refused|blocked|blocked-clients]",
Short: "The most queried names, busiest clients, names with errors, refused clients, blocked names",
Example: ` jarl stats top names --since 7d
jarl stats top clients --limit 50
jarl stats top nxdomain # misconfigured hosts and typos
jarl stats top refused # who is asking for what they may not have`,
jarl stats top refused # who is asking for what they may not have
jarl stats top blocked # what blocking stops most (docs/blocking.md)
jarl stats top blocked-clients # the devices blocking stops most`,
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
kind := stats.KindName
@@ -134,6 +144,9 @@ export the data.`,
if kind == "nxdomain" || kind == "servfail" {
kind = strings.ToLower(args[0])
}
if kind == "blocked-client" {
kind = stats.KindBlockedClient
}
}
c, err := newClient()
if err != nil {
@@ -148,13 +161,22 @@ export the data.`,
for _, e := range es {
total += e.Count
}
head := map[string]string{stats.KindClient: "CLIENT", stats.KindRefused: "CLIENT"}[kind]
head := map[string]string{stats.KindClient: "CLIENT", stats.KindRefused: "CLIENT", stats.KindBlockedClient: "CLIENT"}[kind]
if head == "" {
head = "NAME"
}
fmt.Fprintf(w, "#\t%s\tQUERIES\t\n", head)
named := slices.ContainsFunc(es, func(e stats.Entry) bool { return e.Name != "" })
if named {
fmt.Fprintf(w, "#\t%s\tHOST\tQUERIES\t\n", head)
} else {
fmt.Fprintf(w, "#\t%s\tQUERIES\t\n", head)
}
for i, e := range es {
fmt.Fprintf(w, "%d\t%s\t%d\t%s\n", i+1, e.Key, e.Count, bar(e.Count, es[0].Count, 20))
if named {
fmt.Fprintf(w, "%d\t%s\t%s\t%d\t%s\n", i+1, e.Key, dash(e.Name), e.Count, bar(e.Count, es[0].Count, 20))
} else {
fmt.Fprintf(w, "%d\t%s\t%d\t%s\n", i+1, e.Key, e.Count, bar(e.Count, es[0].Count, 20))
}
}
})
},
@@ -303,7 +325,61 @@ export the data.`,
}
sinceFlag(breakdown, &breakdownSince, "24h", sinceHelp)
cmd.AddCommand(status, summary, top, timeline, heatmap, breakdown)
server := &cobra.Command{
Use: "server",
Short: "Show named's memory, CPU, cache and clients, the host's memory and load, and free disk space",
Long: `Server statistics, sampled once a minute: named's memory and CPU use,
cache size, recursive and TCP clients against their limits, the host's
memory and load, and the space left where zone files, logs and Jarl's
data are. Memory and CPU over time: 'jarl stats timeline --key
g:named:rss,g:named:cpu' (averages; CPU in tenths of a percent).`,
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
var resp struct {
Server *stats.Server `json:"server"`
Named map[string]string `json:"named,omitempty"`
NamedError string `json:"named_error,omitempty"`
Disks []stats.Disk `json:"disks"`
}
if err := c.Get(ctx(), "/stats/server", nil, &resp); err != nil {
return err
}
return output(resp, func(w io.Writer) {
s := resp.Server
if s == nil {
fmt.Fprintln(w, "No sample yet:\tthe first is taken a minute after jarl starts")
return
}
fmt.Fprintf(w, "named:\t%s, up since %s, reconfigured %s\n", s.Version, isoLocal(s.BootTime), isoLocal(s.ConfigTime))
if s.PID > 0 {
fmt.Fprintf(w, "Memory:\t%s resident (named counts %s), %d threads, PID %d\n", sizeStr(s.RSS), sizeStr(s.MemInUse), s.Threads, s.PID)
fmt.Fprintf(w, "CPU:\t%.1f%% of one CPU over the last minute\n", s.CPUPercent)
} else {
fmt.Fprintf(w, "Memory:\tnamed counts %s (its process isn't visible to jarld)\n", sizeStr(s.MemInUse))
}
fmt.Fprintf(w, "Cache:\t%d RRsets, %s\n", s.CacheRRsets, sizeStr(s.CacheMem))
if n := resp.Named; n != nil {
fmt.Fprintf(w, "Recursive clients:\t%s (now/soft limit/limit), most at once %d\n", n["recursive_clients"], s.RecursionHighwater)
fmt.Fprintf(w, "TCP clients:\t%s (now/limit), most at once %d\n", n["tcp_clients"], s.TCPHighwater)
} else if resp.NamedError != "" {
fmt.Fprintf(w, "Clients:\tunknown: %s\n", resp.NamedError)
}
fmt.Fprintf(w, "Sockets:\t%d UDP, %d TCP\n", s.SocketsUDP, s.SocketsTCP)
if h := s.Host; h != nil && h.MemTotal > 0 {
fmt.Fprintf(w, "Host:\t%s of %s memory in use, load %v, %d CPUs, up %s\n", sizeStr(h.MemTotal-h.MemAvailable), sizeStr(h.MemTotal), h.Load, h.CPUs, (time.Duration(h.Uptime) * time.Second).String())
}
for _, d := range resp.Disks {
fmt.Fprintf(w, "Disk:\t%s free of %s for %s (%s)\n", sizeStr(d.Free), sizeStr(d.Total), strings.Join(d.Holds, ", "), d.Path)
}
})
},
}
cmd.AddCommand(status, summary, top, timeline, heatmap, breakdown, server)
return cmd
}
@@ -376,3 +452,26 @@ func sparkline(vals []int64) string {
}
return sb.String()
}
// sizeStr formats a size in binary units.
func sizeStr(n int64) string {
units := []string{"B", "KiB", "MiB", "GiB", "TiB"}
f, i := float64(n), 0
for f >= 1024 && i < len(units)-1 {
f /= 1024
i++
}
if i == 0 || f >= 100 {
return fmt.Sprintf("%.0f %s", f, units[i])
}
return fmt.Sprintf("%.1f %s", f, units[i])
}
// isoLocal shows a time named reports (RFC 3339) in local time.
func isoLocal(s string) string {
t, err := time.Parse(time.RFC3339Nano, s)
if err != nil {
return s
}
return t.Local().Format("2006-01-02 15:04")
}
+223 -30
View File
@@ -3,12 +3,14 @@ package main
import (
"encoding/json"
"fmt"
"golang.org/x/term"
"io"
"net/netip"
"net/url"
"os"
"strconv"
"strings"
"unicode/utf8"
"github.com/spf13/cobra"
@@ -43,7 +45,11 @@ func zoneCmd() *cobra.Command {
if z.Type == "primary" {
serial, recs = fmt.Sprint(z.SOA.Serial), fmt.Sprint(z.Records)
}
fmt.Fprintf(w, "%d\t%s\t%s\t%s\t%s\t%s\n", z.ID, z.Display, z.View, z.Type, recs, serial)
typ := z.Type
if z.Disabled {
typ += " (disabled)"
}
fmt.Fprintf(w, "%d\t%s\t%s\t%s\t%s\t%s\n", z.ID, z.Display, z.View, typ, recs, serial)
}
})
},
@@ -71,11 +77,18 @@ func zoneCmd() *cobra.Command {
fmt.Fprintf(w, "View:\t%s\n", z.View)
}
fmt.Fprintf(w, "Type:\t%s\n", z.Type)
if z.Disabled {
fmt.Fprintf(w, "Disabled:\tyes: named doesn't load it ('jarl zone enable %s')\n", args[0])
}
if z.Type == "primary" {
fmt.Fprintf(w, "Serial:\t%d\n", z.SOA.Serial)
fmt.Fprintf(w, "SOA:\t%s %s refresh %d retry %d expire %d minimum %d\n", z.SOA.MName, z.SOA.RName, z.SOA.Refresh, z.SOA.Retry, z.SOA.Expire, z.SOA.Minimum)
fmt.Fprintf(w, "Default TTL:\t%d\n", z.DefaultTTL)
fmt.Fprintf(w, "Records:\t%d\n", z.Records)
if z.DisabledRecords > 0 {
fmt.Fprintf(w, "Records:\t%d (%d disabled: jarl record list %s --disabled)\n", z.Records, z.DisabledRecords, args[0])
} else {
fmt.Fprintf(w, "Records:\t%d\n", z.Records)
}
}
if z.InView != "" {
fmt.Fprintf(w, "In view:\t%s\n", z.InView)
@@ -300,6 +313,42 @@ and a network between octet boundaries, like a /22, gets one zone per /24
del.Flags().StringVar(&view, "view", "", "view of the zone")
del.Flags().BoolVarP(&yes, "yes", "y", false, "don't ask for confirmation")
toggle := func(disable bool) *cobra.Command {
verb := map[bool]string{true: "disable", false: "enable"}[disable]
long := `Load a disabled zone in named again, as it is in Jarl now (changes made
while it was disabled included).`
if disable {
long = `Disable a zone: it stays in Jarl with its records, options and history,
and can still be edited, but named no longer loads it. Its names are then
answered by recursion where the view offers it (the internet's version of
the zone, if any), and REFUSED elsewhere. Signed zones can't be disabled;
turn signing off first.
jarl zone disable old.adbraun.lol
jarl zone enable old.adbraun.lol`
}
c := &cobra.Command{
Use: verb + " ZONE",
Short: map[bool]string{true: "Stop serving a zone, keeping it in Jarl", false: "Serve a disabled zone again"}[disable],
Long: long,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
path, qv := zonePath(args[0], view)
resp, err := c.Mutate(ctx(), "PATCH", path, qv, map[string]any{"disabled": disable})
if err != nil {
return err
}
return report(resp)
},
}
c.Flags().StringVar(&view, "view", "", "view of the zone")
return c
}
fileCmd := &cobra.Command{
Use: "file ZONE",
Short: "Print the zone file Jarl generates",
@@ -367,7 +416,7 @@ equal to the file: records not in the file are deleted.`,
importCmd.Flags().BoolVar(&replace, "replace", false, "delete records that are not in the file")
importCmd.Flags().BoolVar(&useSOA, "use-soa", false, "also take SOA timers and $TTL from the file")
cmd.AddCommand(list, show, create, update, del, fileCmd, importCmd, zoneSerialsCmd(), zoneDelegationCmd(), zoneDNSSECCmd(), zoneRegistrarCmd())
cmd.AddCommand(list, show, create, update, del, toggle(true), toggle(false), fileCmd, importCmd, zoneSerialsCmd(), zoneDelegationCmd(), zoneDNSSECCmd(), zoneRegistrarCmd())
return cmd
}
@@ -400,10 +449,14 @@ func jsonUnmarshal(b []byte, v any) error { return json.Unmarshal(b, v) }
// ---- records
// viewsFlagHelp describes --view of the record commands that change
// records.
const viewsFlagHelp = "view of the zone; several (internal,external) or \"all\" make the same change in each, together"
func recordCmd() *cobra.Command {
cmd := &cobra.Command{Use: "record", Aliases: []string{"records", "rr"}, Short: "Manage records in primary zones"}
var view, name, typ, search, comment, ttlArg string
var ptr, missingOK, yes bool
var ptr, missingOK, yes, onlyDisabled, wide bool
var id int64
list := &cobra.Command{
@@ -419,18 +472,31 @@ func recordCmd() *cobra.Command {
for k, v := range q("name", name, "type", strings.ToUpper(typ), "q", search) {
qv[k] = v
}
if onlyDisabled {
qv.Set("disabled", "1")
}
var recs []api.Record
if err := c.Get(ctx(), path+"/records", qv, &recs); err != nil {
return err
}
return output(recs, func(w io.Writer) {
fmt.Fprintln(w, "ID\tNAME\tTTL\tTYPE\tDATA\tCOMMENT")
rows := [][]string{{"ID", "NAME", "TTL", "TYPE", "DATA", "COMMENT"}}
for _, r := range recs {
ttl := fmt.Sprint(r.EffectiveTTL)
if r.TTL == nil {
ttl += "*"
}
fmt.Fprintf(w, "%d\t%s\t%s\t%s\t%s\t%s\n", r.ID, r.RelativeName, ttl, r.Type, r.Data, r.Comment)
comment := r.Comment
if r.Disabled {
comment = strings.TrimSpace("[disabled] " + comment)
}
rows = append(rows, []string{fmt.Sprint(r.ID), r.RelativeName, ttl, r.Type, r.Data, comment})
}
if !wide {
fitColumn(rows, 4)
}
for _, row := range rows {
fmt.Fprintln(w, strings.Join(row, "\t"))
}
})
},
@@ -439,6 +505,8 @@ func recordCmd() *cobra.Command {
list.Flags().StringVar(&name, "name", "", "only this name")
list.Flags().StringVarP(&typ, "type", "t", "", "only this type")
list.Flags().StringVar(&search, "search", "", "only records whose name, data or comment contains this")
list.Flags().BoolVar(&onlyDisabled, "disabled", false, "only disabled records")
list.Flags().BoolVarP(&wide, "wide", "w", false, "show long data in full (on a terminal it is shortened to fit)")
add := &cobra.Command{
Use: "add ZONE NAME TYPE DATA...",
@@ -467,15 +535,17 @@ and splitting are automatic).
if ptr {
body["ptr"] = true
}
path, qv := zonePath(args[0], view)
resp, err := c.Mutate(ctx(), "POST", path+"/records", qv, body)
views, err := zoneViews(c, args[0], view)
if err != nil {
return err
}
return report(resp)
return perView(c, views, "add "+args[1]+" "+strings.ToUpper(args[2]), func(c *client.Client, view string) (*api.MutationResponse, error) {
path, qv := zonePath(args[0], view)
return c.Mutate(ctx(), "POST", path+"/records", qv, body)
})
},
}
add.Flags().StringVar(&view, "view", "", "view of the zone")
add.Flags().StringVar(&view, "view", "", viewsFlagHelp)
add.Flags().StringVar(&ttlArg, "ttl", "", "TTL in seconds (default: the RRset's or the zone's)")
add.Flags().StringVar(&comment, "comment", "", "comment")
add.Flags().BoolVar(&ptr, "ptr", false, "also add the PTR record (A/AAAA)")
@@ -560,26 +630,28 @@ or one record by ID.
if len(args) == 3 && !confirm(fmt.Sprintf("Delete all %s records of %s?", strings.ToUpper(args[2]), args[1]), yes) {
return fmt.Errorf("canceled")
}
path, qv := zonePath(args[0], view)
qv.Set("name", args[1])
qv.Set("type", args[2])
if len(args) > 3 {
qv.Set("data", strings.Join(args[3:], " "))
}
if missingOK {
qv.Set("missing_ok", "1")
}
if ptr {
qv.Set("ptr", "1")
}
resp, err := c.Mutate(ctx(), "DELETE", path+"/records", qv, nil)
views, err := zoneViews(c, args[0], view)
if err != nil {
return err
}
return report(resp)
return perView(c, views, "delete "+args[1]+" "+strings.ToUpper(args[2]), func(c *client.Client, view string) (*api.MutationResponse, error) {
path, qv := zonePath(args[0], view)
qv.Set("name", args[1])
qv.Set("type", args[2])
if len(args) > 3 {
qv.Set("data", strings.Join(args[3:], " "))
}
if missingOK {
qv.Set("missing_ok", "1")
}
if ptr {
qv.Set("ptr", "1")
}
return c.Mutate(ctx(), "DELETE", path+"/records", qv, nil)
})
},
}
del.Flags().StringVar(&view, "view", "", "view of the zone")
del.Flags().StringVar(&view, "view", "", viewsFlagHelp)
del.Flags().Int64Var(&id, "id", 0, "delete the record with this ID")
del.Flags().BoolVar(&missingOK, "missing-ok", false, "succeed when nothing matches")
del.Flags().BoolVar(&ptr, "ptr", false, "also delete the matching PTR record (A/AAAA)")
@@ -606,19 +678,104 @@ spaces.
if comment != "" {
body["comment"] = comment
}
path, qv := zonePath(args[0], view)
resp, err := c.Mutate(ctx(), "PUT", path+"/rrsets/"+urlPathEscape(args[1])+"/"+urlPathEscape(args[2]), qv, body)
views, err := zoneViews(c, args[0], view)
if err != nil {
return err
}
return report(resp)
return perView(c, views, "set "+args[1]+" "+strings.ToUpper(args[2]), func(c *client.Client, view string) (*api.MutationResponse, error) {
path, qv := zonePath(args[0], view)
return c.Mutate(ctx(), "PUT", path+"/rrsets/"+urlPathEscape(args[1])+"/"+urlPathEscape(args[2]), qv, body)
})
},
}
set.Flags().StringVar(&view, "view", "", "view of the zone")
set.Flags().StringVar(&view, "view", "", viewsFlagHelp)
set.Flags().StringVar(&ttlArg, "ttl", "", "TTL for the RRset")
set.Flags().StringVar(&comment, "comment", "", "comment for new records")
cmd.AddCommand(list, add, update, del, set)
toggle := func(disable bool) *cobra.Command {
verb := "enable"
long := `Enable disabled records again: by name and type (all of them, or only the
one with DATA), or one record by ID. The record is checked as if it were
added, so a CNAME added in the meantime at its name stops it.
jarl record enable example.com www A 192.0.2.10
jarl record enable --id 1234 --ptr`
if disable {
verb = "disable"
long = `Disable records: they stay in Jarl, with their TTL, comment and ID, but are
left out of the zone (the zone file lists them as comments) until they are
enabled again. Select them by name and type (all of them, or only the one
with DATA), or one record by ID.
jarl record disable example.com www A 192.0.2.10 --ptr # and its PTR record
jarl record disable example.com old CNAME
jarl record disable --id 1234
jarl record list example.com --disabled`
}
var tview string
var tid int64
var tptr, tmissing bool
c := &cobra.Command{
Use: verb + " ZONE NAME TYPE [DATA...] | " + verb + " --id ID",
Short: strings.ToUpper(verb[:1]) + verb[1:] + " records",
Long: long,
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
qv := q()
if tptr {
qv.Set("ptr", "1")
}
if tid > 0 {
resp, err := c.Mutate(ctx(), "POST", "/records/"+strconv.FormatInt(tid, 10)+"/"+verb, qv, nil)
if err != nil {
return err
}
return report(resp)
}
if len(args) < 3 {
return fmt.Errorf("give ZONE NAME TYPE [DATA], or --id")
}
views, err := zoneViews(c, args[0], tview)
if err != nil {
return err
}
return perView(c, views, verb+" "+args[1]+" "+strings.ToUpper(args[2]), func(c *client.Client, view string) (*api.MutationResponse, error) {
path, zq := zonePath(args[0], view)
vq := q()
for k, v := range qv {
vq[k] = v
}
for k, v := range zq {
vq[k] = v
}
vq.Set("name", args[1])
vq.Set("type", args[2])
if len(args) > 3 {
vq.Set("data", strings.Join(args[3:], " "))
}
if tmissing {
vq.Set("missing_ok", "1")
}
resp, err := c.Mutate(ctx(), "POST", path+"/records/"+verb, vq, nil)
if err != nil && tmissing && strings.HasSuffix(err.Error(), "nothing to do") {
fmt.Println("nothing to do")
return nil, nil
}
return resp, err
})
},
}
c.Flags().StringVar(&tview, "view", "", viewsFlagHelp)
c.Flags().Int64Var(&tid, "id", 0, verb+" the record with this ID")
c.Flags().BoolVar(&tmissing, "missing-ok", false, "succeed when nothing matches or the records are already "+verb+"d")
c.Flags().BoolVar(&tptr, "ptr", false, "also "+verb+" the matching PTR record (A/AAAA)")
return c
}
cmd.AddCommand(list, add, update, del, set, toggle(true), toggle(false))
return cmd
}
@@ -636,3 +793,39 @@ func setTTL(body map[string]any, ttl string) error {
}
return nil
}
// fitColumn shortens column col of a table (header row first) so that the
// rows fit the terminal: the table pads every row to its longest cell, so
// one long TXT record would make every row wrap. Nothing is shortened when
// standard output isn't a terminal.
func fitColumn(rows [][]string, col int) {
fd := int(os.Stdout.Fd())
if !term.IsTerminal(fd) {
return
}
width, _, err := term.GetSize(fd)
if err != nil || width <= 0 {
return
}
widths := make([]int, len(rows[0]))
for _, row := range rows {
for i, c := range row {
widths[i] = max(widths[i], utf8.RuneCountInString(c))
}
}
used := 0
for i, w := range widths {
if i != col {
used += w + 2 // the table's padding
}
}
if col == len(widths)-1 {
used -= 2
}
limit := max(width-used-2, 24)
for _, row := range rows[1:] {
if r := []rune(row[col]); len(r) > limit {
row[col] = string(r[:limit-1]) + "…"
}
}
}
+5 -1
View File
@@ -302,7 +302,11 @@ func renderCmd() *cobra.Command {
return err
}
paths := render.Paths{ConfigDir: filepath.Join(out, "config"), ZoneDir: filepath.Join(out, "zones"), CacheDir: filepath.Join(out, "cache")}
o := render.Render(st, paths, renderExtras(a.cfg))
x := renderExtras(a.cfg)
if x.ViewKeySeed, err = engine.ViewKeySeed(context.Background(), a.store); err != nil {
return err
}
o := render.Render(st, paths, x)
for _, f := range o.Files {
if err := os.MkdirAll(filepath.Dir(f.Path), 0o750); err != nil {
return err
+41 -2
View File
@@ -123,6 +123,10 @@ func openApp(allowMissingConfig bool) (*app, error) {
NoReload: cfg.BIND.NoReload,
Extras: renderExtras(cfg),
}, log)
eng.Limits = engine.BlocklistLimits{
Refresh: time.Duration(cfg.Blocking.Refresh), RetryAfter: time.Hour, Timeout: 2 * time.Minute,
MaxBytes: int64(cfg.Blocking.MaxDownloadMB) << 20, MaxEntries: cfg.Blocking.MaxEntries,
}
return &app{cfg: cfg, log: log, store: st, eng: eng, owner: owner}, nil
}
@@ -144,7 +148,7 @@ func statsOptions(cfg *config.Config) stats.Options {
sc := cfg.Stats
o := stats.Options{QueryLog: sc.QueryLog, LogFile: sc.LogFile, StatisticsChannel: sc.StatisticsChannel, TopN: sc.TopN,
RetainMinute: time.Duration(sc.Retention.Minute), RetainHour: time.Duration(sc.Retention.Hour), RetainDay: time.Duration(sc.Retention.Day),
Anonymize: sc.AnonymizeClients}
Anonymize: sc.AnonymizeClients, MainConfig: cfg.BIND.MainConfig}
for _, p := range sc.ExcludeClients {
if pf, err := config.ParsePrefix(p); err == nil {
o.Exclude = append(o.Exclude, pf.Masked())
@@ -163,7 +167,7 @@ func zoneIndex(a *app) stats.ZoneIndex {
}
out := map[string][]string{}
for _, z := range st.Zones {
if z.Type != model.ZonePrimary && z.Type != model.ZoneSecondary && z.Type != model.ZoneMirror {
if z.Type != model.ZonePrimary && z.Type != model.ZoneSecondary && z.Type != model.ZoneMirror || z.Disabled {
continue
}
v := st.ViewName(z)
@@ -299,6 +303,11 @@ func serveCmd() *cobra.Command {
a.log.Warn("the configuration file holds the Porkbun API key and everyone can read it; make it readable only by root and jarld (chown root:jarl, chmod 640)", "file", configPath)
}
}
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{
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),
@@ -307,6 +316,8 @@ func serveCmd() *cobra.Command {
BackupDir: a.cfg.Backup.Dir, BackupWarnAfter: time.Duration(a.cfg.Backup.WarnAfter),
Porkbun: pb, PorkbunAllows: a.cfg.Porkbun.Allows,
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{
Enabled: a.cfg.ACME.HTTPReq.Enabled,
AllowPlainHTTP: !a.cfg.ACME.HTTPReq.RequireHTTPS,
@@ -323,6 +334,7 @@ func serveCmd() *cobra.Command {
}
}()
go auditRetention(ctx, a)
go blocklistLoop(ctx, a)
a.log.Info("jarld starting", "version", version.String(), "database", a.cfg.Database, "bind_config", a.cfg.BIND.ConfigDir)
err = srv.Serve(ctx, func() { sdNotify("READY=1\nSTATUS=serving") })
sdNotify("STOPPING=1")
@@ -332,6 +344,33 @@ func serveCmd() *cobra.Command {
}
}
// blocklistLoop keeps blocklists up to date (docs/blocking.md): every few
// minutes it downloads the lists that are due, and at once when a list
// was added or changed. It also ends a pause of blocking on time.
func blocklistLoop(ctx context.Context, a *app) {
select {
case <-ctx.Done():
return
case <-time.After(10 * time.Second): // let jarld finish starting
}
for {
a.eng.RefreshDue(ctx)
wait := 5 * time.Minute
if until := a.eng.ResumeBlocking(ctx); !until.IsZero() {
wait = min(wait, max(time.Until(until), time.Second))
}
t := time.NewTimer(wait)
select {
case <-ctx.Done():
t.Stop()
return
case <-t.C:
case <-a.eng.BlocklistKicks():
t.Stop()
}
}
}
func auditRetention(ctx context.Context, a *app) {
if a.cfg.Audit.RetentionDays <= 0 {
return
+7 -3
View File
@@ -40,8 +40,10 @@ ProtectHome=yes
PrivateTmp=yes
PrivateDevices=yes
NoNewPrivileges=yes
CapabilityBoundingSet=
AmbientCapabilities=
# The only privilege jarld gets: listening on ports below 1024 (443 for the
# web UI). Nothing else, and NoNewPrivileges still applies.
CapabilityBoundingSet=CAP_NET_BIND_SERVICE
AmbientCapabilities=CAP_NET_BIND_SERVICE
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectKernelLogs=yes
@@ -53,7 +55,9 @@ RestrictRealtime=yes
RestrictSUIDSGID=yes
LockPersonality=yes
MemoryDenyWriteExecute=yes
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
# AF_NETLINK lets jarld list this host's addresses, so its name server
# checks know which addresses are its own.
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 AF_NETLINK
SystemCallArchitectures=native
# @system-service includes @chown, which jarld needs to give files to group bind.
SystemCallFilter=@system-service
+77 -11
View File
@@ -10,6 +10,8 @@ what is documented here.
(`www.adbraun.lol.`), and display helpers drop it (`display_name`,
`relative_name`).
* `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
@@ -110,7 +112,8 @@ on the resources below, except users and tokens) works the same way:
Reads accept `?changeset=ID` too. They then show the configuration as it
will be after the changeset, with `"pending": "add" | "change" | "delete"`
on affected zones and records.
on affected zones and records (`"disable"` or `"enable"` for a record
that is only being disabled or enabled).
The response of an applied change:
@@ -146,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
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
`{zone}` is a zone ID or name (`root` for the root zone). Add `?view=NAME`
@@ -159,7 +170,7 @@ may be the name.
| GET | `/zones` | `?view=`, `?type=`, `?q=` (name contains) |
| POST | `/zones` | ZoneInput (below) |
| GET | `/zones/{zone}` | |
| PATCH | `/zones/{zone}` | ZoneInput (only the fields to change) |
| PATCH | `/zones/{zone}` | ZoneInput (only the fields to change); `{"disabled": true}` disables the zone: named stops loading it, Jarl keeps it |
| DELETE | `/zones/{zone}` | deletes the zone with its records and options |
| GET | `/zones/{zone}/file` | the generated zone file (text/plain) |
| GET | `/zones/{zone}/serials` | asks the zone's other name servers for their serial (below) |
@@ -333,14 +344,18 @@ registrar_error`.
| Method | Path | Body / query |
|--------|------|--------------|
| GET | `/zones/{zone}/records` | `?name=`, `?type=`, `?q=` |
| GET | `/zones/{zone}/records` | `?name=`, `?type=`, `?q=`, `?disabled=1` (only disabled records) or `0` (only enabled ones) |
| POST | `/zones/{zone}/records` | a RecordInput, or an array of them (one changeset) |
| DELETE | `/zones/{zone}/records` | `?name=&type=[&data=][&missing_ok=1][&ptr=1]`: delete by match |
| POST | `/zones/{zone}/records/disable` | same query as delete by match: disable the matching records |
| POST | `/zones/{zone}/records/enable` | same query: enable them again |
| PUT | `/zones/{zone}/rrsets/{name}/{type}` | `{"data": ["192.0.2.1", "192.0.2.2"], "ttl": 60, "comment": "..."}`: make the RRset exactly this |
| DELETE | `/zones/{zone}/rrsets/{name}/{type}` | delete the whole RRset |
| GET | `/records/{id}` | |
| PATCH | `/records/{id}` | RecordInput fields to change (`If-Match` supported) |
| DELETE | `/records/{id}` | `?ptr=1` also deletes the matching PTR |
| POST | `/records/{id}/disable` | `?ptr=1` also disables the matching PTR |
| POST | `/records/{id}/enable` | `?ptr=1` also enables the matching PTR |
RecordInput:
@@ -351,12 +366,26 @@ RecordInput:
"data": "192.0.2.10", // zone file syntax; TXT/SPF take plain text
"ttl": 300, // null or absent: the zone's default TTL
"comment": "web server",
"ptr": true // A/AAAA: also create/move/delete the PTR in the reverse zone
"disabled": false, // true: keep the record in Jarl but out of the zone
"ptr": true // A/AAAA: also create/move/delete (or disable/enable) the PTR in the reverse zone
}
```
Responses add `zone`, `view`, `relative_name`, `effective_ttl`, and `text`
(the decoded value of TXT records).
(the decoded value of TXT records). Zones have `disabled_count` when
some of their records are disabled.
**Disabled records** stay in Jarl, with their ID, TTL and comment, but are
left out of the zone: the zone file lists them as comments (`; disabled:
www IN A 192.0.2.10`). Disabling and enabling are ordinary changes (staged,
reviewed, reverted like any other). Validation checks the zone as it is
served, so disabling the zone's last NS record is refused, and enabling a
record is checked as if it were added: a CNAME added at its name in the
meantime stops it (and gives a `disabled_conflict` warning until then).
`PUT /rrsets` makes the RRset exactly the given data: a disabled record
with one of the values is enabled, other disabled records are left alone.
Disabling a record that is already disabled is a 409; with `missing_ok=1`
(by match) it's "nothing to do".
### Views
@@ -367,6 +396,7 @@ Responses add `zone`, `view`, `relative_name`, `effective_ttl`, and `text`
| GET / PATCH / DELETE | `/views/{id}` | DELETE `?cascade=1` also deletes the view's zones |
| POST | `/views/order` | `{"ids": [3, 1, 2]}` |
| PUT | `/views/{id}/color` | `{"color": "green"}`; `""` for the default |
| GET | `/views/{id}/key` | `{name, algorithm, secret}`: the TSIG key that selects the view (docs/views.md); needs `tsig:write`, audited |
`adopt_zones` moves zones that are not in a view into the new view. named
requires every zone to be in a view as soon as one view exists.
@@ -385,13 +415,41 @@ send the request with that changeset (`?changeset=` or
| Method | Path | Body |
|--------|------|------|
| 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 | `/tsig-keys` | without secrets |
| POST | `/tsig-keys` | `{"name": "xfer", "algorithm": "hmac-sha256", "secret": "<base64>"}` (secret generated when absent; the response `object` shows it once) |
| GET | `/tsig-keys/{id}` | `?secret=1` includes the secret (needs `tsig:write`, audited) |
| PATCH / DELETE | `/tsig-keys/{id}` | PATCH `{"secret": "..."}` rotates |
### Blocking
Needs `blocking:read` (`blocking:write` to change). See
[blocking.md](blocking.md).
| Method | Path | Body / query |
|--------|------|--------------|
| GET | `/blocking/lists` | each with `views` (where it applies), `auto_views` (not chosen: every view that offers recursion), `state` (ok, waiting, error, disabled, idle), `status` (`entries`, `records`, `skipped`, `checked_at`, `updated_at`, `error`) and `memory_mb` (named's memory for it, estimated) |
| POST | `/blocking/lists` | `{"name": "hagezi-multi", "url": "https://...", "views": ["internal"], "comment": "", "step": "normal"}` (`views` empty or absent: automatic; `step` the blocking step it starts at); jarld downloads it right after |
| GET / PATCH / DELETE | `/blocking/lists/{id or name}` | PATCH any of `name`, `url`, `views`, `disabled`, `comment` |
| POST | `/blocking/lists/{id or name}/refresh` | download now and wait; `?force=1` even when the server says nothing changed. Returns the list; a failed update is in `state` and `status.error` |
| GET | `/blocking/rules` | each with `views` and `auto_views` |
| POST | `/blocking/rules` | `{"name": "doubleclick.net", "action": "block", "exact": false, "views": [], "comment": ""}` (`action` block or allow; `exact`: only the name, not the names below it) |
| PATCH / DELETE | `/blocking/rules/{id}` | |
| DELETE | `/blocking/rules` | `?name=DOMAIN[&view=V][&missing_ok=1]`: the rules for a domain |
| GET | `/blocking/check` | `?name=DOMAIN`: per view with blocking, `{view, result: blocked\|allowed\|none, entry, rule_id, list, step}` (what applies to normal devices when blocking isn't paused; `step` when only devices at that step or above are blocked) |
| GET / POST | `/blocking/steps` | the steps above normal, lowest first, with the `lists` and `groups` at each; POST `{"name": "strict", "comment": ""}` adds one on top |
| PATCH / DELETE | `/blocking/steps/{id or name}` | `{"name", "position", "comment"}`; delete is refused while lists or groups use it |
| POST | `/blocking/steps/order` | `{"steps": ["strict", "very-strict"]}`: every step above normal, lowest first |
| GET / POST | `/blocking/groups` | client groups; POST `{"name": "kids", "members": ["10.10.0.50", "tablets"], "step": "strict", "views": []}` |
| PATCH / DELETE | `/blocking/groups/{id or name}` | the same fields |
| GET | `/blocking/status` | `{paused_until, paused_indefinitely}` (null and false when not paused) |
| POST | `/blocking/pause` | `{"minutes": 15}` (1 to 1440): let every name through until then; jarld resumes by itself. `{"indefinitely": true}`: until resumed |
| POST | `/blocking/resume` | end a pause now |
Lists and rules are configuration like the rest: they can be staged, and
listing with `?changeset=` marks them `pending`.
### Options and settings
| Method | Path | Body / query |
@@ -402,7 +460,9 @@ send the request with that changeset (`?changeset=` or
| GET / PATCH | `/settings` | `{"nameservers": [...], "rname": "...", "default_ttl": 3600, "refresh": ..., "retry": ..., "expire": ..., "negative_ttl": ..., "serial_policy": "date"}` |
Option values are named.conf syntax without the final semicolon. They are
checked against the BIND 9.20 grammar for that scope.
checked against the BIND 9.20 grammar for that scope. `position` only
orders top-level statements; the options of a block are always listed and
written in Jarl's fixed order (docs/concepts.md).
### Catalogs and search
@@ -411,6 +471,7 @@ checked against the BIND 9.20 grammar for that scope.
| GET | `/catalog/options` | scopes and zone types; with `?scope=options\|view\|toplevel\|zone:primary\|...` every statement allowed there (name, grammar, kind, status, ARM description). `?all=1` includes statements Jarl manages itself |
| GET | `/catalog/rrtypes` | record types with field hints and examples |
| GET | `/catalog/grants` | resources, roles and the grant syntax |
| GET | `/catalog/ranges?r=A-B,C-D` | for each address range: `range` (canonical), `size` ("99 addresses"), and the `prefixes` it is written to named.conf as; or `error`. Text that isn't a range is left out |
| GET | `/search?q=text` | zones, records and ACLs matching the text |
### Changesets
@@ -436,12 +497,16 @@ An **Operation** is `{"op": "...", "id": 0, "base_version": 0, "data": {...}}`:
| `record.create` | | RecordInput + `"zone": "adbraun.lol"` (or `zone_id`) and `"view"` |
| `record.update` | record | RecordInput |
| `record.delete` | record, or 0 with `zone`, `name`, `type`, optional `data`, `missing_ok`, `ptr` | |
| `record.disable` / `record.enable` | record, or 0 with the same match fields as `record.delete` | stored as `record.update` operations |
| `rrset.replace` | | `{"zone", "view", "name", "type", "ttl", "data": [...]}` (empty data deletes the RRset) |
| `view.create` / `view.update` / `view.delete` / `view.reorder` | view (update/delete) | ViewInput / `{"ids": [...]}` |
| `acl.create` / `acl.update` / `acl.delete` | ACL | ACL fields |
| `tsig.create` / `tsig.update` / `tsig.delete` | key | key fields |
| `option.create` / `option.update` / `option.delete` | option | option fields |
| `settings.update` | | settings fields |
| `blocklist.create` / `blocklist.update` / `blocklist.delete` | blocklist (update/delete) | list fields; views as `views` (names) or `view_ids` |
| `blockrule.create` / `blockrule.update` / `blockrule.delete` | rule (update/delete) | rule fields, likewise |
| `blocking.pause` | | `{"until": "2026-10-10T15:04:05Z"}`, `{"indefinitely": true}`, or `{}` to resume |
Operations later in a batch can refer to objects created earlier in it by
name (zone and view names). One request can therefore create a view, a zone
@@ -451,7 +516,7 @@ in it and its records.
| Method | Path | Notes |
|--------|------|-------|
| GET | `/status` | named status, adoption, drift, open changesets, current problems, object counts; `expiring_tokens` (tokens that expire within 14 days or expired in the last 14: all for administrators, otherwise the caller's); `backup` (administrators: `last_at`, `last_file`, `last_size`, `last_error`, `last_error_at`, `dir`, `warn_after` in seconds, `stale`) |
| GET | `/status` | named status, adoption, drift, open changesets, current problems, object counts; `expiring_tokens` (tokens that expire within 14 days or expired in the last 14: all for administrators, otherwise the caller's); `backup` (administrators: `last_at`, `last_file`, `last_size`, `last_error`, `last_error_at`, `dir`, `warn_after` in seconds, `stale`); `blocklist_errors` (with `blocking:read`: lists whose last update failed, `{name, error, updated_at}`); `blocking_paused_until` or `blocking_paused_indefinitely` (with `blocking:read`, while paused). `problems` include named-checkconf's warnings about the running configuration (`named_warning`) |
| POST | `/system/check` | validation + named-checkconf of the current configuration (`options:read`) |
| POST | `/system/resync` | `{"dry_run": false, "force": false}`: rewrite every file from the database and reload (admin) |
| GET | `/audit` | `?user=&action=&outcome=&object_type=&changeset=&q=&since=&until=&limit=200&before=ID` |
@@ -464,11 +529,12 @@ Needs `stats:read`. When statistics are disabled, every endpoint but
| Method | Path | Query | Returns |
|--------|------|-------|---------|
| GET | `/stats/status` | | `{enabled, query_log, statistics_channel, log_file, last_poll, last_log_line, log_lines_read, first_data, retention, warnings}` |
| GET | `/stats/summary` | `since` (90m, 24h, 7d, 2w; default 24h), `top` (default 10) | totals, `avg_qps`, `peak`, `rcodes`, `qtypes`, `views`, `zones`, `answers`, `transport`, `cache_hit_ratio`, `nxdomain_ratio`, `tops` |
| GET | `/stats/summary` | `since` (90m, 24h, 7d, 2w; default 24h), `top` (default 10) | totals, `avg_qps`, `peak`, `rcodes`, `qtypes`, `views`, `zones`, `answers`, `transport`, `cache_hit_ratio`, `nxdomain_ratio` (without blocked answers), `blocked`, `blocked_ratio`, `blocklists` (blocked per list; `rules` for your rules), `tops` |
| GET | `/stats/timeline` | `since`, `step` (auto, 1m, 5m, 15m, 1h, 6h, 1d), `key` (repeatable or comma separated; `rcode:*` = every counter of a group; default `queries`) | `{step, times: [unix...], series: [{key, values}]}` |
| GET | `/stats/top` | `kind` (name, client, nxdomain, servfail, refused), `since`, `limit` (default 20) | `[{key, count}]` |
| GET | `/stats/top` | `kind` (name, client, nxdomain, servfail, refused, blocked, blocked_client), `since`, `limit` (default 20) | `[{key, count}]`; client, refused and blocked_client entries add `name`, the host name of Jarl's record for the address (the summary's client list too) |
| GET | `/stats/heatmap` | `since` (default 28d), `key` (default queries) | `{key, from, avg[7][24], samples[7][24]}`, Monday first, local time, `-1` = no data |
| GET | `/stats/breakdown` | `group` (qtype, rcode, view, zone, answer, transport, rtt, dnssec, cache, ratelimit), `since` | `[{key, count}]` |
| GET | `/stats/server` | | `{server: {rss, mem_in_use, cpu_percent, threads, pid, cache_rrsets, cache_mem, sockets_udp, sockets_tcp, recursion_highwater, tcp_highwater, version, boot_time, config_time, host: {mem_total, mem_available, load, cpus, uptime}}, named: {recursive_clients, tcp_clients, zones, cpus, worker_threads}, disks: [{path, holds, total, free}]}`; gauges over time: `/stats/timeline?key=g:named:rss` (averages, -1 without a sample) |
| GET | `/stats/breakdown` | `group` (qtype, rcode, view, zone, answer, transport, rtt, dnssec, cache, ratelimit, blocklist), `since` | `[{key, count}]` |
```sh
curl -sf -H "$AUTH" "$API/stats/top?kind=nxdomain&since=7d&limit=10" | jq -r '.[] | "\(.count)\t\(.key)"'
+331
View File
@@ -0,0 +1,331 @@
# Blocking
Jarl can make named answer "this name doesn't exist" (NXDOMAIN) for ads,
trackers and malware, like Pi-hole does, for every device that uses your
server to resolve names. It has two parts:
- **Blocklists**: lists of domains published by others, which jarld
downloads and keeps up to date.
- **Your rules**: domains you block or allow yourself. They take precedence
over every list.
It uses BIND's own mechanism for this, response policy zones (RPZ), so named
does the blocking itself; jarld only prepares the lists.
- [Quick start](#quick-start)
- [Where blocking applies](#where-blocking-applies)
- [Your rules](#your-rules)
- [Blocklists](#blocklists)
- [Checking a name](#checking-a-name)
- [Pausing](#pausing)
- [Statistics](#statistics)
- [Memory and performance](#memory-and-performance)
- [How it works](#how-it-works)
- [Troubleshooting](#troubleshooting)
## Quick start
In the web UI, open **Blocking**, pick one of the well-known lists, and
click **Add list**. Or with the CLI:
```sh
jarl blocklist add hagezi-multi https://raw.githubusercontent.com/hagezi/dns-blocklists/main/adblock/multi.txt
jarl blocklist list # "ok" once downloaded, usually within seconds
jarl block check ads.doubleclick.net # blocked? by what?
jarl block allow cdn.example.com # something you need was blocked
```
Like every change in Jarl, adding a list or rule can be staged and reviewed
first (the web UI does this by default: apply it under **Changes**).
## Where blocking applies
Blocking changes the answers named gives when it resolves names for a
client (recursion). It doesn't change what the internet sees of your own
zones: other resolvers ask your server without recursion.
**Views.** A list or rule applies to the views you choose, or by default
to every view that offers recursion. With the usual split (an `internal`
view that resolves for your network, an `external` view that only answers
for your zones) that is just `internal`. A view that doesn't offer
recursion gets nothing, and naming one gives a warning. Without views,
blocking applies to the whole server.
**What it can't do:**
- Devices that don't use your server for DNS aren't affected. That includes
browsers using their own DNS over HTTPS, and devices with a hard-coded
resolver (8.8.8.8). A firewall rule that sends port 53 to your server, and
your network's DNS settings, are the usual answers.
- A device that validates DNSSEC itself sees a blocked name as a
validation failure rather than "doesn't exist". It's still blocked.
- A list can contain names in your own zones. They are blocked like any
other; allow them with a rule if that's not what you want.
## Your rules
A rule **blocks** or **allows** a domain and every name below it, or with
`--exact` (web UI: "only this exact name") just the domain itself.
```sh
jarl block add doubleclick.net # doubleclick.net and everything below it
jarl block add telemetry.example.com --exact # just that name
jarl block allow cdn.example.com --view internal # an exception, in one view
jarl block list
jarl block delete doubleclick.net # or: --id ID
```
**Precedence:** your rules come before every blocklist, and among your
rules the most specific one wins. With these rules:
| Rule | |
|------|-|
| block `example.com` | and below |
| allow `cdn.example.com` | exact |
`example.com` and `www.example.com` are blocked, `cdn.example.com` is
allowed, and `img.cdn.example.com` is blocked again (the allow rule was for
the exact name). An allow rule beats a blocklist entry whatever the lists
say.
There is one rule per domain and view; a second one is refused. A rule
takes effect as soon as the change is applied.
## Blocklists
**Formats.** Lists are read line by line; one list may mix formats:
| Line | Blocks |
|------|--------|
| `\|\|ads.example^` (Adblock) | the name and every name below it |
| `*.ads.example` | the name and every name below it |
| `0.0.0.0 ads.example` (hosts file, any address) | just the name |
| `ads.example` (plain domain) | just the name |
Comments (`#`, `!`), headers and blank lines are ignored. Lines that DNS
can't act on are skipped and counted: Adblock exceptions (`@@`), cosmetic
and URL rules, addresses, invalid names, and single labels (a broken list
can't block `com`). Prefer lists in Adblock format: they say which entries
cover the names below them.
**Well-known lists** (sizes in October 2026; they change):
| List | URL | Domains | Memory per view |
|------|-----|--------:|----------------:|
| Hagezi Light | `https://raw.githubusercontent.com/hagezi/dns-blocklists/main/adblock/light.txt` | 55,000 | ~65 MB |
| Hagezi Multi Normal | `https://raw.githubusercontent.com/hagezi/dns-blocklists/main/adblock/multi.txt` | 159,000 | ~190 MB |
| Hagezi Pro | `https://raw.githubusercontent.com/hagezi/dns-blocklists/main/adblock/pro.txt` | 195,000 | ~235 MB |
| OISD Small | `https://small.oisd.nl/` | 58,000 | ~70 MB |
| OISD Big | `https://big.oisd.nl/` | 241,000 | ~290 MB |
| StevenBlack hosts | `https://raw.githubusercontent.com/StevenBlack/hosts/master/hosts` | 73,000 | ~45 MB |
One good list is usually enough; lists overlap a lot. Hagezi Multi Normal
is a balanced start; Light and OISD Small block less but almost never
something you need.
**Updates.** jarld checks each list every `blocking.refresh` (a day by
default) and downloads it only when the server says it changed. A new
version is checked with `named-checkzone` and replaces the old one only
when its content differs; named then reloads it. If a download or check
fails, named keeps using the previous version, jarld tries again within
the hour, and the dashboard shows the error. To update now:
```sh
jarl blocklist refresh # every list; or: refresh hagezi-multi [--force]
```
**Managing lists:**
```sh
jarl blocklist update hagezi-multi --view internal # --view auto: every view with recursion
jarl blocklist update hagezi-multi --url URL --name NEW-NAME
jarl blocklist disable hagezi-multi # stop using it, keep it (enable: back without a download)
jarl blocklist delete hagezi-multi
```
A list's state is **ok** (in use), **waiting** (not downloaded yet),
**error** (the last update failed; the previous version stays in use),
**disabled**, or **idle** (it applies to no view, so it does nothing).
## Different blocking for different devices
Client groups give some devices less or more blocking than the rest, like
Pi-hole's groups: no blocking for a work laptop, an extra list for the
kids' tablets.
**Steps** decide which lists a device gets. Each step includes the lists
of the steps below it, plus its own:
| Step | Gets |
|------|------|
| `off` (built in) | only your rules |
| `normal` (built in) | your rules and the normal lists; every device not in a group |
| `strict` (one you add) | normal, plus the lists at step `strict` |
| `very-strict` (one you add) | strict, plus the lists at step `very-strict` |
Every list starts at a step (normal unless you choose one), and every
group is at a step. Your rules apply at every step, `off` included.
```sh
jarl block step add strict
jarl blocklist update social-media --step strict
jarl block group add work 10.10.0.20 --step off
jarl block group add kids 10.10.0.50 10.10.0.51 2001:db8:1::/64 --step strict
jarl block group list
```
In the web UI, the Blocking page's **Devices** panel holds the steps and
the groups, and each list has a step.
- **Members** are addresses, networks (`10.10.0.0/24`), ranges
(`10.10.0.20-10.10.0.49`), or ACLs made only of those (`key`, `localnets` and `!` can't be used: blocking matches
clients by address). A device is in one group: two groups with the same
address are refused, but a more specific address in another group wins
(`10.10.0.0/24` in one group, `10.10.0.20` in another).
- **Steps only add.** A group gets everything up to its step, so "kids get
list A, guests get list B, and neither gets the other's" can't be done
with steps. That needs a view per group, each with its own lists (and
their memory).
- Lists aren't copied per step: each is loaded once, and the steps cost a
small zone each. Lists at a step no group reaches block nothing (Jarl
warns, and shows them as "no effect").
- named must see the device's own address. When a router hands out itself
as DNS server and forwards to named, every query comes from the router,
and groups can't tell devices apart. Devices also need fixed addresses
(DHCP reservations); IPv6 privacy addresses change, so add a device's
network, or rely on its IPv4 address.
- `jarl block check NAME` (and the Blocking page) answers for normal
devices, and says when a list of a higher step blocks the name.
## Checking a name
`jarl block check NAME`, or the box at the top of the Blocking page, says
for each view whether a name is blocked or allowed, and by which rule or
list entry:
```
$ jarl block check img.ads.example.com
view internal: blocked by blocklist hagezi-multi (entry *.ads.example.com)
view guest: allowed by your rule 12 (entry *.ads.example.com)
```
Views without blocking aren't listed.
In the web UI a blocked name gets an **Allow** button, and the statistics'
most blocked names link to this check.
## Pausing
To see whether blocking is what breaks a site, pause it: every name is
answered normally until the time is up (at most a day), then blocking
resumes by itself, also if jarld was restarted in between. A pause can
also last until someone resumes blocking (like Pi-hole's "indefinitely").
```sh
jarl block pause # 10 minutes; or: jarl block pause 1h
jarl block pause indefinitely
jarl block resume # end it now
```
In the web UI, **Pause blocking** is at the top of the Blocking page (with
**Until I resume** among the lengths), and the dashboard says while
blocking is paused. The lists stay loaded (the
rules zone just lets everything through), so pausing and resuming take
effect within a second. Pausing and resuming are recorded in the history
like other changes.
## Statistics
With [query statistics](stats.md) on (including `query_log`), Jarl counts
what blocking does: blocked queries over time (next to all queries) and
as a share of them, which rules and lists blocked them, the most blocked
names, and the clients with the most blocked queries (`jarl stats summary`,
`jarl stats top blocked`, `jarl stats top blocked-clients`, `jarl stats
breakdown blocklist`). Blocked answers are left out of the NXDOMAIN figures, which
are about names that really don't exist.
The Blocking page shows the last 24 hours too: a **Blocked (24h)** column
for each list, and one count for your rules together (named logs which
policy zone matched, not which rule in it).
Without statistics named doesn't log what it blocks at all.
## Memory and performance
**Memory** is the cost to watch. named keeps each list in memory, once per
view it applies to: about 0.6 KB per policy record (an Adblock entry is
two records: the name, and the names below it). The Blocking page and
`jarl blocklist list` show the estimate per list. Hagezi Pro in one view
took named from 25 MB to 260 MB in a test with BIND 9.20; while a list
reloads, the old and new versions briefly coexist.
**Answers aren't slowed**: named looks blocked names up in memory, and
answers them without asking the internet.
**Updating a list** (Hagezi Pro: download, parse, check) takes jarld about
2 seconds. While named loads the new version, the queries that the loading
thread would have answered wait, up to half a second; this only happens
when the list changed, at most once per `blocking.refresh`.
**Other changes are unaffected**: the lists' contents never go through
Jarl's apply, so adding a record takes as long with a list as without.
Downloads are limited by `blocking.max_download_mb` and
`blocking.max_entries` ([configuration.md](configuration.md#blocking)).
## How it works
For each view (or the server, without views) Jarl writes a
`response-policy` statement and the policy zones it lists, in this order:
```
response-policy {
zone "rules.jarl.invalid" min-update-interval 0; # your rules
zone "hagezi-multi.blocklist.jarl.invalid" min-update-interval 0;
} qname-wait-recurse no break-dnssec yes;
```
- Policy zones are named under `.invalid`, so they can't clash with real
zones (zone names under `jarl.invalid` are refused). They allow no
queries or transfers.
- The rules zone of each view is a file next to your zone files
(`/var/lib/bind/jarl/VIEW/rules.jarl.invalid.rpz`) and shows up in the
changeset diff like any other.
- A list's file is `/var/cache/bind/jarl/blocklists/list-ID.rpz`, shared by
the views that use it. Until the first download it is an empty policy
zone, so named always starts. Files of deleted lists are removed.
- `qname-wait-recurse no` blocks without first resolving the real answer;
`break-dnssec yes` also blocks for clients that ask for DNSSEC data;
`min-update-interval 0` applies a new version at once.
- named looks names up in a policy zone like in any zone, so a wildcard
doesn't apply below a name the zone also contains. Jarl writes the zones
so that the most specific rule still wins.
- A `response-policy` option of your own can't be combined with Jarl's
blocking in the same view: Jarl refuses the change and says so.
- Rewrites are logged to Jarl's response log when statistics read it, and
not at all otherwise.
- With client groups, a **gate** zone stands in front of the lists of each
step: `off.gate.jarl.invalid` before the normal lists,
`STEP.gate.jarl.invalid` before a step's. named checks the zones in
order and the first match decides; a gate matches the devices below its
step by address (`rpz-client-ip` triggers) with `rpz-passthru`, so they
never reach the lists behind it. A gate is never logged, and its file is
next to the rules zone (`/var/lib/bind/jarl/VIEW/strict.gate.jarl.invalid.rpz`).
For "everyone except these devices", a gate lists the address ranges
around them, at most a few hundred entries. named matches IPv4 clients as
IPv4-mapped IPv6 addresses, so the IPv6 part leaves `::ffff:0:0/96` out.
Lists and rules need the `blocking` permission ([permissions.md](permissions.md)).
## Troubleshooting
- **"no domains found in N lines"**: the URL didn't return a list (often an
HTML error page). Check it in a browser.
- **A list has no effect / is "idle"**: no view it applies to offers
recursion. Pick the view your devices use (`--view internal`).
- **Something is still answered**: `jarl block check NAME` shows whether
Jarl blocks it. If it does, the device probably doesn't use your server
(DNS over HTTPS, another resolver), or has the answer cached for a
while.
- **Something you need is blocked**: `jarl block check NAME` shows which
list; `jarl block allow NAME` overrides it.
+128 -12
View File
@@ -41,7 +41,7 @@ jarl whoami
| API token (`@FILE` reads it from a file) | `--token` | `JARL_TOKEN` | `token` |
| Skip TLS verification | `--insecure` | | `insecure` |
| 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
file is `jarl/config.yaml` in the user's configuration directory, or
@@ -139,10 +139,11 @@ to stderr with `-o json` or `-o yaml`, so stdout holds just the documents.
| Flag | Meaning |
|------|---------|
| `-o table\|json\|yaml` | Output format. JSON and YAML have the API's full objects, for scripts. |
| `-o id`, `-o field=NAME[,NAME...]` | Just the IDs, or just those fields, one line per object (fields separated by tabs). See [Scripting tips](#scripting-tips). |
| `-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. |
| `--force` | Overwrite BIND files that were changed outside Jarl (drift). |
| `-q, --quiet` | Print only errors, or just the ID where a command creates something. |
| `-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
@@ -181,12 +182,21 @@ jarl zone create consul --type forward --forwarder "127.0.0.1 port 8600"
jarl zone update adbraun.lol --ttl 3600 --contact hostmaster@adbraun.lol --refresh 7200
jarl zone update adbraun.lol --move-to-view internal
jarl zone delete lab.adbraun.lol [-y]
jarl zone disable lab.adbraun.lol [--view V] # named stops serving it; kept (and editable) in Jarl
jarl zone enable lab.adbraun.lol [--view V]
jarl zone file adbraun.lol # the generated zone file
jarl zone import adbraun.lol db.adbraun.lol [--replace] [--use-soa]
jarl zone serials adbraun.lol # are the secondaries (or primaries) on the current serial?
jarl zone delegation adbraun.lol [--resolver 9.9.9.9] # does the parent zone delegate it correctly?
```
**Disabling a zone** leaves it out of named's configuration but keeps it in
Jarl: records, options and history, which can still be edited. Its names
are then answered by recursion where the view offers it (the internet's
version of the zone, if any), and REFUSED elsewhere; `disable` says which.
Enabling it serves it as it is then. A signed zone can't be disabled (turn
signing off first), nor a zone another view shares with `in-view`.
Reverse names follow the dots (the nibbles in IPv6), so a reverse zone
covers a /8, /16 or /24 (a multiple of /4 for IPv6). A network in between is
covered by several zones, created together in one changeset: a /22 by four
@@ -245,7 +255,7 @@ Jarl.)
## Records
```sh
jarl record list adbraun.lol [--name www] [-t A] [--search nas]
jarl record list adbraun.lol [--name www] [-t A] [--search nas] [--disabled]
jarl record add adbraun.lol nas A 192.168.1.20 --ptr --comment "synology"
jarl record add adbraun.lol @ MX 10 mail
jarl record add adbraun.lol @ TXT "v=spf1 mx -all" # plain text, quoting is automatic
@@ -257,11 +267,26 @@ jarl record update 1234 --ttl default
jarl record delete adbraun.lol www A # every A record of www
jarl record delete adbraun.lol www A 192.168.1.10 # just this one
jarl record delete --id 1234 [--ptr]
jarl record disable adbraun.lol www A 192.168.1.10 --ptr # out of the zone, kept in Jarl
jarl record enable adbraun.lol www A 192.168.1.10 --ptr # back again
jarl record disable --id 1234 # by ID; same for enable
# reverse zones: name records by address; the host name needs no final dot
jarl record add 0.10.10.in-addr.arpa 10.10.0.20 PTR nas.adbraun.lol
# a zone in several views (split horizon): the same change in each
jarl record add adbraun.lol mx2 A 203.0.113.5 --view internal,external
jarl record delete adbraun.lol old A --view all -y
```
**Several views.** When a zone exists in more than one view, the record
commands that change records (`add`, `set`, `delete`, `disable`,
`enable`) take `--view a,b` or `--view all` (every view the zone is in).
The changes are applied together as one changeset: if one view refuses
(the zone isn't in it, a CNAME conflict, ...), no view is changed. With
`--changeset` they are staged there instead. Keep split-horizon data that
differs per view (an internal address, say) to one view at a time.
Names are relative to the zone (`@` is the apex) unless they end with a
dot. Data uses zone file syntax, except that TXT/SPF take plain text: Jarl
adds the quotes and splits long values into 255-byte strings. In reverse zones the name can be the address
@@ -273,6 +298,16 @@ by Jarl). Every record type BIND supports can be used. Jarl knows the
syntax of the common ones and checks them strictly, and the rest are
validated by parsing.
**Disabling** a record takes it out of the zone without deleting it: it
keeps its ID, TTL and comment, the zone file lists it as a comment
(`; disabled: www IN A ...`), and `enable` puts it back exactly as it
was. `record list` marks disabled records `[disabled]`, and `zone show`
counts them. The zone is validated as it is served: you can't disable its
last NS record, and enabling a record is checked like adding it (a CNAME
added at the same name in the meantime stops it). `record set` enables a
disabled record whose value it is given, and leaves other disabled
records alone.
## Batches: `jarl apply -f`
Several changes that must succeed or fail together:
@@ -287,6 +322,7 @@ changes:
- set: {zone: adbraun.lol, name: api, type: A, data: [192.168.1.41, 192.168.1.42], ttl: 60}
- update: {id: 1235, comment: "old web server, remove in November"}
- delete: {id: 1240}
- disable: {zone: adbraun.lol, name: old, type: A} # or {id: ...}; enable: likewise
- op: acl.update # any API operation (see api.md)
id: 3
data: {elements: [192.168.1.0/24, 10.10.0.0/16]}
@@ -309,9 +345,9 @@ reads standard input.
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 nas A 10.20.0.10
jarl changeset diff $JARL_CHANGESET # validation + named-checkconf + diff
jarl changeset remove $JARL_CHANGESET 2 # drop operation #2
jarl changeset apply $JARL_CHANGESET
jarl changeset diff # validation + named-checkconf + diff
jarl changeset remove 2 # drop operation #2
jarl changeset apply
unset JARL_CHANGESET
jarl changeset list [--status open|applied|discarded] [--mine]
@@ -321,6 +357,29 @@ jarl history [--limit 50]
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.
## Views, ACLs, TSIG keys, options, settings
@@ -332,8 +391,10 @@ jarl view order internal external
jarl view update external --match-clients "!homelab,any"
jarl view delete old [--cascade]
jarl view color internal green # web UI colour; "default" resets it
jarl view key external [--named-conf] # the key that selects the view (dig -k), docs/views.md
jarl acl create homelab 192.168.1.0/24 10.10.0.0/16 localhost
jarl acl create 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 --rename lan # references are updated
jarl acl delete lan # refused while in use
@@ -347,12 +408,22 @@ jarl option list [--view V | --zone Z | --toplevel | --all]
jarl option set recursion yes
jarl option set allow-recursion '{ homelab; localhost; }'
jarl option set forwarders '{ 1.1.1.1; 9.9.9.9; }'
jarl option set allow-transfer '{ key xfer; }' --zone adbraun.lol
jarl option set forwarders 1.1.1.1 9.9.9.9 # the same: braces are added to a list
jarl option set allow-transfer 'key xfer; 10.0.10.53' --zone adbraun.lol
jarl option set allow-query '10.0.20.1-10.0.20.99; localhost' --view internal
jarl option set response-policy '{ zone "rpz.local"; }' --view internal
jarl option set logging '{ channel q { file "/var/log/named/query.log" versions 3 size 20m; }; category queries { q; }; }' --toplevel
jarl option unset forwarders
jarl option describe # every option allowed at this scope
jarl option describe allow-query-cache # grammar and ARM description
```
Options that end in a list (`allow-*`, `forwarders`, `listen-on`, ...) may
be given without the braces: elements are separated by `;`, `,` or spaces,
and `key NAME` and `port N` stay with their element. Options are always
listed in one fixed order (see [concepts.md](concepts.md)).
```sh
jarl settings show
jarl settings set --ns ns1.adbraun.lol.,ns2.adbraun.lol. --contact hostmaster@adbraun.lol --ttl 3600
@@ -404,15 +475,43 @@ working.
```sh
jarl stats status
jarl stats summary --since 7d
jarl stats top names | clients | nxdomain | servfail | refused [--since 24h] [--limit 20]
jarl stats top names | clients | nxdomain | servfail | refused | blocked | blocked-clients [--since 24h] [--limit 20]
jarl stats timeline --since 6h [--key rcode:NXDOMAIN] [--table]
jarl stats heatmap --since 28d
jarl stats breakdown qtype | rcode | view | zone | answer | rtt
jarl stats breakdown qtype | rcode | view | zone | answer | rtt | blocklist
jarl stats server # named's memory, CPU, cache, clients; host; disk space
```
Statistics must be enabled in `jarl.yaml`, and `-o json` exports the data.
See [stats.md](stats.md).
## Blocking
```sh
jarl blocklist list
jarl blocklist add NAME URL [--view V]... # default: every view that offers recursion
jarl blocklist update NAME [--url U] [--name N] [--view V | --view auto] [--comment C]
jarl blocklist disable | enable | delete NAME
jarl blocklist refresh [NAME...] [--force] # download now (all lists without NAME)
jarl block add DOMAIN [--exact] [--view V]... # block it (and the names below it)
jarl block allow DOMAIN [--exact] [--view V]... # an exception that beats every list
jarl block list
jarl block delete DOMAIN [--view V] | --id ID
jarl block check DOMAIN # blocked? by which rule or list?
jarl block pause [DURATION] # let everything through for a while (default 10m, at most 24h)
jarl block pause indefinitely # ... until 'jarl block resume'
jarl block step list | add NAME | rename STEP NEW | order STEP... | delete STEP
jarl block group list
jarl block group add NAME MEMBER... [--step S] [--view V]... # addresses, networks or ACLs
jarl block group update GROUP [--add M] [--remove M] [--step S] [--name N]
jarl block group delete GROUP
jarl blocklist update LIST --step STEP # devices at that step or above get it
jarl block resume
```
See [blocking.md](blocking.md).
## ACME DNS-01
```sh
@@ -426,8 +525,25 @@ See [acme.md](acme.md) for certbot, acme.sh and a dedicated ACME zone.
## Scripting tips
* `-o json` gives full API objects. Pipe them to `jq`.
* `-q` with `create` commands prints just the ID or token.
* `-o id` prints just the IDs of what a `show` or `list` command finds, one
per line, and `-o field=...` picks fields (their names are those of
`-o json`; a dot reaches inside, as in `soa.serial`; a field an object
doesn't have prints as empty). For changes, they apply to the changed
object:
```sh
ZONE=$(jarl zone show adbraun.lol --view external -o id)
jarl record list adbraun.lol --name www -t A -o id | xargs -n1 jarl record disable --id
jarl zone show adbraun.lol -o field=view,soa.serial # external 2026100901
jarl zone list -o field=id,name,view
ID=$(jarl record add adbraun.lol www A 192.0.2.10 -o id) # the new record's ID
```
* `-q` with `create` commands prints just the ID or token, and with `show`
and `list` just the IDs. Commands without IDs (status, checks) print
nothing but errors.
* `jarl record list` on a terminal shortens data too long for a line
(`…`); `--wide` shows it in full, and so does output to a pipe or file.
* Check `$?` against the exit code table.
* `jarl record delete ... --missing-ok` succeeds when nothing matches, which
keeps cleanup scripts idempotent. `jarl record set` is idempotent by nature.
keeps cleanup scripts idempotent. So does `record disable|enable
--missing-ok`, also when the records are already disabled (enabled). `jarl record set` is idempotent by nature.
+35 -1
View File
@@ -68,7 +68,9 @@ both, using a single mechanism:
* `POST /api/v1/apply` (CLI: `jarl apply -f changes.yaml`) applies a list of
operations as one changeset in a single request.
* `?dry_run=1` (CLI: `--dry-run`) runs the whole pipeline except installing
files and reloading named, and shows the problems and the diff.
files and reloading named, and shows the problems and the diff. Each
part of a named.conf diff says which block it is in, after the `@@`
line numbers: `@@ -50,6 +49,8 @@ view "internal" {`.
**Why bundling matters.** Some changes only make sense together. Turning
`www` from an A record into a CNAME means deleting the A record and adding
@@ -159,6 +161,38 @@ RFC 1982 arithmetic.
Jarl checks them before named does. Statements that Jarl manages itself
(`zone`, `view`, `acl`, `key`, a zone's `type`/`file`, a view's
`match-clients`, ...) can't be set as raw options.
Options of a block are kept in one fixed order, the same in named.conf,
the web UI and the CLI: grouped (files, listening, access control,
recursion and forwarding, DNSSEC, transfers and notify, then the rest),
alphabetical within a group. So the same option is in the same place in
every view and zone. Top-level statements stay in the order they were
added.
* **Address ranges.** Wherever named takes an address match list (ACLs,
views' `match-clients` and `match-destinations`, options such as
`allow-query` or `allow-recursion`) and in blocking's client groups, Jarl
also takes a range of addresses: `10.0.10.101-10.0.10.199`,
`2001:db8::100-2001:db8::1ff`, `!10.0.10.150-10.0.10.160`. BIND only
matches addresses and prefixes, so Jarl keeps the range as you wrote it
and writes the fewest prefixes that cover exactly that range to
named.conf, with the range as a comment above them:
```
acl "dhcp" {
// 10.0.10.101-10.0.10.199
10.0.10.101;
10.0.10.102/31;
10.0.10.104/29;
10.0.10.112/28;
10.0.10.128/26;
10.0.10.192/29;
};
```
Nothing outside the range is matched (writing such lists by hand, it is
easy to round to `10.0.10.96/27` and let in more than you meant). Both
ends are full addresses of the same family, and spaces around the dash
are fine. A range takes at most 62 prefixes for IPv4 and 254 for IPv6.
The web UI shows what a range becomes while you type it. A range in the
IPv4-mapped space (`::ffff:0:0/96`) gets a warning: named can see IPv4
clients as such addresses, so it may match IPv4 clients too.
* **Settings** are Jarl's defaults for new zones: name servers, SOA contact
and timers, default TTL, serial policy.
+50 -1
View File
@@ -18,7 +18,7 @@ The CLI has its own small configuration file, described in [cli.md](cli.md).
| Key | Default | Meaning |
|-----|---------|---------|
| `listen` | `["127.0.0.1:8053"]` | Addresses to listen on (`host:port`, several allowed). Keep these on localhost and management networks. |
| `listen` | `["127.0.0.1:8053"]` | Addresses to listen on (`host:port`, several allowed). Keep these on localhost and management networks. Ports below 1024 (`"192.168.10.2:443"`) work because `jarl.service` gives jarld the `CAP_NET_BIND_SERVICE` capability, and only that ([HTTPS on port 443](#example-https-on-port-443)). |
| `socket` | `/run/jarl/jarl.sock` | A local unix socket for the API. Requests on it from root or jarld's own user need no token and have administrator rights; anyone else must send a token ([permissions.md](permissions.md#the-local-socket)). The `jarl` CLI uses it when no server is configured. At most 107 bytes long; `""` turns it off. If it can't be created, jarld logs a warning and serves the network addresses only. |
| `tls.cert_file`, `tls.key_file` | (empty: plain HTTP) | PEM certificate chain and key. Both or neither. The files are rechecked every 10 minutes and on SIGHUP (`systemctl reload jarl`), so renewed certificates are used without a restart. |
| `trusted_proxies` | `[]` | Addresses or prefixes of reverse proxies whose `X-Forwarded-For` and `X-Forwarded-Proto` (or `Forwarded: proto=`) headers are believed. Needed for correct client IPs in the audit log and for rate limiting behind a proxy, and for knowing that a proxied request came over HTTPS (the session cookie's Secure flag, `acme.httpreq`). From other addresses these headers are ignored. |
@@ -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
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)
An endpoint for lego's `httpreq` DNS provider, so lego and Traefik can
@@ -134,6 +146,17 @@ jarl.yaml; chmod 640 jarl.yaml`, as the Debian package installs it); jarld
warns at start when everyone can read it. At porkbun.com, switch on "API
Access" for the domain, and limit the key to the domain if you can.
### Blocking
How jarld downloads blocklists ([blocking.md](blocking.md)). The lists and
rules themselves are configuration in the database.
| Key | Default | Meaning |
|-----|---------|---------|
| `blocking.refresh` | `24h` | How often each blocklist is checked for changes (at least `1h`); it is downloaded only when it changed ([blocking.md](blocking.md)). |
| `blocking.max_download_mb` | `64` | Larger downloads are refused (the previous version stays in use). |
| `blocking.max_entries` | `1000000` | Lists with more domains are refused. |
## Example: management network with TLS
```yaml
@@ -149,6 +172,32 @@ audit:
retention_days: 730
```
## Example: HTTPS on port 443
```yaml
listen:
- "127.0.0.1:8053" # the CLI on the server keeps using this (or the socket)
- "192.168.10.2:443"
tls:
cert_file: /etc/jarl/tls/fullchain.pem
key_file: /etc/jarl/tls/privkey.pem
```
jarld runs as the unprivileged `jarl` user, and binding a port below 1024
needs the `CAP_NET_BIND_SERVICE` capability, which `jarl.service` grants
(`AmbientCapabilities=CAP_NET_BIND_SERVICE`; nothing else). The unit in
Jarl 0.2.0 and earlier doesn't, and jarld then fails with "bind:
permission denied". Upgrading the package installs the new unit; until
then, a drop-in does the same:
```sh
sudo systemctl edit jarl
# [Service]
# CapabilityBoundingSet=CAP_NET_BIND_SERVICE
# AmbientCapabilities=CAP_NET_BIND_SERVICE
sudo systemctl restart jarl
```
## Example: behind nginx
```yaml
+3
View File
@@ -10,6 +10,7 @@ internal/
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
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
validate/ whole-configuration validation and security lints
render/ State → named.conf, keys.conf and zone files, with a source map for error messages
@@ -28,6 +29,8 @@ internal/
nscheck/ DNS queries to other name servers: zone serials, the parent's delegation and DS records
dnssec/ reads named's DNSSEC key and state files; DS records; the signing phases (docs/dnssec.md)
porkbun/ Porkbun API client (name servers, glue, DS records) and an in-memory fake for tests
blocking/ blocklist parser, response policy zone writers, and matching a name the way named does
(the downloads are in engine/blocklists.go)
config/ jarld's YAML configuration
deploy/ systemd units (jarld, the daily backup service and timer), sysusers.d and tmpfiles.d files
deploy/debian/ the Debian package's control template and postinst/prerm/postrm
+12 -2
View File
@@ -122,8 +122,18 @@ can make validating resolvers reject cached answers. Then:
[DNSViz](https://dnsviz.net/) gives an independent picture.
If the parent zone is itself a primary zone in Jarl (`home.adbraun.lol`
inside `adbraun.lol`), the DNSSEC tab says so: add the DS as a record in
that zone.
inside `adbraun.lol`), the DS goes in as a record in that zone, and Jarl
gives you both ways to add it: an **Add to adbraun.lol** button on the
DNSSEC tab, and the command, which `jarl zone dnssec status` prints too:
```sh
jarl record add adbraun.lol home DS 51126 13 2 FFBECE3D...
```
Once the parent zone has it, the tab and `status` say so. When turning
signing off, the steps give the matching `jarl record delete adbraun.lol
home DS`. The zone must be delegated first (NS records for `home` in
`adbraun.lol`), or validation refuses the DS.
## Putting your own name servers on the internet
+3 -3
View File
@@ -88,9 +88,9 @@ password <token>
Download the two `.deb` files from the release page, or build them:
```sh
make deb # dist/jarl_0.1.0_amd64.deb and dist/jarl-cli_0.1.0_amd64.deb
make deb # dist/jarl_0.2.0_amd64.deb and dist/jarl-cli_0.2.0_amd64.deb
make deb DEB_ARCH=arm64 # for another architecture (also armhf, i386, ...)
sudo apt install ./dist/jarl_0.1.0_amd64.deb ./dist/jarl-cli_0.1.0_amd64.deb
sudo apt install ./dist/jarl_0.2.0_amd64.deb ./dist/jarl-cli_0.2.0_amd64.deb
```
Give apt both files: `jarl` needs `jarl-cli` of the same version.
@@ -139,7 +139,7 @@ To manage Jarl servers from your laptop or another server, install only
users, services or directories):
```sh
sudo apt install jarl-cli # with the registry above; or: sudo apt install ./jarl-cli_0.1.0_amd64.deb
sudo apt install jarl-cli # with the registry above; or: sudo apt install ./jarl-cli_0.2.0_amd64.deb
jarl config set server https://ns1.adbraun.lol:8053
jarl config set token
```
+231
View File
@@ -0,0 +1,231 @@
# Prometheus metrics
jarld can serve metrics for [Prometheus](https://prometheus.io/) at
`/metrics`:
- **health** you can alert on: named up, validation problems, files changed
outside Jarl, blocklists that fail to download, backup age, DNSSEC zones
waiting for you at the parent, and API tokens about to expire,
- **queries**, once [query statistics](stats.md) are on: named's counters
(result codes, record types, cache, upstream times, DNSSEC validation, rate
limiting) and Jarl's own (responses per view and zone, blocked queries per
blocklist), plus named's memory, CPU, cache and sockets.
Metrics are **off by default**. Everything is set in `/etc/jarl/jarl.yaml`;
nothing is stored in the database.
## Turning it on
Prometheus on the same server needs only:
```yaml
metrics:
enabled: true
```
```sh
sudo systemctl restart jarl
curl -s http://127.0.0.1:8053/metrics | head
```
`/metrics` is served on the addresses in `listen` (and their TLS, if set)
and on the local socket. Query counters also need `stats.enabled: true`
([stats.md](stats.md)); without it you get only the health metrics.
### Prometheus on another machine
Give `/metrics` an address of its own and say who may scrape it:
```yaml
metrics:
enabled: true
listen: ["0.0.0.0:9450"] # serves only /metrics, over plain HTTP
allow: ["192.168.1.20"] # the Prometheus server
```
```yaml
# prometheus.yml
scrape_configs:
- job_name: jarl
static_configs:
- targets: ["dns.lan:9450"]
```
Or use a token instead of (or as well as) the address list. Any address
that sends it may scrape:
```yaml
metrics:
enabled: true
listen: ["0.0.0.0:9450"]
token: "…" # e.g. from: openssl rand -hex 24
```
```yaml
# prometheus.yml
scrape_configs:
- job_name: jarl
authorization:
credentials: "…" # or credentials_file: /etc/prometheus/jarl.token
static_configs:
- targets: ["dns.lan:9450"]
```
With a token in jarl.yaml, keep the file readable by root and jarld only
(`chown root:jarl /etc/jarl/jarl.yaml; chmod 640 /etc/jarl/jarl.yaml`);
jarld warns at start when everyone can read it. On `metrics.listen` the
token travels unencrypted, so use it on a network you trust, or scrape the
main listener over HTTPS instead (`scheme: https` and its port in
Prometheus).
### Settings
| Key | Default | Meaning |
|-----|---------|---------|
| `metrics.enabled` | `false` | Serve `/metrics`. |
| `metrics.listen` | `[]` | Extra `ADDRESS:PORT`s that serve only `/metrics`, over plain HTTP. |
| `metrics.allow` | `["127.0.0.1", "::1"]` | Addresses or prefixes that may scrape without a token. Behind a reverse proxy in `trusted_proxies`, this is the client's address from `X-Forwarded-For`. `[]` with a token: the token is required everywhere. |
| `metrics.token` | `""` | A bearer token (at least 16 characters) that lets any address scrape. |
A scrape is allowed when it comes from an address in `allow`, **or**
carries the token, **or** comes over the local socket from root or jarld's
own user. A wrong token is refused (and counts towards the per-address
limit on failed logins). Jarl's API tokens don't work for `/metrics`.
The firewall must let Prometheus reach the port. `allow` is checked by
jarld, not the kernel.
## What is exported
Everything is prefixed `jarl_`. The text format is checked with `promtool
check metrics`.
### Health (always)
These are read at most every 15 seconds; a scrape in between gets the
same values.
| Metric | Meaning |
|--------|---------|
| `jarl_build_info{version}` | Always 1. |
| `jarl_start_time_seconds` | When jarld started. |
| `jarl_metrics_state_ok` | 0 when Jarl's state couldn't be read (see jarld's log). |
| `jarl_adopted` | 1 when named loads Jarl's configuration. |
| `jarl_named_up` | 1 when named answers `rndc status` (missing with `bind.no_reload`). |
| `jarl_named_recursive_clients`, `…_soft_limit`, `…_limit` | Recursive clients now, and named's limits (`rndc status`). |
| `jarl_named_tcp_clients`, `…_limit` | TCP clients now, and the limit. |
| `jarl_open_changesets` | Changesets with staged changes not applied yet. |
| `jarl_last_apply_timestamp_seconds` | When the last changeset was applied. |
| `jarl_problems{severity}` | Validation problems (`error`, `warning`, `info`) as on the dashboard, including named's own warnings. |
| `jarl_drifted_files` | Managed files changed or removed outside Jarl. |
| `jarl_objects{kind}` | Views, zones, records, ACLs, TSIG keys and options. |
| `jarl_backup_last_success_timestamp_seconds`, `jarl_backup_last_size_bytes` | The newest backup by `jarld backup`. |
| `jarl_backup_last_failure_timestamp_seconds` | A failed backup newer than the last good one. |
| `jarl_backup_stale` | 1 when the newest backup is older than `backup.warn_after`. |
| `jarl_blocking_paused` | 1 while blocking is paused (only with blocklists or rules). |
| `jarl_blocking_paused_until_timestamp_seconds` | When a timed pause ends. |
| `jarl_blocklist_enabled{list}` | 1 for lists that are switched on. |
| `jarl_blocklist_entries{list}` | Domains in the last good download. |
| `jarl_blocklist_failing{list}` | 1 when the last download or check of an enabled list failed. |
| `jarl_blocklist_last_check_timestamp_seconds{list}`, `jarl_blocklist_last_change_timestamp_seconds{list}` | When the list was last checked, and last changed. |
| `jarl_dnssec_phase{zone,view,phase}` | Always 1, one per signed zone; `phase` is one of the [DNSSEC phases](dnssec.md). `ready` means: add the DS at the parent. `removing_ds`: remove it. |
| `jarl_token_expiry_timestamp_seconds{id,user,token}` | When each API token with an expiry date expires. |
### Queries (with `stats.enabled`)
The counters start at zero when jarld starts (Prometheus handles that).
named's counters move once a minute, when Jarl polls named's statistics
channel. Counters from the response log (`responses`, views, zones,
blocking) move as lines are read and need `stats.query_log`. Clients in
`stats.exclude_clients` are left out of those. Names and clients are not
exported, because there would be too many of them. Use the Statistics page
for them.
| Metric | Meaning |
|--------|---------|
| `jarl_queries_total` | Queries named received. |
| `jarl_responses_by_rcode_total{rcode}` | Responses by result code (`NOERROR`, `NXDOMAIN`, `SERVFAIL`, `REFUSED`, …). |
| `jarl_queries_by_type_total{type}` | Queries by record type. |
| `jarl_queries_by_transport_total{transport}` | `udp` and `tcp`. |
| `jarl_answers_total{source}` | `authoritative` or `recursive`. |
| `jarl_recursion_queries_total`, `jarl_dropped_queries_total`, `jarl_duplicate_queries_total` | Queries that caused recursion, that named dropped, and duplicates. |
| `jarl_ratelimit_responses_total{action}` | Responses rate limiting `dropped` or `slipped`. |
| `jarl_upstream_responses_by_rtt_total{rtt}` | Upstream answers during recursion by round-trip time (`<10ms` … `>1600ms`). |
| `jarl_dnssec_validations_total{result}` | `valid`, `valid-negative` and `failed`. |
| `jarl_cache_lookups_total{result}` | `hits` and `misses`. |
| `jarl_logged_responses_total` | Responses read from the response log. |
| `jarl_responses_by_view_total{view}` | Logged responses per view (`_default` without views). |
| `jarl_responses_by_zone_total{zone}` | Logged responses per local zone; `-` for names in no local zone. |
| `jarl_blocked_queries_total` | Queries blocking answered. |
| `jarl_blocked_by_list_total{list}` | Blocked queries per blocklist; `rules` for your own rules. |
| `jarl_stats_poll_ok`, `jarl_stats_last_poll_timestamp_seconds` | Whether the last poll of named's statistics channel worked, and when it was. |
| `jarl_stats_log_lines_total`, `jarl_stats_last_log_line_timestamp_seconds` | Response log lines read, and the time of the last one. |
| `jarl_stats_warnings` | Warnings on the Statistics page (e.g. no log lines while named answers queries). |
| `jarl_named_start_time_seconds`, `jarl_named_config_time_seconds` | When named started, and last loaded its configuration. |
| `jarl_named_resident_memory_bytes`, `jarl_named_threads`, `jarl_named_cpu_seconds_total` | named's process (`/proc`). |
| `jarl_named_memory_in_use_bytes` | Memory named counts as in use. |
| `jarl_named_cache_rrsets`, `jarl_named_cache_memory_bytes` | named's caches, all views together. |
| `jarl_named_sockets{proto}` | Open UDP and TCP sockets. |
| `jarl_named_recursive_clients_highwater`, `jarl_named_tcp_clients_highwater` | The most clients at once since named started. |
Host metrics (load, memory, disks) are left to
[node_exporter](https://github.com/prometheus/node_exporter).
## Example queries
```promql
# Queries per second, and the share that failed
rate(jarl_queries_total[5m])
rate(jarl_responses_by_rcode_total{rcode="SERVFAIL"}[5m]) / ignoring(rcode) rate(jarl_queries_total[5m])
# Blocked share of logged responses, per list
rate(jarl_blocked_by_list_total[1h]) / ignoring(list) group_left rate(jarl_logged_responses_total[1h])
# Busiest zones
topk(5, rate(jarl_responses_by_zone_total{zone!="-"}[1h]))
# Cache hit rate
rate(jarl_cache_lookups_total{result="hits"}[5m]) / ignoring(result) sum without(result) (rate(jarl_cache_lookups_total[5m]))
```
## Example alerts
```yaml
groups:
- name: jarl
rules:
- alert: JarlDown
expr: up{job="jarl"} == 0
for: 5m
- alert: NamedDown
expr: jarl_named_up == 0
for: 2m
- alert: JarlConfigErrors
expr: jarl_problems{severity="error"} > 0
for: 15m
- alert: JarlDrift
expr: jarl_drifted_files > 0
for: 15m
- alert: BlocklistFailing
expr: jarl_blocklist_failing == 1
for: 6h
- alert: BackupStale
expr: jarl_backup_stale == 1
- alert: DNSSECWaitingForYou
expr: jarl_dnssec_phase{phase=~"ready|removing_ds|unsigned"} == 1
for: 1h
annotations:
summary: "{{ $labels.zone }}: DNSSEC is waiting for a step at the parent ({{ $labels.phase }})"
- alert: TokenExpiresSoon
expr: jarl_token_expiry_timestamp_seconds - time() < 7 * 86400
annotations:
summary: "API token {{ $labels.token }} of {{ $labels.user }} expires within a week"
- alert: BlockingPausedForLong
expr: jarl_blocking_paused == 1
for: 24h
- alert: NamedRecursiveClientsHigh
expr: jarl_named_recursive_clients / jarl_named_recursive_clients_soft_limit > 0.8
for: 5m
- alert: StatsNotCollecting
expr: jarl_stats_poll_ok == 0 or jarl_stats_warnings > 0
for: 15m
```
+3
View File
@@ -363,6 +363,9 @@ rndc reconfig
## Monitoring
* `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:
`sudo jarl status -o json`.
* `GET /api/v1/status` with a read-only token reports named reachability,
+2 -1
View File
@@ -135,6 +135,7 @@ RESOURCE:ACCESS [zones=PATTERNS] [views=PATTERNS] [names=PATTERNS] [types=TYPES]
| `options` | server options, top-level statements, the defaults for new zones, `jarl check` |
| `audit` | everyone's audit events and changesets (without it, users see only their own) |
| `stats` | query statistics, including client addresses and the names they look up (read only) |
| `blocking` | blocklists and block/allow rules ([blocking.md](blocking.md)); `write` also downloads lists now |
| `*` | all of the above |
`ACCESS` is `read` or `write` (write includes read). Filters narrow a grant.
@@ -168,7 +169,7 @@ you can then edit.
| Role | Grants |
|------|--------|
| `readonly` | `*:read` (includes statistics) |
| `dns-editor` | `zones:write`, `records:write`, `views:read`, `acls:read`, `options:read` |
| `dns-editor` | `zones:write`, `records:write`, `views:read`, `acls:read`, `options:read`, `blocking:read` |
| `operator` | `*:write` (everything except user management) |
| `acme` | `records:write names=_acme-challenge.* types=TXT` |
+43 -2
View File
@@ -102,22 +102,58 @@ lists, views, zones). named's own counters can't be filtered.
| `view:NAME` | response log | answers per view (`_default` without views) |
| `zone:NAME` | response log | answers per local zone; `zone:-` for names in none of your zones |
| `responses` | response log | lines read (a health check) |
| `blocked`, `blocklist:NAME` | response log | queries [blocking](blocking.md) answered, and by which list (`blocklist:rules` for your own rules) |
**Server** (sampled every minute; stored as gauges, `g:KEY` the sum of
the samples and `gn:KEY` their number, so a chart shows the average):
| Gauge | Source | Meaning |
|-------|--------|---------|
| `g:named:rss` | /proc | named's resident memory (what the system sees), bytes |
| `g:named:mem` | named | the memory named counts itself as using, bytes |
| `g:named:cpu` | /proc | named's CPU use over the minute, in tenths of a percent of one CPU |
| `g:cache:mem`, `g:cache:rrsets` | named | the cache's memory and RRsets, all views |
| `g:host:mem-used`, `g:host:load` | /proc | the host's memory in use, and its 1-minute load average times 100 |
Jarl finds named's process by name (the one whose command line mentions
named's main configuration file); reading its memory and CPU time needs no
privileges. With `/proc` mounted `hidepid`, it isn't visible and those
figures are left out. The current values also include named's sockets, its
recursive and TCP clients against their limits (`rndc status`), the most
at once since it started, and the space left on the file systems holding
the zone files, Jarl's cache, named.conf, the response log and this
database.
**Top lists** (per hour and per day, the `top_n` largest): `name` (most
queried names), `client`, `nxdomain` (names answered NXDOMAIN), `servfail`
(names answered SERVFAIL), `refused` (clients that were refused).
(names answered SERVFAIL), `refused` (clients that were refused), `blocked`
(names blocking answered), `blocked_client` (clients by blocked queries). Blocked answers are NXDOMAIN too, but they
aren't counted as names that don't exist: not in the `nxdomain` list, and
not in the summary's NXDOMAIN ratio.
Nothing else is stored. In particular there's no per-query record, so you
can't look up "who asked for X at 14:02". That is deliberate, both for size
and privacy.
**Client names.** Where the client lists (busiest and refused clients) show
an address Jarl has a record for, they show its host name too: the PTR
record's target, or else the name of an A/AAAA record with that address
(disabled records don't count, and only records you may read). This is
looked up in Jarl's own data when the list is shown, not stored and not
asked of DNS, so it is instant and adds no queries of its own. The name is
what the records say now: with DHCP, an address seen last week may have
been another machine. Anonymized clients (`anonymize_clients`) are
prefixes and get no name.
## Using it
**Web UI**: Statistics. Pick a range (1 hour to 90 days); everything on the
page follows it. Charts have a hover readout (or focus them and use the
arrow keys) and a **Table** button showing the same data as a table. The
heatmap shows the average queries per hour for each weekday and hour over at
least the last 7 days. The page refreshes every minute.
least the last 7 days. The page refreshes every minute. **Server**, at the
bottom, shows named's memory, CPU, cache and clients, the host and the
disks now, and named's memory and CPU over the range.
**CLI**:
@@ -132,11 +168,16 @@ jarl stats timeline --since 6h # sparkline; --table for every bucket
jarl stats timeline --since 7d --key rcode:NXDOMAIN --key rcode:SERVFAIL
jarl stats heatmap --since 28d
jarl stats breakdown qtype | rcode | view | zone | answer | rtt | dnssec | cache | transport | ratelimit
jarl stats server # memory, CPU, cache, clients, host, disks now
jarl stats timeline --since 7d --key g:named:rss --table # named's memory over a week
jarl stats summary --since 30d -o json # export (also -o yaml)
```
**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
`operator` roles include it, `dns-editor` doesn't. Statistics show client
addresses and what they look up, so grant it with care.
+32 -3
View File
@@ -30,6 +30,7 @@ showing them.
| `record_out_of_zone` | error | The name must be inside the zone (catches `www.example.com` without the final dot) |
| `record_duplicate` | error | The same record can't exist twice |
| `cname_conflict` | error | A CNAME must be the only record at its name (RFC 1034), so no CNAME next to A, MX, TXT... |
| `disabled_conflict` | warning | A disabled record that couldn't be enabled again as the zone is now (a CNAME now shares its name) |
| `cname_multiple` | error | A name can have only one CNAME |
| `cname_apex` | error | No CNAME at the zone apex (it needs SOA and NS) |
| `cname_loop` | error | CNAMEs that point back to themselves |
@@ -56,6 +57,8 @@ showing them.
| `zone_missing_option` | error | Secondary, stub and mirror zones need `primaries` |
| `zone_file_required` / `zone_file_invalid` / `zone_file_managed` | error | Hint zones need a file. Forward zones have none. Primary zone files are Jarl's |
| `zone_inview_invalid`, `zone_inview_option` | error | `in-view` zones only inside views, and only with `forward`/`forwarders` |
| `zone_inview_disabled` | error | An `in-view` zone can't share a disabled zone |
| `zone_disabled_signed` | error | A signed zone can't be disabled: turn signing off first |
| `zone_records_unmanaged` | error | Only primary zones can have records in Jarl |
| `zone_serial_policy` | error | `date`, `increment` or `unixtime` |
| `zone_no_forwarders`, `zone_hint_name`, `zone_name_slash` | info / warning | Unusual but legal setups |
@@ -64,11 +67,12 @@ showing them.
| 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_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 |
| `key_*` | error / warning | Valid key names, a supported algorithm, a base64 secret of a sensible length. hmac-md5/sha1 are flagged as weak |
| `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`) |
| `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 |
| `option_unknown`, `option_scope_invalid` | error | The option must exist in BIND 9.20 and be allowed where it is set (server, view, zone of this type, top level) |
@@ -100,6 +104,24 @@ applied.
| `dnssec_old_keys` | info | Signing a zone whose key directory still has old keys |
| `serial_not_higher` | error | An explicit serial on a signed zone must be above the one named has loaded (automatic serials are raised by themselves) |
## Blocking
From [blocking.md](blocking.md).
| Code | Severity | Rule |
|------|----------|------|
| `blocking_rule_duplicate` | error | One rule per domain and view |
| `blocking_response_policy` | error | No `response-policy` option where Jarl's blocking writes that statement (the view, or the server) |
| `zone_name_reserved` | error | Zone names under `jarl.invalid` are reserved for blocking's policy zones |
| `blocking_view_missing` | error | A list or rule names a view that doesn't exist |
| `blocking_no_view` | warning | A list or rule applies to no view: none offers recursion, and blocking only changes recursive answers |
| `blocking_no_recursion` | warning | A list or rule names a view that doesn't offer recursion, where it has no effect |
| `blocking_step_missing` | error | A list or client group is at a step that doesn't exist (a list can't be at `off`) |
| `client_group_member_invalid` | error | Client group members are addresses, networks, or ACLs made only of those |
| `client_group_overlap` | error | The same address or network is in two client groups of a view |
| `client_group_empty` | warning | A client group has no members |
| `blocklist_step_unused` | warning | A list is at a step that no client group reaches, so it blocks nothing |
## Security lints
| Code | Severity | What it catches |
@@ -116,3 +138,10 @@ values Jarl accepts as raw named.conf syntax, such as `logging` channels
and `rrset-order`, are the main example. Their errors come back as
`validation_failed` (HTTP 422) with the checker's messages in `check`, each
tied to the object it belongs to.
named-checkconf's warnings don't block a change. They are shown with the
change, and the dashboard's configuration checks list them for as long as
they apply (`named_warning`; `named_error` if the running configuration no
longer passes, after a hand edit say). Jarl runs named-checkconf on the
running configuration for this, without loading zones, when the dashboard
asks and the configuration changed, or at most every 10 minutes.
+41 -2
View File
@@ -116,7 +116,44 @@ dig @<public-ip> example.com # from outside: status R
jarl check
```
On the server, `dig -b <source address>` picks which view you test.
On the server, `dig -b <source address>` picks which view you test, or,
from anywhere, sign the query with the view's key (below):
```sh
jarl view key external --named-conf > external.key
dig -k external.key @ns1.adbraun.lol nas.adbraun.lol +short # 203.0.113.20, even from the LAN
```
## View keys
named picks a view by where a query comes from, so a query this server
sends to its own public address usually lands in the internal view: its
source is one of your own networks. That makes checks of the external view
from the server itself (such as Jarl's delegation check) see the wrong
view.
So Jarl gives every view a TSIG key, `jarl-view-NAME`, and puts it at the
front of the view's `match-clients`:
```
view "internal" {
match-clients { !key jarl-view-external; key jarl-view-internal; homelab; };
...
view "external" {
match-clients { key jarl-view-external; any; };
```
A query signed with a view's key lands in that view, whatever its source;
the `!key` stops an earlier view from taking a later view's queries.
Clients don't send these keys, so for them nothing changes. Jarl signs its
own queries to this server's addresses with the key of the zone's view.
The keys live in `keys.conf` with your other keys, never in diffs or in
`jarl tsig list`; their secrets are derived from a random value in Jarl's
database (and in its backups), so they stay the same from one apply to
the next. `jarl view key VIEW`
shows one (it needs `tsig:write` and is audited). Key names starting with
`jarl-view-` are reserved, and the view keys can be used in your own
options (`allow-transfer { key jarl-view-external; }`).
## Living with views
@@ -130,7 +167,9 @@ On the server, `dig -b <source address>` picks which view you test.
`jarl view list` shows it.
* Deleting a view is refused while it still has zones, unless you pass
`--cascade`.
* Give each view a colour (Views → Edit → Colour, or
* Each view has its own page (Views → the view's name): its settings,
options and zones.
* Give each view a colour (the view's page → Settings → Colour, or
`jarl view color internal green`) to tell the zones of each view apart
in the zone list. The colour only affects the web UI, so it applies at
once, with no changeset or deploy. `jarl view color internal default`
+80 -7
View File
@@ -19,11 +19,13 @@ const Prefix = "/api/v1"
// Zone is a zone as returned by the API.
type Zone struct {
model.Zone
Display string `json:"display_name"`
View string `json:"view,omitempty"`
Records int `json:"record_count"`
ZoneFile string `json:"zone_file,omitempty"` // the file named loads
RName string `json:"rname_email,omitempty"`
Display string `json:"display_name"`
View string `json:"view,omitempty"`
Records int `json:"record_count"`
// DisabledRecords is how many of the records are disabled.
DisabledRecords int `json:"disabled_count,omitempty"`
ZoneFile string `json:"zone_file,omitempty"` // the file named loads
RName string `json:"rname_email,omitempty"`
// Registrar names the registrar Jarl can manage the domain at
// ("porkbun"), when one is configured for it.
Registrar string `json:"registrar,omitempty"`
@@ -40,7 +42,8 @@ type Record struct {
EffectiveTTL uint32 `json:"effective_ttl"`
// Text is the decoded value of TXT and SPF records.
Text *string `json:"text,omitempty"`
// Pending is set when listing with a changeset: add, change or delete.
// Pending is set when listing with a changeset: add, change, delete,
// disable or enable.
Pending string `json:"pending,omitempty"`
}
@@ -74,6 +77,10 @@ type Option struct {
Where string `json:"where"` // e.g. "view internal"
Description string `json:"description,omitempty"`
Status string `json:"status,omitempty"`
// Formatted is the value laid out over several lines, as in
// named.conf, when it has a block (for display and editing; Value is
// the same on one line).
Formatted string `json:"formatted,omitempty"`
}
// User is a user with formatted grants.
@@ -239,6 +246,21 @@ type StatusResponse struct {
Backup *BackupStatus `json:"backup,omitempty"`
// TOTP is about two-factor login across users (administrators only).
TOTP *TOTPOverview `json:"totp,omitempty"`
// BlocklistErrors are blocklists whose last update failed (for those
// who may see blocking).
BlocklistErrors []BlocklistError `json:"blocklist_errors,omitempty"`
// BlockingPausedUntil is set while blocking is paused for a time,
// BlockingPausedIndefinitely while it is paused until resumed.
BlockingPausedUntil *time.Time `json:"blocking_paused_until,omitempty"`
BlockingPausedIndefinitely bool `json:"blocking_paused_indefinitely,omitempty"`
}
// BlocklistError is a blocklist whose last update failed. UpdatedAt is
// the version named keeps using (nil: none yet).
type BlocklistError struct {
Name string `json:"name"`
Error string `json:"error"`
UpdatedAt *time.Time `json:"updated_at,omitempty"`
}
// TOTPOverview is what the dashboard reports about two-factor login.
@@ -370,7 +392,12 @@ type DNSSECResponse struct {
*dnssec.Status
// ParentZone is set when the parent zone is a primary zone in Jarl
// (in the same view): the DS record then goes there, as a record.
ParentZone string `json:"parent_zone,omitempty"`
ParentZone string `json:"parent_zone,omitempty"`
ParentZoneID int64 `json:"parent_zone_id,omitempty"`
ParentView string `json:"parent_view,omitempty"`
// ParentDS is the DS records the parent zone in Jarl has for the
// zone (record data, e.g. "51126 13 2 FFBE...").
ParentDS []string `json:"parent_ds,omitempty"`
// Registrar names the registrar Jarl can publish the DS at
// ("porkbun"), when one is configured in jarl.yaml.
Registrar string `json:"registrar,omitempty"`
@@ -463,3 +490,49 @@ type RegistrarChangeResponse struct {
Checks []nscheck.Finding `json:"checks"`
Warnings []string `json:"warnings"`
}
// Blocklist is a blocklist as returned by the API (docs/blocking.md).
type Blocklist struct {
model.Blocklist
// Views are the views it applies to; AutoViews is set when that is
// "every view that offers recursion" (view_ids is empty).
Views []string `json:"views"`
AutoViews bool `json:"auto_views,omitempty"`
// State is ok, waiting (not downloaded yet), error (the last update
// failed; the previous version stays in use), disabled, or idle (it
// applies to no view).
State string `json:"state"`
Status *model.BlocklistStatus `json:"status,omitempty"`
// MemoryMB estimates what named needs for the list, in all its views.
MemoryMB int `json:"memory_mb,omitempty"`
Pending string `json:"pending,omitempty"`
// Step is the name of the step the list starts at ("normal" or one
// above it).
Step string `json:"step"`
}
// BlockStep is a blocking step above normal as returned by the API, with
// what uses it.
type BlockStep struct {
model.BlockStep
Lists []string `json:"lists"`
Groups []string `json:"groups"`
Pending string `json:"pending,omitempty"`
}
// ClientGroup is a client group as returned by the API.
type ClientGroup struct {
model.ClientGroup
Step string `json:"step"`
Views []string `json:"views"`
AutoViews bool `json:"auto_views,omitempty"`
Pending string `json:"pending,omitempty"`
}
// BlockRule is a block or allow rule as returned by the API.
type BlockRule struct {
model.BlockRule
Views []string `json:"views"`
AutoViews bool `json:"auto_views,omitempty"`
Pending string `json:"pending,omitempty"`
}
+12 -10
View File
@@ -12,19 +12,20 @@ import (
// Resources that grants apply to.
const (
ResZones = "zones" // zone definitions and zone options
ResRecords = "records" // records in zones
ResViews = "views" // views and view options
ResACLs = "acls"
ResTSIG = "tsig" // TSIG keys
ResOptions = "options" // server options, top-level statements, settings
ResAudit = "audit" // audit log and everyone's changesets
ResStats = "stats" // query statistics
ResAll = "*"
ResZones = "zones" // zone definitions and zone options
ResRecords = "records" // records in zones
ResViews = "views" // views and view options
ResACLs = "acls"
ResTSIG = "tsig" // TSIG keys
ResOptions = "options" // server options, top-level statements, settings
ResAudit = "audit" // audit log and everyone's changesets
ResStats = "stats" // query statistics
ResBlocking = "blocking" // blocklists and block/allow rules
ResAll = "*"
)
// Resources lists the resources in display order.
var Resources = []string{ResZones, ResRecords, ResViews, ResACLs, ResTSIG, ResOptions, ResAudit, ResStats}
var Resources = []string{ResZones, ResRecords, ResViews, ResACLs, ResTSIG, ResOptions, ResAudit, ResStats, ResBlocking}
// Access levels.
const (
@@ -377,6 +378,7 @@ var RoleTemplates = map[string][]model.Grant{
{Resource: ResViews, Access: Read},
{Resource: ResACLs, Access: Read},
{Resource: ResOptions, Access: Read},
{Resource: ResBlocking, Access: Read},
},
"operator": {{Resource: ResAll, Access: Write}},
"acme": {{Resource: ResRecords, Access: Write, Names: []string{"_acme-challenge.*"}, Types: []string{"TXT"}}},
+16 -1
View File
@@ -340,7 +340,14 @@ type ServerStatus struct {
ConfigFile string `json:"config_file"`
Zones string `json:"zones"`
Running bool `json:"running"`
Raw string `json:"raw"`
// RecursiveClients is "now/soft limit/hard limit" (the soft limit is
// where named starts dropping the oldest queries), TCPClients
// "now/limit".
RecursiveClients string `json:"recursive_clients,omitempty"`
TCPClients string `json:"tcp_clients,omitempty"`
CPUs string `json:"cpus,omitempty"`
WorkerThreads string `json:"worker_threads,omitempty"`
Raw string `json:"raw"`
}
// Status returns named's status.
@@ -363,6 +370,14 @@ func (t *Tools) Status(ctx context.Context) (*ServerStatus, error) {
st.ConfigFile = v
case ok && k == "number of zones":
st.Zones = v
case ok && k == "recursive clients":
st.RecursiveClients = v
case ok && k == "tcp clients":
st.TCPClients = v
case ok && k == "CPUs found":
st.CPUs = v
case ok && k == "worker threads":
st.WorkerThreads = v
case strings.Contains(line, "server is up and running"):
st.Running = true
}
+180
View File
@@ -0,0 +1,180 @@
package blocking
import (
"errors"
"os"
"path/filepath"
"strings"
"testing"
)
func TestParse(t *testing.T) {
src := "\uFEFF[Adblock Plus]\r\n" + `! Title: test list
# a comment
||ads.example^
||Tracker.Example.^$important
||cosmetic.example^$third-party
@@||allowed.example^
example.org##.banner
|https://url.example/path
0.0.0.0 hosts.example other.example # two names
127.0.0.1 localhost
127.0.0.1 localhost.localdomain
::1 ip6-localhost
0.0.0.0 0.0.0.0
plain.example
*.wild.example
single
1.2.3.4
bad_label!.example
münchen.example
www.ads.example
` + strings.Repeat("a", 64) + ".example\n"
l, err := Parse(strings.NewReader(src), Limits{})
if err != nil {
t.Fatal(err)
}
want := map[string]uint8{
"ads.example": Exact | Below, "tracker.example": Exact | Below,
"hosts.example": Exact, "other.example": Exact, "plain.example": Exact,
"wild.example": Exact | Below, "xn--mnchen-3ya.example": Exact, "www.ads.example": Exact,
}
if len(l.Domains) != len(want) {
t.Errorf("domains = %v", l.Domains)
}
for n, f := range want {
if l.Domains[n] != f {
t.Errorf("%s = %d, want %d", n, l.Domains[n], f)
}
}
// Skipped: $third-party, @@, ##, |url, localhost (x3), 0.0.0.0, single, 1.2.3.4, bad_label!, 64-char label.
if l.Skipped != 12 || l.Lines != 19 {
t.Errorf("lines %d, skipped %d", l.Lines, l.Skipped)
}
Compact(l)
if _, ok := l.Domains["www.ads.example"]; ok {
t.Error("www.ads.example is covered by ||ads.example^ and should be dropped")
}
var sb strings.Builder
n, hash, err := FeedZone(&sb, "test", 7, l)
if err != nil || n != 10 {
t.Fatalf("FeedZone: %d records, %v", n, err)
}
for _, line := range []string{"ads.example\tCNAME\t.\n", "*.ads.example\tCNAME\t.\n", "plain.example\tCNAME\t.\n", "@\tSOA\tlocalhost. hostmaster.localhost. 7 "} {
if !strings.Contains(sb.String(), line) {
t.Errorf("zone lacks %q:\n%s", line, sb.String())
}
}
if strings.Contains(sb.String(), "*.plain.example") || strings.Contains(sb.String(), "$ORIGIN") {
t.Errorf("zone:\n%s", sb.String())
}
// The hash ignores the serial.
var other strings.Builder
if _, h2, _ := FeedZone(&other, "test", 8, l); h2 != hash {
t.Error("hash depends on the serial")
}
}
func TestParseLimits(t *testing.T) {
_, err := Parse(strings.NewReader("a.example\nb.example\na.example\nc.example\n"), Limits{MaxEntries: 2})
if !errors.Is(err, ErrTooManyEntries) {
t.Errorf("err = %v", err)
}
if _, err := Parse(strings.NewReader("a.example\nb.example\na.example\n"), Limits{MaxEntries: 2}); err != nil {
t.Errorf("a repeated name counted against the limit: %v", err)
}
l, _ := Parse(strings.NewReader("long-name.example\nshort.example\n"), Limits{MaxNameLen: 15})
if len(l.Domains) != 1 || l.Skipped != 1 {
t.Errorf("max name length: %v", l.Domains)
}
}
func TestRuleEntries(t *testing.T) {
rules := []Rule{
{Name: "ads.example", Action: ActionBlock}, // and everything below
{Name: "cdn.ads.example", Action: ActionAllow, Exact: true}, // just this name
{Name: "wide.example", Action: ActionBlock}, // and below
{Name: "ok.wide.example", Action: ActionAllow}, // and below
{Name: "deep.x.y.wide.example", Action: ActionAllow, Exact: true}, // two empty names in between
{Name: "solo.example", Action: ActionBlock, Exact: true},
}
e := RuleEntries(rules)
for name, want := range map[string]string{
"ads.example": ActionBlock,
"x.ads.example": ActionBlock,
"cdn.ads.example": ActionAllow,
"y.cdn.ads.example": ActionBlock, // the wildcard above still applies
"ok.wide.example": ActionAllow,
"a.ok.wide.example": ActionAllow,
"y.wide.example": ActionBlock, // exists only because of a deeper rule
"x.y.wide.example": ActionBlock,
"z.x.y.wide.example": ActionBlock,
"deep.x.y.wide.example": ActionAllow,
"below.deep.x.y.wide.example": ActionBlock,
"solo.example": ActionBlock,
"other.example": "",
"sub.solo.example": "",
"example": "",
"unrelated.org": "",
} {
_, act, ok := MatchRules(e, name)
if !ok {
act = ""
}
if act != want {
t.Errorf("%s: %q, want %q", name, act, want)
}
}
z := RulesZoneFile("rules", rules)
if !strings.Contains(z, "cdn.ads.example\tCNAME\trpz-passthru.\n*.cdn.ads.example\tCNAME\t.\n") {
t.Errorf("rules zone:\n%s", z)
}
if strings.Index(z, "ads.example\t") > strings.Index(z, "*.ads.example\t") {
t.Errorf("a name should come before its wildcard:\n%s", z)
}
}
func TestMatchFeed(t *testing.T) {
l, _ := Parse(strings.NewReader("||tracker.example^\nexact.example\n||deep.a.example^\n"), Limits{})
Compact(l)
path := filepath.Join(t.TempDir(), "list.rpz")
f, _ := os.Create(path)
FeedZone(f, "t", 1, l)
f.Close()
for name, want := range map[string]string{
"tracker.example": "tracker.example",
"a.b.tracker.example": "*.tracker.example",
"exact.example": "exact.example",
"sub.exact.example": "",
"x.deep.a.example": "*.deep.a.example",
"a.example": "",
"example": "",
} {
owner, ok, err := MatchFeed(path, name)
if err != nil {
t.Fatal(err)
}
if !ok {
owner = ""
}
if owner != want {
t.Errorf("%s: %q, want %q", name, owner, want)
}
}
}
func TestNames(t *testing.T) {
if ListZone("hagezi") != "hagezi.blocklist.jarl.invalid" || ListName("hagezi.blocklist.jarl.invalid.") != "hagezi" || ListName(RulesZone) != "" || ListName("x.y.blocklist.jarl.invalid") != "" {
t.Error("zone names")
}
if n, err := NormalizeName("Ads.Example.COM."); err != nil || n != "ads.example.com" {
t.Errorf("NormalizeName = %q, %v", n, err)
}
for _, bad := range []string{"com", "1.2.3.4", "a b.example", "*.example.com", ""} {
if _, err := NormalizeName(bad); err == nil {
t.Errorf("%q accepted", bad)
}
}
}
+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)
}
}
+198
View File
@@ -0,0 +1,198 @@
// Package blocking turns blocklists and block/allow rules into BIND
// response policy zones (RPZ), and answers which rule or list matches a
// name.
//
// A policy zone holds one rule per owner name: "ads.example CNAME ."
// answers NXDOMAIN for ads.example, "*.ads.example CNAME ." for every name
// below it, and "CNAME rpz-passthru." answers normally (an exception).
// named looks names up in a policy zone like in any zone, so wildcards
// follow DNS rules: a wildcard doesn't apply below a name that exists in
// the zone. The writers here add the rules that keep "the most specific
// rule wins" true despite that (see RulesZone).
package blocking
import (
"bufio"
"errors"
"fmt"
"io"
"net/netip"
"strings"
"unicode/utf8"
"jarl/internal/dnsname"
)
// Flags say what a list entry covers.
const (
Exact uint8 = 1 << iota // the name itself
Below // every name below it
)
// List is a parsed blocklist.
type List struct {
// Domains maps names (lowercase, without the final dot) to flags.
Domains map[string]uint8
// Lines is the number of lines with an entry (not comments or blank
// lines); Skipped how many of them were not usable.
Lines, Skipped int
}
// Limits bound what Parse accepts.
type Limits struct {
MaxEntries int // distinct domains (0: no limit)
MaxNameLen int // longest name in characters (0: 253)
}
// ErrTooManyEntries is returned when a list has more than MaxEntries
// domains.
var ErrTooManyEntries = errors.New("too many entries")
// Parse reads a blocklist. Each line is one of:
//
// ||ads.example^ Adblock style: the name and everything below it
// *.ads.example the name and everything below it
// 0.0.0.0 ads.example hosts file (any address; several names allowed)
// ads.example just this name
//
// Comments (#, !), Adblock headers ([Adblock Plus]) and blank lines are
// ignored. Lines that can't be used for DNS blocking (exceptions,
// cosmetic and URL rules, addresses, single-label names, invalid names)
// are counted as skipped.
func Parse(r io.Reader, lim Limits) (*List, error) {
maxLen := lim.MaxNameLen
if maxLen <= 0 || maxLen > 253 {
maxLen = 253
}
l := &List{Domains: map[string]uint8{}}
full := false // a new name was refused because of MaxEntries
add := func(name string, flags uint8) bool {
name, ok := normalize(name, maxLen)
if !ok {
return false
}
if _, seen := l.Domains[name]; !seen && lim.MaxEntries > 0 && len(l.Domains) >= lim.MaxEntries {
full = true
return false
}
l.Domains[name] |= flags
return true
}
sc := bufio.NewScanner(r)
sc.Buffer(make([]byte, 64*1024), 1024*1024)
first := true
for sc.Scan() {
line := sc.Text()
if first {
line = strings.TrimPrefix(line, "\uFEFF")
first = false
}
line = strings.TrimSpace(line)
if line == "" || line[0] == '#' || line[0] == '!' || line[0] == '[' {
continue
}
l.Lines++
ok := false
switch {
case strings.HasPrefix(line, "||"):
ok = add(adblockDomain(line[2:]), Exact|Below)
case strings.ContainsAny(line, "|/$^@") || strings.Contains(line, "##"):
// Exceptions (@@), URL and cosmetic rules: not for DNS.
default:
if i := strings.Index(line, "#"); i >= 0 {
line = strings.TrimSpace(line[:i])
}
fields := strings.Fields(line)
switch {
case len(fields) == 1 && strings.HasPrefix(fields[0], "*."):
ok = add(fields[0][2:], Exact|Below)
case len(fields) == 1:
ok = add(fields[0], Exact)
case len(fields) > 1 && isAddr(fields[0]):
for _, f := range fields[1:] {
if add(f, Exact) {
ok = true
}
}
}
}
if full {
return l, fmt.Errorf("%w: the list has more than %d domains", ErrTooManyEntries, lim.MaxEntries)
}
if !ok {
l.Skipped++
}
}
if err := sc.Err(); err != nil {
return l, err
}
return l, nil
}
// adblockDomain returns the domain of an Adblock rule's body
// ("ads.example^" or "ads.example^$important"), or "" when the rule
// does more than block a domain (other modifiers, paths, wildcards).
func adblockDomain(s string) string {
d, rest, _ := strings.Cut(s, "^")
if rest != "" && rest != "$important" {
return ""
}
if strings.ContainsAny(d, "*/:|$") {
return ""
}
return d
}
func isAddr(s string) bool {
_, err := netip.ParseAddr(s)
return err == nil
}
// localNames are hosts-file names that aren't blocklist entries.
var localNames = map[string]bool{"localhost.localdomain": true, "ip6-localhost": true, "ip6-loopback": true}
// normalize lowercases a name, removes the final dot, converts
// international names to their ASCII form, and checks it is usable: at
// least two labels of letters, digits, hyphens and underscores, and not an
// address.
func normalize(name string, maxLen int) (string, bool) {
name = strings.TrimSuffix(strings.ToLower(strings.TrimSpace(name)), ".")
if name == "" || localNames[name] || isAddr(name) {
return "", false
}
for i := 0; i < len(name); i++ {
if name[i] >= utf8.RuneSelf {
a, err := dnsname.ToASCII(name)
if err != nil {
return "", false
}
name = strings.TrimSuffix(strings.ToLower(a), ".")
break
}
}
if len(name) > maxLen || !strings.Contains(name, ".") {
return "", false
}
for _, label := range strings.Split(name, ".") {
if label == "" || len(label) > 63 {
return "", false
}
for i := 0; i < len(label); i++ {
c := label[i]
if !(c >= 'a' && c <= 'z' || c >= '0' && c <= '9' || c == '-' || c == '_') {
return "", false
}
}
}
return name, true
}
// NormalizeName checks and normalizes a domain the way list entries are
// (for rules entered by hand). The name must have at least two labels.
func NormalizeName(name string) (string, error) {
n, ok := normalize(name, 253-len(RulesZone)-2)
if !ok {
return "", fmt.Errorf("%q is not a domain name that can be blocked (it needs at least two labels of letters, digits, hyphens or underscores, like ads.example.com)", name)
}
return n, nil
}
+316
View File
@@ -0,0 +1,316 @@
package blocking
import (
"bufio"
"crypto/sha256"
"encoding/hex"
"fmt"
"io"
"os"
"sort"
"strings"
)
// Policy zone names. They are under .invalid (RFC 6761), so they can
// never clash with a real zone, and named only uses them internally.
const (
// RulesZone holds the rules entered in Jarl (one copy per view).
RulesZone = "rules.jarl.invalid"
// listSuffix follows a list's name: hagezi.blocklist.jarl.invalid.
listSuffix = ".blocklist.jarl.invalid"
)
// ListZone returns the policy zone name of a blocklist.
func ListZone(name string) string { return name + listSuffix }
// ListName returns the blocklist name of a policy zone ("" for the rules
// zone and zones that aren't Jarl's).
func ListName(zone string) string {
zone = strings.TrimSuffix(strings.ToLower(zone), ".")
if n, ok := strings.CutSuffix(zone, listSuffix); ok && !strings.Contains(n, ".") {
return n
}
return ""
}
// Match says what blocking does with a name in one view.
type Match struct {
View string `json:"view,omitempty"`
Result string `json:"result"` // blocked, allowed or none
Entry string `json:"entry,omitempty"` // the matching entry: the name, or *.parent
RuleID int64 `json:"rule_id,omitempty"`
List string `json:"list,omitempty"` // the blocklist, when a list matched
// Step is the list's step when it is above normal: only clients at
// that step or above get this answer (others: none).
Step string `json:"step,omitempty"`
}
// Actions of rules.
const (
ActionBlock = "block"
ActionAllow = "allow"
)
// cname is the CNAME target of each action: "." answers NXDOMAIN,
// rpz-passthru. answers as if there were no rule.
var cname = map[string]string{ActionBlock: ".", ActionAllow: "rpz-passthru."}
// header starts a policy zone file. There is no $ORIGIN: names are
// relative to the zone, so a list can be renamed without rewriting its
// file. The short TTLs make a change reach clients' caches quickly.
func header(title string, serial uint32) string {
return fmt.Sprintf("; %s\n; Generated by Jarl. DO NOT EDIT.\n$TTL 60\n@\tSOA\tlocalhost. hostmaster.localhost. %d 3600 600 86400 60\n\tNS\tlocalhost.\n", title, serial)
}
// Placeholder is the content of a list's zone file before the list has
// been downloaded: a valid, empty policy zone.
func Placeholder() string {
return header("Blocklist not downloaded yet.", 1)
}
// Rule is a block or allow rule entered by hand.
type Rule struct {
Name string // lowercase, without the final dot
Action string // ActionBlock or ActionAllow
Exact bool // only the name itself, not the names below it
}
// RuleEntries returns the policy zone owners for rules (relative names,
// "*." for wildcards) and their actions, with the entries that make the
// most specific rule win added: named won't apply a wildcard below a name
// that exists in the zone, so every name below a wildcard that exists
// (because a more specific rule mentions it) gets the wildcard's action
// for itself and for the names below it, unless a rule says otherwise.
// Callers make sure no two rules have the same name and scope.
func RuleEntries(rules []Rule) map[string]string {
e := map[string]string{}
for _, r := range rules {
e[r.Name] = r.Action
if !r.Exact {
e["*."+r.Name] = r.Action
}
}
// Every name that exists in the zone: owners and their ancestors.
nodes := map[string]bool{}
for owner := range e {
n := strings.TrimPrefix(owner, "*.")
for {
nodes[n] = true
i := strings.IndexByte(n, '.')
if i < 0 {
break
}
n = n[i+1:]
}
}
ordered := make([]string, 0, len(nodes))
for n := range nodes {
ordered = append(ordered, n)
}
// Shallow names first, so a filled-in wildcard is inherited further
// down.
sort.Slice(ordered, func(i, j int) bool {
di, dj := strings.Count(ordered[i], "."), strings.Count(ordered[j], ".")
if di != dj {
return di < dj
}
return ordered[i] < ordered[j]
})
for _, n := range ordered {
inherited := ""
for a := n; ; {
i := strings.IndexByte(a, '.')
if i < 0 {
break
}
a = a[i+1:]
if act, ok := e["*."+a]; ok {
inherited = act
break
}
}
if inherited == "" {
continue
}
if _, ok := e[n]; !ok {
e[n] = inherited
}
if _, ok := e["*."+n]; !ok {
e["*."+n] = inherited
}
}
return e
}
// PausedZoneFile renders a rules zone that lets every name through, for
// while blocking is paused: the rules zone comes first, and its match
// stops named from looking at the lists.
func PausedZoneFile(title string) string {
return header(title+" (blocking is paused)", 1) + "*\tCNAME\trpz-passthru.\n"
}
// RulesZoneFile renders the rules zone of a view.
func RulesZoneFile(title string, rules []Rule) string {
e := RuleEntries(rules)
owners := make([]string, 0, len(e))
for o := range e {
owners = append(owners, o)
}
sort.Slice(owners, func(i, j int) bool { return ownerLess(owners[i], owners[j]) })
var sb strings.Builder
sb.WriteString(header(title, 1))
for _, o := range owners {
fmt.Fprintf(&sb, "%s\tCNAME\t%s\n", o, cname[e[o]])
}
return sb.String()
}
// ownerLess sorts names by their labels from the right (example,
// ads.example, *.ads.example, cdn.ads.example), so related rules are next
// to each other.
func ownerLess(a, b string) bool {
ka, kb := reverseLabels(a), reverseLabels(b)
return ka < kb
}
func reverseLabels(n string) string {
labels := strings.Split(n, ".")
for i, j := 0, len(labels)-1; i < j; i, j = i+1, j-1 {
labels[i], labels[j] = labels[j], labels[i]
}
// A wildcard sorts right after the name it belongs to.
return strings.ReplaceAll(strings.Join(labels, "\x00"), "*", "\x01")
}
// MatchRules finds the rule entry named would apply to name (lowercase,
// without the final dot) in a rules zone with entries e: the name itself,
// or else the wildcard of its closest existing ancestor. ok is false when
// no entry applies.
func MatchRules(e map[string]string, name string) (owner, action string, ok bool) {
if act, found := e[name]; found {
return name, act, true
}
nodes := map[string]bool{}
for o := range e {
n := strings.TrimPrefix(o, "*.")
for {
nodes[n] = true
i := strings.IndexByte(n, '.')
if i < 0 {
break
}
n = n[i+1:]
}
}
if nodes[name] {
return "", "", false // the name exists without a rule of its own
}
for a := name; ; {
i := strings.IndexByte(a, '.')
if i < 0 {
return "", "", false
}
a = a[i+1:]
if nodes[a] {
if act, found := e["*."+a]; found {
return "*." + a, act, true
}
return "", "", false
}
}
}
// Compact drops entries another entry already covers (a name below a
// name whose subdomains are blocked). That keeps the zone small, and it
// matters for correctness: no name exists below a wildcard, so every
// wildcard applies to all names below it.
func Compact(l *List) {
for n := range l.Domains {
for a := n; ; {
i := strings.IndexByte(a, '.')
if i < 0 {
break
}
a = a[i+1:]
if l.Domains[a]&Below != 0 {
delete(l.Domains, n)
break
}
}
}
}
// FeedZone writes a list's policy zone to w and returns the number of
// records and a hash of them (the serial isn't part of the hash, so it
// tells whether the content changed). l must be compacted.
func FeedZone(w io.Writer, title string, serial uint32, l *List) (records int, hash string, err error) {
names := make([]string, 0, len(l.Domains))
for n := range l.Domains {
names = append(names, n)
}
sort.Strings(names)
bw := bufio.NewWriterSize(w, 256*1024)
h := sha256.New()
if _, err := bw.WriteString(header(title, serial)); err != nil {
return 0, "", err
}
write := func(owner string) error {
line := owner + "\tCNAME\t.\n"
h.Write([]byte(line))
records++
_, err := bw.WriteString(line)
return err
}
for _, n := range names {
f := l.Domains[n]
if f&Exact != 0 {
if err := write(n); err != nil {
return 0, "", err
}
}
if f&Below != 0 {
if err := write("*." + n); err != nil {
return 0, "", err
}
}
}
if err := bw.Flush(); err != nil {
return 0, "", err
}
return records, hex.EncodeToString(h.Sum(nil)), nil
}
// MatchFeed reports which entry of a list's zone file blocks name: the
// name itself, or a wildcard of one of its ancestors (the closest).
// Lists are compacted, so no name exists below a wildcard and the
// closest wildcard ancestor is the one named uses.
func MatchFeed(path, name string) (owner string, ok bool, err error) {
want := map[string]int{name: 0}
depth := 1
for a := name; ; depth++ {
i := strings.IndexByte(a, '.')
if i < 0 {
break
}
a = a[i+1:]
want["*."+a] = depth
}
f, err := os.Open(path)
if err != nil {
return "", false, err
}
defer f.Close()
best := -1
sc := bufio.NewScanner(f)
for sc.Scan() {
line := sc.Text()
o, _, found := strings.Cut(line, "\t")
if !found {
continue
}
if d, hit := want[o]; hit && (best < 0 || d < best) {
best, owner = d, o
}
}
return owner, best >= 0, sc.Err()
}
+84
View File
@@ -427,3 +427,87 @@ func classify(name, grammar string) (string, []string) {
}
return KindRaw, nil
}
// listParam matches an optional parameter before a statement's list, such
// as "[ port <integer> ]" in allow-transfer's grammar.
var listParam = regexp.MustCompile(`\[ ([a-z0-9-]+) `)
// WrapList adds the braces to a list value given without them, for
// statements that end in a list ("allow-transfer [ port <integer> ] {
// <address_match_element>; ... }"): "10.0.0.53" becomes "{ 10.0.0.53; }",
// "port 5353 10.0.0.53; key xfer" becomes "port 5353 { 10.0.0.53; key
// xfer; }". Elements are separated by ";", "," or new lines, or by spaces
// when they are all addresses. Values with a brace, and values of other
// statements, are returned as they are.
func (st *Statement) WrapList(v string) string {
g, _, _ := strings.Cut(st.Grammar, "\n")
open := strings.Index(g, " {")
if strings.Contains(v, "{") || open < 0 || !strings.HasSuffix(g, "; ... };") {
return v
}
params := map[string]bool{}
for _, m := range listParam.FindAllStringSubmatch(g[:open], -1) {
params[m[1]] = true
}
fields := strings.Fields(v)
i := 0
for i+1 < len(fields) && params[strings.ToLower(fields[i])] {
i += 2
}
head := strings.Join(fields[:i], " ")
rest := strings.Join(fields[i:], " ")
var elems []string
if !strings.ContainsAny(rest, ";,\n") {
elems = spaceElements(fields[i:])
} else {
for _, e := range strings.FieldsFunc(rest, func(r rune) bool { return r == ';' || r == ',' || r == '\n' }) {
if e = strings.TrimSpace(e); e != "" {
elems = append(elems, e)
}
}
}
if len(elems) == 0 {
return v
}
list := "{ " + strings.Join(elems, "; ") + "; }"
if head != "" {
return head + " " + list
}
return list
}
// spaceElements splits list elements separated by spaces: "key NAME"
// is one element, and "port N" and "tls NAME" belong to the address
// before them (forwarders { 192.0.2.1 port 53; }).
func spaceElements(fields []string) []string {
var out []string
for i := 0; i < len(fields); i++ {
f := fields[i]
switch strings.ToLower(f) {
case "key", "!key", "!":
if i+1 < len(fields) {
f += " " + fields[i+1]
i++
}
if f[0] == '!' && strings.HasPrefix(strings.ToLower(f), "! key") && i+1 < len(fields) {
f += " " + fields[i+1]
i++
}
case "-":
// A range with spaces around the dash: 10.0.0.1 - 10.0.0.9.
if len(out) > 0 && i+1 < len(fields) {
out[len(out)-1] += " - " + fields[i+1]
i++
continue
}
case "port", "tls":
if len(out) > 0 && i+1 < len(fields) {
out[len(out)-1] += " " + f + " " + fields[i+1]
i++
continue
}
}
out = append(out, f)
}
return out
}
+25
View File
@@ -77,3 +77,28 @@ func TestCatalog(t *testing.T) {
t.Errorf("zone types = %s", types)
}
}
func TestWrapList(t *testing.T) {
c := Default()
for _, tc := range []struct{ scope, name, in, want string }{
{ScopeOptions, "allow-transfer", "10.0.10.53", "{ 10.0.10.53; }"},
{ScopeOptions, "allow-transfer", "localhost 10.0.10.53 key xfer ! key old !10.0.0.1", "{ localhost; 10.0.10.53; key xfer; ! key old; !10.0.0.1; }"},
{ScopeOptions, "forwarders", "1.1.1.1 port 853 tls cf 9.9.9.9", "{ 1.1.1.1 port 853 tls cf; 9.9.9.9; }"},
{ScopeOptions, "allow-transfer", "localhost; 10.0.10.53", "{ localhost; 10.0.10.53; }"},
{ScopeOptions, "allow-transfer", "port 5353 key xfer, !10.0.0.1", "port 5353 { key xfer; !10.0.0.1; }"},
{ScopeOptions, "allow-transfer", "{ any; }", "{ any; }"},
{ScopeOptions, "allow-query", "10.0.0.0/8 192.168.1.0/24", "{ 10.0.0.0/8; 192.168.1.0/24; }"},
{ScopeOptions, "forwarders", "1.1.1.1 9.9.9.9", "{ 1.1.1.1; 9.9.9.9; }"},
{ScopeOptions, "listen-on", "port 53 10.0.10.5", "port 53 { 10.0.10.5; }"},
{ScopeOptions, "recursion", "yes", "yes"},
{ScopeOptions, "directory", `"/var/cache/bind"`, `"/var/cache/bind"`},
} {
st, ok := c.Lookup(tc.scope, tc.name)
if !ok {
t.Fatalf("%s not found", tc.name)
}
if got := st.WrapList(tc.in); got != tc.want {
t.Errorf("%s %q = %q, want %q", tc.name, tc.in, got, tc.want)
}
}
}
+28 -5
View File
@@ -30,8 +30,14 @@ type Client struct {
// Changeset, if set, stages changes into this changeset instead of
// applying them.
Changeset int64
DryRun bool
Force bool
// CheckChangeset, if set, runs once before Changeset is first used,
// and its error stops the request. The CLI checks a changeset taken
// from JARL_CHANGESET with it.
CheckChangeset func(ctx context.Context) error
csChecked bool
csErr error
DryRun bool
Force bool
// Preflight lets only requests through that can't change anything:
// reads and dry runs. Running a command on several servers uses it to
// check the command everywhere before running it anywhere.
@@ -243,6 +249,19 @@ func (c *Client) Get(ctx context.Context, path string, query url.Values, out any
return c.Do(ctx, http.MethodGet, path, query, nil, out)
}
// 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
// Force.
func (c *Client) Mutate(ctx context.Context, method, path string, query url.Values, body any) (*api.MutationResponse, error) {
@@ -250,8 +269,12 @@ func (c *Client) Mutate(ctx context.Context, method, path string, query url.Valu
for k, v := range query {
q[k] = v
}
if c.Changeset > 0 {
q.Set("changeset", fmt.Sprint(c.Changeset))
cs, err := c.UseChangeset(ctx)
if err != nil {
return &api.MutationResponse{}, err
}
if cs > 0 {
q.Set("changeset", fmt.Sprint(cs))
}
if c.DryRun {
q.Set("dry_run", "1")
@@ -260,7 +283,7 @@ func (c *Client) Mutate(ctx context.Context, method, path string, query url.Valu
q.Set("force", "1")
}
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
}
+121 -14
View File
@@ -4,6 +4,7 @@ package config
import (
"bytes"
"fmt"
"net"
"net/netip"
"net/url"
"os"
@@ -25,18 +26,89 @@ type Config struct {
TrustedProxies []string `yaml:"trusted_proxies"`
// Socket is a local unix socket for the API, where root and jarld's
// own user need no token ("" for none).
Socket string `yaml:"socket"`
Database string `yaml:"database"`
StagingDir string `yaml:"staging_dir"`
Log Log `yaml:"log"`
Session Session `yaml:"session"`
TOTP TOTP `yaml:"totp"`
BIND BIND `yaml:"bind"`
Audit Audit `yaml:"audit"`
Backup Backup `yaml:"backup"`
Stats Stats `yaml:"stats"`
ACME ACME `yaml:"acme"`
Porkbun Porkbun `yaml:"porkbun"`
Socket string `yaml:"socket"`
Database string `yaml:"database"`
StagingDir string `yaml:"staging_dir"`
Log Log `yaml:"log"`
Session Session `yaml:"session"`
TOTP TOTP `yaml:"totp"`
BIND BIND `yaml:"bind"`
Audit Audit `yaml:"audit"`
Backup Backup `yaml:"backup"`
Stats Stats `yaml:"stats"`
ACME ACME `yaml:"acme"`
Porkbun Porkbun `yaml:"porkbun"`
Blocking Blocking `yaml:"blocking"`
Metrics Metrics `yaml:"metrics"`
}
// Metrics configures the Prometheus endpoint /metrics (docs/metrics.md).
// A scrape is allowed from an address in Allow, or with Token as a bearer
// token; root and jarld's own user may also scrape on the local socket.
type Metrics struct {
Enabled bool `yaml:"enabled"`
// Listen are extra addresses that serve only /metrics, over plain
// HTTP. /metrics is always served on the main listeners too.
Listen []string `yaml:"listen"`
// Allow are the addresses or prefixes that may scrape without a token.
Allow []string `yaml:"allow"`
// Token, when set, lets any address that sends it as
// "Authorization: Bearer TOKEN" scrape.
Token string `yaml:"token"`
}
func (m *Metrics) check() error {
if !m.Enabled {
return nil
}
for _, a := range m.Listen {
if _, port, err := net.SplitHostPort(a); err != nil || port == "" {
return fmt.Errorf("listen %q must be ADDRESS:PORT, e.g. 0.0.0.0:9450", a)
}
}
for _, p := range m.Allow {
if _, err := ParsePrefix(p); err != nil {
return fmt.Errorf("allow: %v", err)
}
}
if m.Token != "" && (len(m.Token) < 16 || strings.ContainsAny(m.Token, " \t\r\n")) {
return fmt.Errorf("token must be at least 16 characters, without spaces (e.g. from 'openssl rand -hex 24')")
}
if len(m.Allow) == 0 && m.Token == "" {
return fmt.Errorf("set allow, token or both, or nobody can scrape")
}
return nil
}
// AllowPrefixes returns the parsed allow list.
func (m *Metrics) AllowPrefixes() []netip.Prefix {
var out []netip.Prefix
for _, p := range m.Allow {
if pf, err := ParsePrefix(p); err == nil {
out = append(out, pf.Masked())
}
}
return out
}
// Blocking configures blocklist downloads (docs/blocking.md). The lists
// and rules themselves are configuration in the database.
type Blocking struct {
// Refresh is how often each list is checked for changes; a list is
// only downloaded again when it changed.
Refresh Duration `yaml:"refresh"`
MaxDownloadMB int `yaml:"max_download_mb"`
MaxEntries int `yaml:"max_entries"`
}
func (b *Blocking) check() error {
if b.Refresh < Duration(time.Hour) {
return fmt.Errorf("refresh must be at least 1h (list hosts ask not to be polled more often)")
}
if b.MaxDownloadMB < 1 || b.MaxEntries < 1 {
return fmt.Errorf("max_download_mb and max_entries must be at least 1")
}
return nil
}
// Porkbun lets Jarl manage domains' name servers, glue records and DNSSEC
@@ -300,8 +372,10 @@ func Default() *Config {
Retention: StatsRetention{Minute: Duration(7 * 24 * time.Hour), Hour: Duration(90 * 24 * time.Hour), Day: Duration(730 * 24 * time.Hour)},
TopN: 100,
},
ACME: ACME{HTTPReq: HTTPReq{RequireHTTPS: true}},
Backup: Backup{Dir: "/var/backups/jarl", Retention: Duration(30 * 24 * time.Hour), KeepAtLeast: 7, WarnAfter: Duration(2 * 24 * time.Hour)},
ACME: ACME{HTTPReq: HTTPReq{RequireHTTPS: true}},
Blocking: Blocking{Refresh: Duration(24 * time.Hour), MaxDownloadMB: 64, MaxEntries: 1_000_000},
Metrics: Metrics{Allow: []string{"127.0.0.1", "::1"}},
Backup: Backup{Dir: "/var/backups/jarl", Retention: Duration(30 * 24 * time.Hour), KeepAtLeast: 7, WarnAfter: Duration(2 * 24 * time.Hour)},
}
}
@@ -369,6 +443,12 @@ func (c *Config) check() error {
if err := c.Porkbun.check(); err != nil {
return fmt.Errorf("porkbun: %w", err)
}
if err := c.Blocking.check(); err != nil {
return fmt.Errorf("blocking: %w", err)
}
if err := c.Metrics.check(); err != nil {
return fmt.Errorf("metrics: %w", err)
}
switch c.Log.Format {
case "text", "json":
default:
@@ -600,4 +680,31 @@ porkbun:
api_key: "" # pk1_...
secret_api_key: "" # sk1_...
domains: [] # only these domains (default: any Jarl zone the key reaches)
# Downloading blocklists (docs/blocking.md). The lists and rules are set up
# in the web UI or with 'jarl blocklist' and 'jarl block'; this only sets
# how jarld downloads them.
blocking:
refresh: 24h # check each list for changes this often (at least 1h)
max_download_mb: 64 # refuse larger downloads
max_entries: 1000000 # refuse lists with more domains
# Prometheus metrics at /metrics (docs/metrics.md): named's counters and
# Jarl's own (blocking, zones, views), plus health gauges to alert on
# (named up, validation problems, blocklist errors, backup age, DNSSEC
# steps, token expiry). Off by default. /metrics is served on the
# addresses in 'listen' at the top of this file; a scrape is allowed from
# an address in 'allow' below, or with the token. Query counters need
# stats.enabled.
metrics:
enabled: false
# Extra addresses that serve only /metrics, over plain HTTP, e.g.
# ["0.0.0.0:9450"] for a Prometheus server elsewhere on the network.
listen: []
# Addresses or prefixes that may scrape without a token.
allow: ["127.0.0.1", "::1"]
# A bearer token (at least 16 characters) that lets any address scrape:
# in Prometheus, 'authorization: { credentials: TOKEN }'. Keep this file
# readable by root and jarld only when you set it.
token: ""
`
+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)
}
}
}
+299
View File
@@ -0,0 +1,299 @@
package engine
import (
"context"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strconv"
"strings"
"sync/atomic"
"testing"
"time"
"jarl/internal/blocking"
"jarl/internal/model"
)
func blockRule(name, action string, exact bool, views ...string) Request {
in := BlockRuleInput{Name: &name, Action: &action, Exact: &exact}
if len(views) > 0 {
in.Views = &views
}
return NewRequest(OpBlockRuleCreate, 0, in)
}
func blocklist(name, url string, views ...string) Request {
in := BlocklistInput{Name: &name, URL: &url}
if len(views) > 0 {
in.Views = &views
}
return NewRequest(OpBlocklistCreate, 0, in)
}
// listServer serves a blocklist with an ETag, and counts the downloads
// that sent content.
type listServer struct {
body atomic.Value
full atomic.Int32
calls atomic.Int32
}
func newListServer(t *testing.T, body string) (*listServer, *httptest.Server) {
ls := &listServer{}
ls.body.Store(body)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ls.calls.Add(1)
b := ls.body.Load().(string)
etag := `"` + string(rune('a'+len(b)%26)) + "x" + string(rune('a'+len(b)/26%26)) + `"`
if r.Header.Get("If-None-Match") == etag {
w.WriteHeader(http.StatusNotModified)
return
}
w.Header().Set("ETag", etag)
ls.full.Add(1)
w.Write([]byte(b))
}))
t.Cleanup(srv.Close)
return ls, srv
}
func TestBlockingRender(t *testing.T) {
env := newEnv(t)
setupZone(env)
env.submit(env.admin,
NewRequest(OpViewCreate, 0, ViewInput{Name: model.Ptr("internal"), MatchClients: &[]string{"10.0.0.0/8"}, AdoptZones: true}),
NewRequest(OpViewCreate, 0, ViewInput{Name: model.Ptr("external")}),
)
st := env.state()
ext := st.ViewByName("external")
env.submit(env.admin, NewRequest(OpOptionCreate, 0, OptionInput{Scope: model.Ptr(model.ScopeView), ScopeID: &ext.ID, Name: model.Ptr("recursion"), Value: model.Ptr("no")}))
_, srv := newListServer(t, "||ads.example^\n")
res := env.submit(env.admin,
blocklist("ads", srv.URL+"/list.txt"),
blockRule("tracker.example", "block", false),
blockRule("cdn.tracker.example", "allow", true),
)
if res.Ops[1].Summary != "block tracker.example (and below)" || res.Ops[2].Summary != "allow cdn.tracker.example" {
t.Errorf("summaries: %q, %q", res.Ops[1].Summary, res.Ops[2].Summary)
}
conf, _ := os.ReadFile(filepath.Join(env.dir, "etc/named.conf"))
c := string(conf)
// Automatic views: only internal offers recursion.
internal := c[strings.Index(c, `view "internal"`):strings.Index(c, `view "external"`)]
for _, want := range []string{
"response-policy {\n\t\tzone \"rules.jarl.invalid\" min-update-interval 0 log no;\n\t\tzone \"ads.blocklist.jarl.invalid\" min-update-interval 0 log no;\n\t} qname-wait-recurse no break-dnssec yes;",
`zone "rules.jarl.invalid" {`,
"allow-query { none; };",
} {
if !strings.Contains(internal, want) {
t.Errorf("view internal lacks %q:\n%s", want, internal)
}
}
if strings.Contains(c[strings.Index(c, `view "external"`):], "response-policy") {
t.Error("view external, without recursion, got blocking")
}
rules, err := os.ReadFile(filepath.Join(env.dir, "zones/internal/rules.jarl.invalid.rpz"))
if err != nil || !strings.Contains(string(rules), "cdn.tracker.example\tCNAME\trpz-passthru.\n*.cdn.tracker.example\tCNAME\t.\n") {
t.Errorf("rules zone: %v\n%s", err, rules)
}
// The list's file exists as an empty policy zone until downloaded.
b := env.state().BlocklistByName("ads")
listFile := filepath.Join(env.dir, "cache/blocklists/list-"+strconv.FormatInt(b.ID, 10)+".rpz")
if f, err := os.ReadFile(listFile); err != nil || !strings.Contains(string(f), "not downloaded yet") {
t.Errorf("placeholder: %v %s", err, f)
}
// A view a list or rule names can't be deleted.
res = env.submit(env.admin, NewRequest(OpBlocklistUpdate, b.ID, BlocklistInput{Views: &[]string{"external"}}))
if len(res.Problems) != 1 || res.Problems[0].Code != "blocking_no_recursion" {
t.Errorf("a list in a view without recursion: %+v", res.Problems)
}
if e := env.submitErr(env.admin, NewRequest(OpViewDelete, ext.ID, DeleteInput{Cascade: true})); e.Kind != KindConflict || !strings.Contains(e.Message, "blocklist ads") {
t.Errorf("deleting a used view: %+v", e)
}
st = env.state()
// A second rule for the same name and view is refused.
if e := env.submitErr(env.admin, blockRule("tracker.example", "allow", true)); e.Kind != KindValidation || !strings.Contains(e.Message, "two rules for tracker.example") {
t.Errorf("duplicate rule: %+v", e)
}
// So is a response-policy option where Jarl writes one.
in := st.ViewByName("internal")
if e := env.submitErr(env.admin, NewRequest(OpOptionCreate, 0, OptionInput{Scope: model.Ptr(model.ScopeView), ScopeID: &in.ID, Name: model.Ptr("response-policy"), Value: model.Ptr(`{ zone "x"; }`)})); e.Kind != KindValidation {
t.Errorf("response-policy option: %+v", e)
}
// Bad input.
for _, req := range []Request{blocklist("Bad Name", srv.URL), blocklist("ok", "ftp://x/list"), blockRule("com", "block", false), blockRule("a.example", "deny", false), blockRule("a.example", "block", false, "nope")} {
if e := env.submitErr(env.admin, req); e.Kind != KindBadRequest {
t.Errorf("%s: %+v", req.Data, e)
}
}
}
func TestBlocklistRefresh(t *testing.T) {
env := newEnv(t)
setupZone(env)
ls, srv := newListServer(t, "# test\n||ads.example^\n0.0.0.0 tracker.example\nnot a domain line\n")
env.submit(env.admin, blocklist("ads", srv.URL), blockRule("good.ads.example", "allow", false))
b := env.state().BlocklistByName("ads")
ctx := context.Background()
env.e.RefreshDue(ctx)
s, _ := env.st.BlocklistStatuses(ctx)
st := s[b.ID]
if st == nil || st.Error != "" || st.Entries != 2 || st.Records != 3 || st.Skipped != 1 || st.UpdatedAt == nil {
t.Fatalf("status after the first download: %+v", st)
}
file := env.e.cfg.Paths.BlocklistFile(b.ID)
if f, _ := os.ReadFile(file); !strings.Contains(string(f), "*.ads.example\tCNAME\t.\n") || !strings.Contains(string(f), "tracker.example\tCNAME\t.\n") {
t.Errorf("list zone:\n%s", f)
}
// Not due again: nothing is fetched.
env.e.RefreshDue(ctx)
if ls.calls.Load() != 1 {
t.Errorf("downloads after a second RefreshDue: %d", ls.calls.Load())
}
// A cleared cache leaves an empty placeholder: downloaded again at once.
os.Remove(file)
env.e.RefreshDue(ctx)
if ls.calls.Load() != 2 {
t.Errorf("downloads after the file was lost: %d", ls.calls.Load())
}
if f, _ := os.ReadFile(file); !strings.Contains(string(f), "*.ads.example\tCNAME\t.\n") {
t.Errorf("list zone after the file was lost:\n%s", f)
}
// Unchanged on the server: 304, no rewrite.
firstHash := st.SHA256
fullBefore := ls.full.Load()
env.e.RefreshBlocklist(ctx, b.ID, false)
if ls.full.Load() != fullBefore {
t.Errorf("a conditional download fetched the list again")
}
// Changed: rewritten.
ls.body.Store("||ads.example^\n||more.example^\n")
st, err := env.e.RefreshBlocklist(ctx, b.ID, false)
if err != nil || st.Entries != 2 || st.Records != 4 || st.SHA256 == firstHash {
t.Errorf("after a change: %+v, %v", st, err)
}
// A broken download keeps the previous version and reports why.
before, _ := os.ReadFile(file)
ls.body.Store("<html><body>Not found</body></html>\n")
st, err = env.e.RefreshBlocklist(ctx, b.ID, true)
if err == nil || !strings.Contains(st.Error, "no domains found") {
t.Errorf("garbage: %+v, %v", st, err)
}
if after, _ := os.ReadFile(file); string(after) != string(before) {
t.Error("a failed update changed the list's file")
}
// A failed attempt is retried after RetryAfter, not at every check.
calls := ls.calls.Load()
env.e.RefreshDue(ctx)
if ls.calls.Load() != calls {
t.Error("a failed list was tried again before RetryAfter")
}
// Too big.
env.e.Limits.MaxBytes = 10
ls.body.Store("||ads.example^\n||more.example^\n||third.example^\n")
if st, _ = env.e.RefreshBlocklist(ctx, b.ID, true); !strings.Contains(st.Error, "larger than") {
t.Errorf("size limit: %+v", st)
}
env.e.Limits = DefaultBlocklistLimits()
env.e.RefreshBlocklist(ctx, b.ID, true)
// What blocks what.
check := func(name string) blocking.Match {
m, err := env.e.CheckBlocking(env.state(), name)
if err != nil || len(m) != 1 {
t.Fatalf("check %s: %+v %v", name, m, err)
}
return m[0]
}
if m := check("x.ads.example"); m.Result != "blocked" || m.List != "ads" || m.Entry != "*.ads.example" {
t.Errorf("x.ads.example: %+v", m)
}
if m := check("a.good.ads.example"); m.Result != "allowed" || m.RuleID == 0 || m.Entry != "*.good.ads.example" {
t.Errorf("a.good.ads.example: %+v", m)
}
if m := check("fine.example"); m.Result != "none" {
t.Errorf("fine.example: %+v", m)
}
// Deleting the list removes its file and status.
env.submit(env.admin, NewRequest(OpBlocklistDelete, b.ID, nil))
env.e.RefreshDue(ctx)
if _, err := os.Stat(file); !os.IsNotExist(err) {
t.Errorf("the deleted list's file is still there: %v", err)
}
if s, _ := env.st.BlocklistStatuses(ctx); s[b.ID] != nil {
t.Error("the deleted list's status is still there")
}
}
func TestBlockingPause(t *testing.T) {
env := newEnv(t)
setupZone(env)
env.submit(env.admin, blocklist("ads", "https://example.org/list"))
rulesFile := filepath.Join(env.dir, "zones/_default/rules.jarl.invalid.rpz")
if _, err := os.Stat(rulesFile); !os.IsNotExist(err) {
t.Fatal("a rules zone without rules or a pause")
}
until := env.e.Now().Add(10 * time.Minute)
res := env.submit(env.admin, NewRequest(OpBlockingPause, 0, BlockingPauseInput{Until: &until}))
if !strings.HasPrefix(res.Ops[0].Summary, "pause blocking until ") {
t.Errorf("summary = %q", res.Ops[0].Summary)
}
f, err := os.ReadFile(rulesFile)
if err != nil || !strings.HasSuffix(string(f), "NS\tlocalhost.\n*\tCNAME\trpz-passthru.\n") {
t.Fatalf("paused rules zone: %v\n%s", err, f)
}
conf, _ := os.ReadFile(filepath.Join(env.dir, "etc/named.conf"))
if !strings.Contains(string(conf), "zone \"rules.jarl.invalid\" min-update-interval 0 log no;\n\t\tzone \"ads.") {
t.Errorf("the paused rules zone must come first:\n%s", conf)
}
// Not yet time: nothing happens.
if got := env.e.ResumeBlocking(context.Background()); !got.Equal(until) {
t.Errorf("ResumeBlocking before the end = %v", got)
}
// Time is up: resumed, and the rules zone is gone again.
env.submit(env.admin, blockRule("ads.example", "block", false))
env.e.Now = func() time.Time { return until.Add(time.Second) }
if got := env.e.ResumeBlocking(context.Background()); !got.IsZero() || env.state().Settings.BlockingPausedUntil != nil {
t.Fatalf("after the pause: %v, %v", got, env.state().Settings.BlockingPausedUntil)
}
if f, _ := os.ReadFile(rulesFile); !strings.Contains(string(f), "*.ads.example\tCNAME\t.\n") || strings.Contains(string(f), "passthru") {
t.Errorf("rules zone after the pause:\n%s", f)
}
// Reverting a pause resumes blocking.
until = env.e.Now().Add(time.Hour)
res = env.submit(env.admin, NewRequest(OpBlockingPause, 0, BlockingPauseInput{Until: &until}))
rev, err := env.e.Revert(context.Background(), res.ChangesetID, env.admin)
if err != nil {
t.Fatal(err)
}
if _, err := env.e.Apply(context.Background(), rev.ID, env.admin, ApplyOptions{}); err != nil || env.state().Settings.BlockingPausedUntil != nil {
t.Errorf("revert of a pause: %v, %v", err, env.state().Settings.BlockingPausedUntil)
}
// Until resumed: jarld never ends it, a resume does.
res = env.submit(env.admin, NewRequest(OpBlockingPause, 0, BlockingPauseInput{Indefinitely: true}))
if res.Ops[0].Summary != "pause blocking until resumed" || !env.state().Settings.BlockingPaused() {
t.Errorf("indefinite pause: %q", res.Ops[0].Summary)
}
if f, _ := os.ReadFile(rulesFile); !strings.Contains(string(f), "*\tCNAME\trpz-passthru.\n") {
t.Errorf("rules zone while paused indefinitely:\n%s", f)
}
env.e.Now = func() time.Time { return until.Add(1000 * time.Hour) }
if got := env.e.ResumeBlocking(context.Background()); !got.IsZero() || !env.state().Settings.BlockingPausedIndefinitely {
t.Errorf("an indefinite pause ended by itself: %v", got)
}
env.submit(env.admin, NewRequest(OpBlockingPause, 0, BlockingPauseInput{}))
if env.state().Settings.BlockingPaused() {
t.Error("resume didn't end an indefinite pause")
}
// At most a day.
long := env.e.Now().Add(48 * time.Hour)
if e := env.submitErr(env.admin, NewRequest(OpBlockingPause, 0, BlockingPauseInput{Until: &long})); e.Kind != KindBadRequest {
t.Errorf("a two-day pause: %+v", e)
}
}
+409
View File
@@ -0,0 +1,409 @@
package engine
import (
"context"
"errors"
"fmt"
"io"
"io/fs"
"net/http"
"os"
"path/filepath"
"slices"
"strconv"
"strings"
"time"
"jarl/internal/blocking"
"jarl/internal/model"
"jarl/internal/render"
"jarl/internal/version"
)
// BlocklistLimits bound blocklist downloads (blocking in jarl.yaml).
type BlocklistLimits struct {
Refresh time.Duration // how often a list is checked for changes
RetryAfter time.Duration // after a failed attempt
MaxBytes int64 // per download
MaxEntries int // domains per list
Timeout time.Duration // per download
}
// DefaultBlocklistLimits are the limits without configuration.
func DefaultBlocklistLimits() BlocklistLimits {
return BlocklistLimits{Refresh: 24 * time.Hour, RetryAfter: time.Hour, MaxBytes: 64 << 20, MaxEntries: 1_000_000, Timeout: 2 * time.Minute}
}
// KickBlocklists asks the download loop to look for work now (a list was
// added or changed).
func (e *Engine) KickBlocklists() {
select {
case e.blKick <- struct{}{}:
default:
}
}
// BlocklistKicks delivers KickBlocklists' signals to the download loop.
func (e *Engine) BlocklistKicks() <-chan struct{} { return e.blKick }
// ensureBlocklistFiles creates missing blocklist zone files as empty
// policy zones, so named can load its configuration before a list has
// been downloaded. Existing files are never touched.
func (e *Engine) ensureBlocklistFiles(paths []string) error {
var errs []error
for _, path := range paths {
if _, err := os.Stat(path); err == nil {
continue
}
if err := e.deploy.writeNew(path, []byte(blocking.Placeholder()), 0o640); err != nil && !errors.Is(err, fs.ErrExist) {
errs = append(errs, err)
}
}
return errors.Join(errs...)
}
// RefreshDue downloads the blocklists that are due: never tried, URL
// changed, file missing or empty (the cache was cleared), last check older
// than Refresh, or a failed attempt older than RetryAfter. Lists that are
// disabled or apply to no view are skipped. It also removes the files and
// status of deleted lists.
func (e *Engine) RefreshDue(ctx context.Context) {
st, err := e.store.LoadState(ctx)
if err != nil {
e.log.Warn("blocklists: loading the configuration failed", "err", err)
return
}
statuses, err := e.store.BlocklistStatuses(ctx)
if err != nil {
e.log.Warn("blocklists: loading their status failed", "err", err)
return
}
out := render.Render(st, e.cfg.Paths, e.extras(ctx))
if err := e.ensureBlocklistFiles(out.Blocklists); err != nil {
e.log.Warn("blocklists: creating empty zone files failed", "err", err)
}
e.cleanBlocklists(ctx, st, statuses)
now := e.Now()
for _, b := range st.SortedBlocklists() {
if b.Disabled || len(st.BlockingViews(b.ViewIDs)) == 0 {
continue
}
s := statuses[b.ID]
due := s == nil || s.URL != b.URL || s.CheckedAt == nil
if !due {
wait := e.Limits.Refresh
if s.Error != "" {
wait = e.Limits.RetryAfter
}
due = now.Sub(*s.CheckedAt) >= wait || s.Error == "" && isPlaceholder(e.cfg.Paths.BlocklistFile(b.ID))
}
if !due {
continue
}
if ctx.Err() != nil {
return
}
if _, err := e.RefreshBlocklist(ctx, b.ID, false); err != nil && ctx.Err() == nil {
e.log.Warn("blocklist update failed; named keeps using the previous version", "list", b.Name, "err", err)
}
}
}
// isPlaceholder reports whether a list's zone file is missing or still the
// empty policy zone written before its download.
func isPlaceholder(path string) bool {
f, err := os.Open(path)
if err != nil {
return true
}
defer f.Close()
head := make([]byte, len(blocking.Placeholder()))
n, _ := io.ReadFull(f, head)
return string(head[:n]) == blocking.Placeholder()
}
// cleanBlocklists removes the zone files and status of deleted lists.
func (e *Engine) cleanBlocklists(ctx context.Context, st *model.State, statuses map[int64]*model.BlocklistStatus) {
for id := range statuses {
if st.Blocklists[id] == nil {
e.store.DeleteBlocklistStatus(ctx, id)
}
}
dir := e.cfg.Paths.BlocklistsDir()
entries, err := os.ReadDir(dir)
if err != nil {
return
}
for _, ent := range entries {
n := ent.Name()
idText, ok := strings.CutPrefix(strings.TrimSuffix(n, ".rpz"), "list-")
id, err := strconv.ParseInt(idText, 10, 64)
if !ok || !strings.HasSuffix(n, ".rpz") || err != nil || st.Blocklists[id] != nil {
continue
}
if err := os.Remove(filepath.Join(dir, n)); err == nil {
e.log.Info("removed the zone file of a deleted blocklist", "file", n)
}
}
}
// errNotModified means the server said the list hasn't changed.
var errNotModified = errors.New("not modified")
// RefreshBlocklist downloads a list now and, when its content changed,
// replaces its zone file and makes named reload it. force downloads even
// when the server says nothing changed. A failure keeps the previous
// version. It returns the new status.
func (e *Engine) RefreshBlocklist(ctx context.Context, id int64, force bool) (*model.BlocklistStatus, error) {
e.blMu.Lock()
defer e.blMu.Unlock()
st, err := e.store.LoadState(ctx)
if err != nil {
return nil, err
}
b := st.Blocklists[id]
if b == nil {
return nil, errf(KindNotFound, "blocklist %d does not exist", id)
}
statuses, err := e.store.BlocklistStatuses(ctx)
if err != nil {
return nil, err
}
prev := statuses[id]
if prev == nil {
prev = &model.BlocklistStatus{ID: id}
}
file := e.cfg.Paths.BlocklistFile(id)
now := e.Now()
next := *prev
next.CheckedAt = &now
conditional := !force && prev.URL == b.URL && prev.SHA256 != "" && !isPlaceholder(file)
err = e.downloadBlocklist(ctx, st, b, &next, conditional, file)
next.URL, next.Error = b.URL, ""
if err != nil && !errors.Is(err, errNotModified) {
next.Error = err.Error()
}
if serr := e.store.SetBlocklistStatus(ctx, &next); serr != nil && err == nil {
err = serr
}
if errors.Is(err, errNotModified) {
err = nil
}
return &next, err
}
// downloadBlocklist fetches, parses, checks and installs a list, filling
// in s. It returns errNotModified when there is nothing to do.
func (e *Engine) downloadBlocklist(ctx context.Context, st *model.State, b *model.Blocklist, s *model.BlocklistStatus, conditional bool, file string) error {
ctx, cancel := context.WithTimeout(ctx, e.Limits.Timeout)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, b.URL, nil)
if err != nil {
return err
}
req.Header.Set("User-Agent", "Jarl/"+version.Short()+" (blocklist download)")
if conditional {
if s.ETag != "" {
req.Header.Set("If-None-Match", s.ETag)
}
if s.LastModified != "" {
req.Header.Set("If-Modified-Since", s.LastModified)
}
}
client := e.HTTP
if client == nil {
client = http.DefaultClient
}
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("download failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusNotModified && conditional {
return errNotModified
}
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("download failed: the server answered %s", resp.Status)
}
zone := blocking.ListZone(b.Name)
body := &limitedReader{r: resp.Body, left: e.Limits.MaxBytes}
l, err := blocking.Parse(body, blocking.Limits{MaxEntries: e.Limits.MaxEntries, MaxNameLen: 253 - len(zone) - 3})
if body.over {
return fmt.Errorf("the list is larger than %d MB (blocking.max_download_mb in jarl.yaml)", e.Limits.MaxBytes>>20)
}
if errors.Is(err, blocking.ErrTooManyEntries) {
return fmt.Errorf("%v (blocking.max_entries in jarl.yaml)", err)
}
if err != nil {
return fmt.Errorf("download failed: %w", err)
}
if len(l.Domains) == 0 {
return fmt.Errorf("no domains found in %d lines: is this a blocklist in hosts, domains or Adblock format?", l.Lines)
}
entries, skipped := len(l.Domains), l.Skipped
blocking.Compact(l)
dir := filepath.Dir(file)
if err := e.deploy.EnsureDir(dir, e.deploy.dirMode(dir)); err != nil {
return err
}
tmp, err := os.CreateTemp(dir, ".list-*.rpz")
if err != nil {
return err
}
tmpName := tmp.Name()
defer os.Remove(tmpName) // after a successful rename, there's nothing to remove
records, hash, err := blocking.FeedZone(tmp, fmt.Sprintf("Blocklist %s from %s, %s", b.Name, b.URL, e.Now().UTC().Format(time.RFC3339)), uint32(e.Now().Unix()), l)
if cerr := tmp.Close(); err == nil {
err = cerr
}
if err != nil {
return err
}
s.ETag, s.LastModified = resp.Header.Get("ETag"), resp.Header.Get("Last-Modified")
s.Entries, s.Skipped = entries, skipped
if hash == s.SHA256 && s.URL == b.URL && !isPlaceholder(file) {
return errNotModified // same content: named keeps what it has
}
if e.tools != nil && !e.cfg.SkipCheck {
res, err := e.tools.CheckZone(ctx, zone, tmpName)
if err != nil {
return fmt.Errorf("running named-checkzone failed: %w", err)
}
if !res.OK {
msg := "named-checkzone rejected the list's zone"
for _, m := range res.Messages {
if m.Error {
msg += ": " + m.Text
break
}
}
return errors.New(msg)
}
}
if err := e.deploy.finish(tmpName, file, 0o640); err != nil {
return err
}
now := e.Now()
s.UpdatedAt, s.Records, s.SHA256 = &now, records, hash
e.log.Info("blocklist updated", "list", b.Name, "domains", entries, "records", records, "skipped_lines", skipped)
return e.reloadBlocklist(ctx, st, b)
}
// reloadBlocklist makes named load a list's new file in every view that
// uses it.
func (e *Engine) reloadBlocklist(ctx context.Context, st *model.State, b *model.Blocklist) error {
if e.cfg.NoReload || e.tools == nil {
return nil
}
if adopted, err := e.Adopted(); err != nil || !adopted {
return nil // the file is ready for when named loads Jarl's configuration
}
for _, z := range render.PolicyZones(st, e.cfg.Paths) {
if z.ListID != b.ID {
continue
}
if err := e.tools.ReloadZone(ctx, z.Name, z.View); err != nil {
return fmt.Errorf("the list was downloaded, but named didn't reload it: %w", err)
}
}
return nil
}
// limitedReader reads at most left bytes and notes when there was more.
type limitedReader struct {
r io.Reader
left int64
over bool
}
func (l *limitedReader) Read(p []byte) (int, error) {
if l.left <= 0 {
var one [1]byte
if n, _ := l.r.Read(one[:]); n > 0 {
l.over = true
}
return 0, io.EOF
}
if int64(len(p)) > l.left {
p = p[:l.left]
}
n, err := l.r.Read(p)
l.left -= int64(n)
return n, err
}
// CheckBlocking reports, for every view with blocking, whether name is
// blocked or allowed there, and by which rule or list. It looks names up
// the way named does: rules first, then the lists in order. The answer is
// for clients at the normal step; a list of a higher step that blocks the
// name says so (Match.Step), and clients at step off skip every list.
func (e *Engine) CheckBlocking(st *model.State, name string) ([]blocking.Match, error) {
n, err := blocking.NormalizeName(name)
if err != nil {
return nil, errf(KindBadRequest, "%v", err)
}
var out []blocking.Match
feeds := map[string]string{} // file -> matching entry ("" none)
var cur *blocking.Match
for _, z := range render.PolicyZones(st, e.cfg.Paths) {
if cur == nil || cur.View != z.View {
out = append(out, blocking.Match{View: z.View, Result: "none"})
cur = &out[len(out)-1]
}
if cur.Result != "none" || z.IsGate() {
continue // an earlier zone decided, or a gate (it never blocks)
}
if z.ListID == 0 {
owner, act, ok := blocking.MatchRules(blocking.RuleEntries(render.ViewRules(st, z.ViewID)), n)
if ok {
cur.Entry, cur.RuleID = owner, ruleFor(st, z.ViewID, owner)
cur.Result = map[string]string{blocking.ActionBlock: "blocked", blocking.ActionAllow: "allowed"}[act]
}
continue
}
entry, seen := feeds[z.File]
if !seen {
owner, ok, err := blocking.MatchFeed(z.File, n)
if err != nil && !errors.Is(err, fs.ErrNotExist) {
return nil, err
}
if ok {
entry = owner
}
feeds[z.File] = entry
}
if entry != "" {
b := st.Blocklists[z.ListID]
cur.Result, cur.Entry, cur.List = "blocked", entry, b.Name
if b.StepID != model.StepNormal {
cur.Step = st.StepName(b.StepID)
}
}
}
return out, nil
}
// ruleFor finds the rule behind a rules-zone entry: the rule for the
// entry's name, or (for entries added so the most specific rule wins) the
// closest rule above it that covers the names below it.
func ruleFor(st *model.State, viewID int64, owner string) int64 {
name := strings.TrimPrefix(owner, "*.")
wild := name != owner
var best *model.BlockRule
for _, r := range st.SortedBlockRules() {
if !slices.Contains(st.BlockingViews(r.ViewIDs), viewID) {
continue
}
if r.Name == name && (!wild || !r.Exact) {
return r.ID
}
if !r.Exact && strings.HasSuffix(name, "."+r.Name) && (best == nil || len(r.Name) > len(best.Name)) {
best = r
}
}
if best != nil {
return best.ID
}
return 0
}
+327
View File
@@ -0,0 +1,327 @@
package engine
import (
"context"
"encoding/json"
"fmt"
"net/url"
"regexp"
"slices"
"strings"
"time"
"jarl/internal/auth"
"jarl/internal/blocking"
"jarl/internal/model"
)
// ---- blocking: blocklists and rules (docs/blocking.md)
var blocklistNameRe = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]{0,38}[a-z0-9])?$`)
func blockingAction() auth.Action { return auth.Action{Resource: auth.ResBlocking, Write: true} }
// viewIDs resolves the views of a blocklist or rule input: names or IDs,
// in view order, without duplicates. nil, nil means "not given".
func viewIDs(st *model.State, names *[]string, ids *[]int64) ([]int64, error) {
if names == nil && ids == nil {
return nil, nil
}
want := map[int64]bool{}
if names != nil {
for _, n := range *names {
v := st.ViewByName(strings.TrimSpace(n))
if v == nil {
return nil, errf(KindBadRequest, "view %q does not exist", n)
}
want[v.ID] = true
}
}
if ids != nil {
for _, id := range *ids {
if st.Views[id] == nil {
return nil, errf(KindBadRequest, "view %d does not exist", id)
}
want[id] = true
}
}
out := []int64{}
for _, v := range st.OrderedViews() {
if want[v.ID] {
out = append(out, v.ID)
}
}
return out, nil
}
// CheckBlocklistURL checks a blocklist URL: http or https, with a host.
func CheckBlocklistURL(raw string) (string, error) {
raw = strings.TrimSpace(raw)
u, err := url.Parse(raw)
if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" {
return "", fmt.Errorf("%q is not an http or https URL", raw)
}
return raw, nil
}
func applyBlocklistPut(st *model.State, op model.Op, c *applyCtx) error {
if err := c.require(blockingAction()); err != nil {
return err
}
var in BlocklistInput
if err := decode(op.Data, &in); err != nil {
return err
}
var b *model.Blocklist
if op.Kind == OpBlocklistCreate {
if op.ID == 0 || st.Blocklists[op.ID] != nil {
return errf(KindConflict, "blocklist ID %d is already in use", op.ID)
}
if in.Name == nil || in.URL == nil {
return errf(KindBadRequest, "a blocklist needs a name and a URL")
}
b = &model.Blocklist{ID: op.ID, ViewIDs: []int64{}}
} else {
b = st.Blocklists[op.ID]
if b == nil {
return errf(KindNotFound, "blocklist %d does not exist", op.ID)
}
if err := checkVersion(op, b.Version, "blocklist "+b.Name); err != nil {
return err
}
}
if in.Name != nil {
name := strings.ToLower(strings.TrimSpace(*in.Name))
if !blocklistNameRe.MatchString(name) {
return errf(KindBadRequest, "blocklist name %q: use 1 to 40 lowercase letters, digits and hyphens, like hagezi-pro", *in.Name)
}
if other := st.BlocklistByName(name); other != nil && other.ID != b.ID {
return errf(KindConflict, "blocklist %s already exists", name)
}
b.Name = name
}
if in.URL != nil {
u, err := CheckBlocklistURL(*in.URL)
if err != nil {
return errf(KindBadRequest, "%v", err)
}
b.URL = u
}
ids, err := viewIDs(st, in.Views, in.ViewIDs)
if err != nil {
return err
}
if ids != nil {
b.ViewIDs = ids
}
if in.Disabled != nil {
b.Disabled = *in.Disabled
}
step, given, err := stepRef(st, in.Step, in.StepID)
if err != nil {
return err
}
if given {
if step == model.StepOff {
return errf(KindBadRequest, "a blocklist starts at normal or a step above it, not off")
}
b.StepID = step
}
if in.Comment != nil {
b.Comment = *in.Comment
}
b.Touch(c.user(), c.now)
st.Blocklists[b.ID] = b
return nil
}
func applyBlocklistDelete(st *model.State, op model.Op, c *applyCtx) error {
if err := c.require(blockingAction()); err != nil {
return err
}
b := st.Blocklists[op.ID]
if b == nil {
return errf(KindNotFound, "blocklist %d does not exist", op.ID)
}
if err := checkVersion(op, b.Version, "blocklist "+b.Name); err != nil {
return err
}
delete(st.Blocklists, b.ID)
return nil
}
func applyBlockRulePut(st *model.State, op model.Op, c *applyCtx) error {
if err := c.require(blockingAction()); err != nil {
return err
}
var in BlockRuleInput
if err := decode(op.Data, &in); err != nil {
return err
}
var r *model.BlockRule
if op.Kind == OpBlockRuleCreate {
if op.ID == 0 || st.BlockRules[op.ID] != nil {
return errf(KindConflict, "rule ID %d is already in use", op.ID)
}
if in.Name == nil {
return errf(KindBadRequest, "a rule needs a domain name")
}
r = &model.BlockRule{ID: op.ID, Action: model.BlockActionBlock, ViewIDs: []int64{}}
} else {
r = st.BlockRules[op.ID]
if r == nil {
return errf(KindNotFound, "rule %d does not exist", op.ID)
}
if err := checkVersion(op, r.Version, "rule "+r.Name); err != nil {
return err
}
}
if in.Name != nil {
n, err := blocking.NormalizeName(*in.Name)
if err != nil {
return errf(KindBadRequest, "%v", err)
}
r.Name = n
}
if in.Action != nil {
a := strings.ToLower(strings.TrimSpace(*in.Action))
if a != model.BlockActionBlock && a != model.BlockActionAllow {
return errf(KindBadRequest, "a rule's action is block or allow, not %q", *in.Action)
}
r.Action = a
}
if in.Exact != nil {
r.Exact = *in.Exact
}
ids, err := viewIDs(st, in.Views, in.ViewIDs)
if err != nil {
return err
}
if ids != nil {
r.ViewIDs = ids
}
if in.Comment != nil {
r.Comment = *in.Comment
}
r.Touch(c.user(), c.now)
st.BlockRules[r.ID] = r
return nil
}
func applyBlockRuleDelete(st *model.State, op model.Op, c *applyCtx) error {
if err := c.require(blockingAction()); err != nil {
return err
}
r := st.BlockRules[op.ID]
if r == nil {
return errf(KindNotFound, "rule %d does not exist", op.ID)
}
if err := checkVersion(op, r.Version, "rule "+r.Name); err != nil {
return err
}
delete(st.BlockRules, r.ID)
return nil
}
// blockingUsers lists the blocklists and rules that name a view.
func blockingUsers(st *model.State, viewID int64) []string {
var out []string
for _, b := range st.SortedBlocklists() {
if slices.Contains(b.ViewIDs, viewID) {
out = append(out, "blocklist "+b.Name)
}
}
for _, r := range st.SortedBlockRules() {
if slices.Contains(r.ViewIDs, viewID) {
out = append(out, ruleLabel(r))
}
}
for _, g := range st.SortedClientGroups() {
if slices.Contains(g.ViewIDs, viewID) {
out = append(out, "client group "+g.Name)
}
}
return out
}
// ruleLabel describes a rule: "block ads.example (and below)".
func ruleLabel(r *model.BlockRule) string {
s := r.Action + " " + r.Name
if !r.Exact {
s += " (and below)"
}
return s
}
// ---- expansion
func (x *expander) blocklistCreate(req Request) ([]model.Op, error) {
return x.simpleCreate(req, OpBlocklistCreate, func(data json.RawMessage) (string, error) {
var in BlocklistInput
if err := decode(data, &in); err != nil {
return "", err
}
if in.Name == nil || in.URL == nil {
return "", errf(KindBadRequest, "a blocklist needs a name and a URL")
}
return "add blocklist " + *in.Name + " (" + *in.URL + ")", nil
})
}
func (x *expander) blockRuleCreate(req Request) ([]model.Op, error) {
return x.simpleCreate(req, OpBlockRuleCreate, func(data json.RawMessage) (string, error) {
var in BlockRuleInput
if err := decode(data, &in); err != nil {
return "", err
}
if in.Name == nil {
return "", errf(KindBadRequest, "a rule needs a domain name")
}
r := &model.BlockRule{Name: strings.ToLower(strings.TrimSuffix(strings.TrimSpace(*in.Name), ".")), Action: model.BlockActionBlock}
if in.Action != nil {
r.Action = strings.ToLower(*in.Action)
}
if in.Exact != nil {
r.Exact = *in.Exact
}
return ruleLabel(r), nil
})
}
func applyBlockingPause(st *model.State, op model.Op, c *applyCtx) error {
if err := c.require(blockingAction()); err != nil {
return err
}
var in BlockingPauseInput
if err := decode(op.Data, &in); err != nil {
return err
}
if in.Until != nil && in.Until.Sub(c.now) > MaxBlockingPause+time.Minute {
return errf(KindBadRequest, "blocking can be paused for at most %s, or until it is resumed", MaxBlockingPause)
}
if in.Indefinitely && in.Until != nil {
return errf(KindBadRequest, "a pause is either until a time or until resumed, not both")
}
st.Settings.BlockingPausedUntil = in.Until
st.Settings.BlockingPausedIndefinitely = in.Indefinitely
return nil
}
// ResumeBlocking ends a pause whose time is up, and returns when the
// current pause ends (zero when blocking isn't paused).
func (e *Engine) ResumeBlocking(ctx context.Context) time.Time {
st, err := e.store.LoadState(ctx)
if err != nil || st.Settings.BlockingPausedUntil == nil {
return time.Time{}
}
until := *st.Settings.BlockingPausedUntil
if e.Now().Before(until) {
return until
}
if _, err := e.Submit(ctx, []Request{NewRequest(OpBlockingPause, 0, BlockingPauseInput{})}, auth.System, "system", "resume blocking after the pause", ApplyOptions{}); err != nil {
e.log.Warn("resuming blocking after the pause failed; it will be tried again", "err", err)
return e.Now().Add(time.Minute)
}
e.log.Info("blocking resumed after the pause")
return time.Time{}
}
+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)
}
}
+45
View File
@@ -183,6 +183,48 @@ func (d *Deployer) writeAtomic(path string, content []byte, mode fs.FileMode) er
return nil
}
// writeNew creates a file that doesn't exist yet (fs.ErrExist if it
// does), with the deploy group.
func (d *Deployer) writeNew(path string, content []byte, mode fs.FileMode) error {
dir := filepath.Dir(path)
if err := d.EnsureDir(dir, d.dirMode(dir)); err != nil {
return err
}
f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, mode)
if err != nil {
return err
}
_, err = f.Write(content)
if gid, gerr := d.groupID(); err == nil && gerr == nil && gid >= 0 {
err = f.Chown(-1, gid)
}
if cerr := f.Close(); err == nil {
err = cerr
}
if err != nil {
os.Remove(path)
}
return err
}
// finish gives a temporary file written next to path its mode and the
// deploy group, and moves it into place.
func (d *Deployer) finish(tmp, path string, mode fs.FileMode) error {
if err := os.Chmod(tmp, mode); err != nil {
return err
}
gid, err := d.groupID()
if err != nil {
return err
}
if gid >= 0 {
if err := os.Chown(tmp, -1, gid); err != nil {
return fmt.Errorf("chgrp %s: %w", path, err)
}
}
return os.Rename(tmp, path)
}
// Install writes files and removes paths, returning a backup that undoes
// the changes. On error the files already written are restored.
func (d *Deployer) Install(files []*render.File, remove []string, dirs []string) (*Backup, error) {
@@ -293,5 +335,8 @@ func fileDiff(path string, old []byte, existed bool, new []byte, removed, secret
if d != "" && !strings.HasSuffix(d, "\n") {
d += "\n"
}
if strings.HasSuffix(path, ".conf") {
d = labelHunks(d, string(old), string(new))
}
return d
}
+136
View File
@@ -0,0 +1,136 @@
package engine
import (
"regexp"
"strconv"
"strings"
)
// hunkHeader matches a unified diff hunk header; the line counts are left
// out when they are 1.
var hunkHeader = regexp.MustCompile(`^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@$`)
// labelHunks adds the blocks a hunk's first change is in to its header,
// like git does with function names, so a change to named.conf says where
// it is:
//
// @@ -50,6 +49,8 @@ view "internal" {
//
// Removed lines are looked up in the old file, added ones in the new.
func labelHunks(diff, old, new string) string {
if !strings.Contains(diff, "\n@@ ") {
return diff
}
oldCtx, newCtx := blockContext(old), blockContext(new)
lines := strings.SplitAfter(diff, "\n")
for i, line := range lines {
m := hunkHeader.FindStringSubmatch(strings.TrimSuffix(line, "\n"))
if m == nil {
continue
}
o, _ := strconv.Atoi(m[1])
n, _ := strconv.Atoi(m[2])
label := ""
hunk:
for _, l := range lines[i+1:] {
switch {
case strings.HasPrefix(l, "-"):
label = at(oldCtx, o)
break hunk
case strings.HasPrefix(l, "+"):
label = at(newCtx, n)
break hunk
case strings.HasPrefix(l, " "):
o, n = o+1, n+1
case strings.HasPrefix(l, `\`):
default:
break hunk
}
}
if label != "" {
lines[i] = strings.TrimSuffix(line, "\n") + " " + label + "\n"
}
}
return strings.Join(lines, "")
}
// at returns the context of line n (1-based), or "".
func at(ctx []string, n int) string {
if n < 1 || n > len(ctx) {
return ""
}
return ctx[n-1]
}
// blockContext returns, for each line of a named.conf file, the blocks
// open at its start: `view "internal" { zone "example.com" {`, or "" at
// the top level. It knows named.conf's strings and comments.
func blockContext(text string) []string {
var (
out []string
stack []string
stmt strings.Builder // the current statement, up to a { or ;
inStr bool
inBlock bool // in a /* */ comment
)
label := func() string {
if len(stack) == 0 {
return ""
}
return strings.Join(stack, " { ") + " {"
}
for _, line := range strings.SplitAfter(text, "\n") {
if line == "" {
continue
}
out = append(out, label())
for i := 0; i < len(line); i++ {
c := line[i]
switch {
case inBlock:
if c == '*' && i+1 < len(line) && line[i+1] == '/' {
inBlock = false
i++
}
case inStr:
stmt.WriteByte(c)
if c == '\\' && i+1 < len(line) {
stmt.WriteByte(line[i+1])
i++
} else if c == '"' {
inStr = false
}
case c == '"':
inStr = true
stmt.WriteByte(c)
case c == '#' || c == '/' && i+1 < len(line) && line[i+1] == '/':
i = len(line) // a comment to the end of the line
case c == '/' && i+1 < len(line) && line[i+1] == '*':
inBlock = true
i++
case c == '{':
stack = append(stack, shortLabel(stmt.String()))
stmt.Reset()
case c == '}':
if len(stack) > 0 {
stack = stack[:len(stack)-1]
}
stmt.Reset()
case c == ';':
stmt.Reset()
default:
stmt.WriteByte(c)
}
}
}
return out
}
// shortLabel tidies the text before a { for a hunk header.
func shortLabel(s string) string {
s = strings.Join(strings.Fields(s), " ")
if len(s) > 60 {
s = s[:57] + "..."
}
return s
}
+63
View File
@@ -0,0 +1,63 @@
package engine
import (
"strings"
"testing"
)
func TestLabelHunks(t *testing.T) {
old := `// header
options {
directory "/var/cache/bind";
forwarders { 1.1.1.1; 9.9.9.9; };
listen-on { any; };
recursion no;
version "a { b"; # a { in a string and comments: /* {
dnssec-validation auto;
};
view "internal" {
match-clients { 10.0.0.0/8; };
recursion yes;
allow-transfer { localhost; };
/* a { comment
over lines */
zone "example.com" {
type primary;
file "example.com.db";
notify yes;
};
zone "other.example" { type primary; file "o.db"; };
};
`
new := strings.Replace(old, "\tforwarders { 1.1.1.1; 9.9.9.9; };\n", "", 1)
new = strings.Replace(new, "\tallow-transfer { localhost; };\n", "\tallow-transfer { localhost; };\n\tforward first;\n\tforwarders { 1.1.1.1; 9.9.9.9; };\n", 1)
new = strings.Replace(new, "\t\tnotify yes;\n", "\t\tnotify no;\n", 1)
d := fileDiff("/etc/bind/named.conf", []byte(old), true, []byte(new), false, false)
var headers []string
for _, l := range strings.Split(d, "\n") {
if strings.HasPrefix(l, "@@") {
headers = append(headers, l)
}
}
want := []string{
`@@ -1,7 +1,6 @@ options {`,
`@@ -12,12 +11,14 @@ view "internal" {`,
}
if strings.Join(headers, "\n") != strings.Join(want, "\n") {
t.Errorf("headers:\n%s\n\ndiff:\n%s", strings.Join(headers, "\n"), d)
}
// The first change of a hunk decides, and a change inside a zone
// names both blocks.
new2 := strings.Replace(old, "\t\tnotify yes;\n", "\t\tnotify no;\n", 1)
d = fileDiff("/etc/bind/named.conf", []byte(old), true, []byte(new2), false, false)
if !strings.Contains(d, ` @@ view "internal" { zone "example.com" {`+"\n") {
t.Errorf("zone change:\n%s", d)
}
// Zone files aren't named.conf syntax.
d = fileDiff("/var/lib/bind/example.com.db", []byte(old), true, []byte(new2), false, false)
if strings.Contains(d, "@@ view") {
t.Errorf("zone file labelled:\n%s", d)
}
}
+63 -2
View File
@@ -17,6 +17,9 @@ func DiffOps(from, to *model.State) []model.Op {
add := func(kind string, id int64, base int, data any, summary string) {
ops = append(ops, newOp(kind, id, base, data, summary))
}
if !reflect.DeepEqual(from.Settings.BlockingPausedUntil, to.Settings.BlockingPausedUntil) || from.Settings.BlockingPausedIndefinitely != to.Settings.BlockingPausedIndefinitely {
add(OpBlockingPause, 0, 0, BlockingPauseInput{Until: to.Settings.BlockingPausedUntil, Indefinitely: to.Settings.BlockingPausedIndefinitely}, map[bool]string{true: "pause blocking", false: "resume blocking"}[to.Settings.BlockingPaused()])
}
if !reflect.DeepEqual(stripVersion(from.Settings), stripVersion(to.Settings)) {
s := to.Settings
add(OpSettingsUpdate, 0, 0, SettingsInput{DefaultTTL: &s.DefaultTTL, Nameservers: model.Ptr(append([]string{}, s.Nameservers...)), RName: &s.RName,
@@ -60,7 +63,7 @@ func DiffOps(from, to *model.State) []model.Op {
n := to.Zones[k]
s := n.SOA
in := ZoneInput{Name: &n.Name, ViewID: &n.ViewID, Type: &n.Type, InView: &n.InView, File: &n.File,
DefaultTTL: &n.DefaultTTL, SerialPolicy: &n.SerialPolicy, DNSSECPolicy: &n.DNSSECPolicy, Comment: &n.Comment}
DefaultTTL: &n.DefaultTTL, SerialPolicy: &n.SerialPolicy, DNSSECPolicy: &n.DNSSECPolicy, Comment: &n.Comment, Disabled: &n.Disabled}
if n.Type == model.ZonePrimary {
in.SOA = &SOAInput{MName: &s.MName, RName: &s.RName, Serial: &s.Serial, Refresh: &s.Refresh, Retry: &s.Retry, Expire: &s.Expire, Minimum: &s.Minimum}
}
@@ -88,7 +91,7 @@ func DiffOps(from, to *model.State) []model.Op {
}
for _, k := range sortedKeys(to.Records) {
n := to.Records[k]
in := RecordInput{ZoneID: n.ZoneID, Name: &n.Name, Type: &n.Type, Data: &n.Data, Comment: &n.Comment}
in := RecordInput{ZoneID: n.ZoneID, Name: &n.Name, Type: &n.Type, Data: &n.Data, Comment: &n.Comment, Disabled: &n.Disabled}
if n.TTL != nil {
in.TTL = model.Some(*n.TTL)
} else {
@@ -101,7 +104,64 @@ func DiffOps(from, to *model.State) []model.Op {
add(OpRecordUpdate, k, o.Version, in, fmt.Sprintf("restore %s %s %s", n.Name, n.Type, n.Data))
}
}
// Steps before the lists and groups that use them.
for _, k := range sortedKeys(to.BlockSteps) {
n := to.BlockSteps[k]
in := BlockStepInput{Name: &n.Name, Position: model.Ptr(n.Position), Comment: &n.Comment}
if o, ok := from.BlockSteps[k]; !ok {
add(OpBlockStepCreate, k, 0, in, "add blocking step "+n.Name)
} else if !sameObject(o, n) {
add(OpBlockStepUpdate, k, o.Version, in, "restore blocking step "+n.Name)
}
}
for _, k := range sortedKeys(to.Blocklists) {
n := to.Blocklists[k]
in := BlocklistInput{Name: &n.Name, URL: &n.URL, ViewIDs: model.Ptr(append([]int64{}, n.ViewIDs...)), Disabled: &n.Disabled, Comment: &n.Comment, StepID: model.Ptr(n.StepID)}
if o, ok := from.Blocklists[k]; !ok {
add(OpBlocklistCreate, k, 0, in, "add blocklist "+n.Name)
} else if !sameObject(o, n) {
add(OpBlocklistUpdate, k, o.Version, in, "restore blocklist "+n.Name)
}
}
for _, k := range sortedKeys(to.BlockRules) {
n := to.BlockRules[k]
in := BlockRuleInput{Name: &n.Name, Action: &n.Action, Exact: &n.Exact, ViewIDs: model.Ptr(append([]int64{}, n.ViewIDs...)), Comment: &n.Comment}
if o, ok := from.BlockRules[k]; !ok {
add(OpBlockRuleCreate, k, 0, in, "add rule: "+ruleLabel(n))
} else if !sameObject(o, n) {
add(OpBlockRuleUpdate, k, o.Version, in, "restore rule: "+ruleLabel(n))
}
}
for _, k := range sortedKeys(to.ClientGroups) {
n := to.ClientGroups[k]
in := ClientGroupInput{Name: &n.Name, Members: model.Ptr(append([]string{}, n.Members...)), StepID: model.Ptr(n.StepID), ViewIDs: model.Ptr(append([]int64{}, n.ViewIDs...)), Comment: &n.Comment}
if o, ok := from.ClientGroups[k]; !ok {
add(OpClientGroupCreate, k, 0, in, "add client group "+n.Name)
} else if !sameObject(o, n) {
add(OpClientGroupUpdate, k, o.Version, in, "restore client group "+n.Name)
}
}
// Deletions, most dependent objects first.
for _, k := range sortedKeys(from.ClientGroups) {
if _, ok := to.ClientGroups[k]; !ok {
add(OpClientGroupDelete, k, from.ClientGroups[k].Version, nil, "delete client group "+from.ClientGroups[k].Name)
}
}
for _, k := range sortedKeys(from.BlockRules) {
if _, ok := to.BlockRules[k]; !ok {
add(OpBlockRuleDelete, k, from.BlockRules[k].Version, nil, "delete rule: "+ruleLabel(from.BlockRules[k]))
}
}
for _, k := range sortedKeys(from.Blocklists) {
if _, ok := to.Blocklists[k]; !ok {
add(OpBlocklistDelete, k, from.Blocklists[k].Version, nil, "delete blocklist "+from.Blocklists[k].Name)
}
}
for _, k := range sortedKeys(from.BlockSteps) {
if _, ok := to.BlockSteps[k]; !ok {
add(OpBlockStepDelete, k, from.BlockSteps[k].Version, nil, "delete blocking step "+from.BlockSteps[k].Name)
}
}
for _, k := range sortedKeys(from.Records) {
if _, ok := to.Records[k]; !ok {
o := from.Records[k]
@@ -156,6 +216,7 @@ func DiffOps(from, to *model.State) []model.Op {
func stripVersion(s model.Settings) model.Settings {
s.Version = 0
s.BlockingPausedUntil, s.BlockingPausedIndefinitely = nil, false // their own operation
return s
}
+176
View File
@@ -0,0 +1,176 @@
package engine
import (
"os"
"path/filepath"
"strings"
"testing"
"jarl/internal/model"
)
func disableReq(id int64, ptr bool) Request {
return NewRequest(OpRecordDisable, id, RecordInput{PTR: ptr})
}
func enableReq(id int64, ptr bool) Request {
return NewRequest(OpRecordEnable, id, RecordInput{PTR: ptr})
}
func (env *testEnv) zoneFile(name string) string {
env.t.Helper()
b, err := os.ReadFile(filepath.Join(env.dir, "zones/_default", name+".zone"))
if err != nil {
env.t.Fatal(err)
}
return string(b)
}
func serialOf(env *testEnv, zone string) uint32 {
return env.state().ZonesNamed(zone)[0].SOA.Serial
}
func TestDisableRecord(t *testing.T) {
env := newEnv(t)
setupZone(env)
env.submit(env.admin, rec("example.com", "www", "A", "192.0.2.10"))
www := findRec(env.state(), "www.example.com.", "A")
serial := serialOf(env, "example.com")
res := env.submit(env.admin, disableReq(www.ID, false))
if len(res.Ops) != 1 || res.Ops[0].Summary != "disable www.example.com. A 192.0.2.10" {
t.Fatalf("ops = %+v", res.Ops)
}
if r := env.state().Records[www.ID]; r == nil || !r.Disabled {
t.Fatalf("record = %+v", r)
}
zf := env.zoneFile("example.com")
if !strings.Contains(zf, "; disabled: www\t\tIN\tA\t192.0.2.10\n") || strings.Contains(zf, "\nwww\t") {
t.Errorf("zone file:\n%s", zf)
}
if s := serialOf(env, "example.com"); s != serial+1 {
t.Errorf("serial after disable = %d, want %d", s, serial+1)
}
// Editing a disabled record changes nothing that is served.
env.submit(env.admin, NewRequest(OpRecordUpdate, www.ID, RecordInput{Comment: model.Ptr("old web server"), Data: model.Ptr("192.0.2.11")}))
if s := serialOf(env, "example.com"); s != serial+1 {
t.Errorf("serial after editing a disabled record = %d", s)
}
// Disabling it again says so.
if e := env.submitErr(env.admin, disableReq(www.ID, false)); e.Kind != KindConflict || !strings.Contains(e.Message, "already disabled") {
t.Errorf("second disable: %+v", e)
}
env.submit(env.admin, enableReq(www.ID, false))
r := env.state().Records[www.ID]
if r.Disabled || r.Data != "192.0.2.11" || r.Comment != "old web server" {
t.Errorf("enabled record = %+v", r)
}
if zf := env.zoneFile("example.com"); !strings.Contains(zf, "\nwww\t\tIN\tA\t192.0.2.11\t; old web server\n") || strings.Contains(zf, "disabled") {
t.Errorf("zone file after enable:\n%s", zf)
}
}
func TestDisableValidation(t *testing.T) {
env := newEnv(t)
setupZone(env)
// The zone must keep a name server.
ns := findRec(env.state(), "example.com.", "NS")
if e := env.submitErr(env.admin, disableReq(ns.ID, false)); e.Kind != KindValidation {
t.Errorf("disabling the only NS: %+v", e)
}
// A disabled record doesn't count for the CNAME rule...
env.submit(env.admin, rec("example.com", "www", "A", "192.0.2.10"))
www := findRec(env.state(), "www.example.com.", "A")
env.submit(env.admin, disableReq(www.ID, false))
res := env.submit(env.admin, rec("example.com", "www", "CNAME", "ns1"))
// ...but there's a warning that it can't come back,
if len(res.Problems) != 1 || res.Problems[0].Code != "disabled_conflict" || res.Problems[0].ObjectID != www.ID {
t.Errorf("problems = %+v", res.Problems)
}
// and enabling it is checked like adding it.
if e := env.submitErr(env.admin, enableReq(www.ID, false)); e.Kind != KindValidation || !strings.Contains(e.Message, "CNAME") {
t.Errorf("enabling next to a CNAME: %+v", e)
}
}
func TestDisableByMatchAndPTR(t *testing.T) {
env := newEnv(t)
setupZone(env)
env.submit(env.admin, NewRequest(OpZoneCreate, 0, ZoneInput{Name: model.Ptr("192.0.2.0/24")}))
env.submit(env.admin, NewRequest(OpRecordCreate, 0, RecordInput{Zone: "example.com", Name: model.Ptr("nas"), Type: model.Ptr("A"), Data: model.Ptr("192.0.2.20"), PTR: true}))
ptrDisabled := func() bool {
return findRec(env.state(), "20.2.0.192.in-addr.arpa.", "PTR").Disabled
}
// By match, with the PTR record.
match := RecordInput{Zone: "example.com", Name: model.Ptr("nas"), Type: model.Ptr("A"), PTR: true}
res := env.submit(env.admin, NewRequest(OpRecordDisable, 0, match))
if len(res.Ops) != 2 || res.Ops[1].Summary != "disable 20.2.0.192.in-addr.arpa. PTR nas.example.com." {
t.Fatalf("ops = %+v", res.Ops)
}
if !findRec(env.state(), "nas.example.com.", "A").Disabled || !ptrDisabled() {
t.Fatal("A or PTR not disabled")
}
env.submit(env.admin, NewRequest(OpRecordEnable, 0, match))
if findRec(env.state(), "nas.example.com.", "A").Disabled || ptrDisabled() {
t.Fatal("A or PTR not enabled")
}
// Without ptr, the PTR record stays as it is.
a := findRec(env.state(), "nas.example.com.", "A")
env.submit(env.admin, disableReq(a.ID, false))
if ptrDisabled() {
t.Error("PTR disabled without ptr")
}
// Data narrows the match; nothing matching is an error unless missing_ok.
env.submit(env.admin, rec("example.com", "api", "A", "192.0.2.30"), rec("example.com", "api", "A", "192.0.2.31"))
one := RecordInput{Zone: "example.com", Name: model.Ptr("api"), Type: model.Ptr("A"), Data: model.Ptr("192.0.2.31")}
env.submit(env.admin, NewRequest(OpRecordDisable, 0, one))
if findRecData(env.state(), "api.example.com.", "192.0.2.30").Disabled || !findRecData(env.state(), "api.example.com.", "192.0.2.31").Disabled {
t.Error("data match wrong")
}
none := RecordInput{Zone: "example.com", Name: model.Ptr("nope"), Type: model.Ptr("A")}
if e := env.submitErr(env.admin, NewRequest(OpRecordDisable, 0, none)); e.Kind != KindNotFound {
t.Errorf("no match: %+v", e)
}
// missing_ok: nothing to do (an error the CLI's --missing-ok accepts).
none.MissingOK = true
if e := env.submitErr(env.admin, NewRequest(OpRecordDisable, 0, none)); e.Message != "nothing to do" {
t.Errorf("missing_ok: %+v", e)
}
}
func TestDisableRRSetAndRevert(t *testing.T) {
env := newEnv(t)
setupZone(env)
env.submit(env.admin, rec("example.com", "api", "A", "192.0.2.30"), rec("example.com", "api", "A", "192.0.2.31"))
r30 := findRecData(env.state(), "api.example.com.", "192.0.2.30")
r31 := findRecData(env.state(), "api.example.com.", "192.0.2.31")
env.submit(env.admin, disableReq(r30.ID, false), disableReq(r31.ID, false))
// Setting the RRset enables a disabled record with a given value and
// leaves the other disabled one alone.
env.submit(env.admin, NewRequest(OpRRSetReplace, 0, RRSetInput{Zone: "example.com", Name: "api", Type: "A", Data: []string{"192.0.2.30", "192.0.2.40"}}))
s := env.state()
if r := s.Records[r30.ID]; r == nil || r.Disabled {
t.Errorf(".30 = %+v, want kept and enabled", r)
}
if r := s.Records[r31.ID]; r == nil || !r.Disabled {
t.Errorf(".31 = %+v, want kept and disabled", r)
}
// Reverting a disabling changeset enables the record again.
res := env.submit(env.admin, disableReq(r30.ID, false))
rev, err := env.e.Revert(env.ctx, res.ChangesetID, env.admin)
if err != nil {
t.Fatal(err)
}
if _, err := env.e.Apply(env.ctx, rev.ID, env.admin, ApplyOptions{}); err != nil {
t.Fatal(err)
}
if env.state().Records[r30.ID].Disabled {
t.Error("revert did not enable the record")
}
}
+86 -9
View File
@@ -14,8 +14,10 @@ import (
"fmt"
"io/fs"
"log/slog"
"net/http"
"os"
"path/filepath"
"slices"
"sort"
"strings"
"sync"
@@ -23,6 +25,7 @@ import (
"jarl/internal/auth"
"jarl/internal/bind"
"jarl/internal/blocking"
"jarl/internal/dnsname"
"jarl/internal/model"
"jarl/internal/namedconf"
@@ -64,6 +67,17 @@ type Engine struct {
deploy *Deployer
mu sync.Mutex // serializes applies
Now func() time.Time
// Blocklist downloads (blocklists.go).
blMu sync.Mutex // serializes downloads
blKick chan struct{} // a list was added or changed
HTTP *http.Client // for downloads; nil: a default client
Limits BlocklistLimits
namedCheck namedCheck // named-checkconf on the running configuration (namedwarn.go)
seedMu sync.Mutex
seed []byte // the view keys' seed (viewkeys.go)
}
// New creates an engine.
@@ -78,6 +92,8 @@ func New(st *store.Store, tools *bind.Tools, cfg Config, log *slog.Logger) *Engi
store: st, tools: tools, cfg: cfg, log: log,
deploy: &Deployer{Group: cfg.FileGroup, NamedWritable: []string{cfg.Paths.ZoneDir, cfg.Paths.CacheDir}},
Now: time.Now,
blKick: make(chan struct{}, 1),
Limits: DefaultBlocklistLimits(),
}
}
@@ -383,6 +399,7 @@ type plan struct {
drift []DriftEntry
configChanged bool
reloadZones []*model.Zone
reloadPolicy []render.PolicyZone // rules zones whose file changed
verify map[int64]uint32
newZones bool
backup *Backup
@@ -423,7 +440,7 @@ func (e *Engine) makePlan(ctx context.Context, base, work *model.State, user str
pl.introduced = append(pl.introduced, problems...)
return pl, nil
}
pl.out = render.Render(work, e.cfg.Paths, e.cfg.Extras)
pl.out = render.Render(work, e.cfg.Paths, e.extras(ctx))
deployed, err := e.store.DeployedFiles(ctx)
if err != nil {
return nil, err
@@ -446,7 +463,7 @@ func (e *Engine) makePlan(ctx context.Context, base, work *model.State, user str
}
}
for path, df := range deployed {
if pl.out.File(path) != nil || df.Kind != render.KindZone {
if pl.out.File(path) != nil || df.Kind != render.KindZone && df.Kind != render.KindPolicy {
continue
}
old, existed, _ := readFile(path)
@@ -459,18 +476,18 @@ func (e *Engine) makePlan(ctx context.Context, base, work *model.State, user str
pl.diff = diff.String()
// Which zones named must reload, and which serials to verify.
baseOut := render.Render(base, e.cfg.Paths, e.cfg.Extras)
baseOut := render.Render(base, e.cfg.Paths, e.extras(ctx))
changedPaths := map[string]bool{}
for _, f := range pl.changed {
changedPaths[f.Path] = true
}
for _, z := range work.SortedZones() {
if z.Type != model.ZonePrimary {
continue
if z.Type != model.ZonePrimary || z.Disabled {
continue // disabled zones aren't in named: nothing to reload
}
path := pl.out.ZoneFiles[z.ID]
old := base.Zones[z.ID]
existedInNamed := old != nil && old.Type == model.ZonePrimary && baseOut.ZoneFiles[old.ID] == path
existedInNamed := old != nil && old.Type == model.ZonePrimary && !old.Disabled && baseOut.ZoneFiles[old.ID] == path
if !existedInNamed {
pl.newZones = true
pl.verify[z.ID] = z.SOA.Serial
@@ -484,6 +501,18 @@ func (e *Engine) makePlan(ctx context.Context, base, work *model.State, user str
if len(work.Zones) != len(base.Zones) {
pl.configChanged = true
}
// Rules zones and gates whose content changed. New ones come with a
// change to named.conf, and reconfig loads them.
for _, z := range pl.out.Policy {
if z.ListID != 0 || !changedPaths[z.File] {
continue
}
if slices.ContainsFunc(baseOut.Policy, func(o render.PolicyZone) bool {
return o.Name == z.Name && o.ViewID == z.ViewID && o.File == z.File
}) {
pl.reloadPolicy = append(pl.reloadPolicy, z)
}
}
if !e.cfg.SkipCheck {
if err := e.stagingCheck(ctx, pl); err != nil {
@@ -505,7 +534,7 @@ func (e *Engine) stagingCheck(ctx context.Context, pl *plan) error {
}
defer os.RemoveAll(dir)
sp := render.Paths{ConfigDir: filepath.Join(dir, "config"), ZoneDir: filepath.Join(dir, "zones"), CacheDir: filepath.Join(dir, "cache")}
sout := render.Render(pl.work, sp, e.cfg.Extras)
sout := render.Render(pl.work, sp, e.extras(ctx))
for _, d := range sout.Dirs {
if err := os.MkdirAll(d, 0o750); err != nil {
return err
@@ -519,6 +548,16 @@ func (e *Engine) stagingCheck(ctx context.Context, pl *plan) error {
return err
}
}
// Blocklists as empty policy zones: their content is checked when
// they are downloaded.
for _, path := range sout.Blocklists {
if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil {
return err
}
if err := os.WriteFile(path, []byte(blocking.Placeholder()), 0o640); err != nil {
return err
}
}
main, err := e.stagingMain(dir, sp.MainConfig())
if err != nil {
return err
@@ -796,6 +835,12 @@ func (e *Engine) applyOps(ctx context.Context, cs *model.Changeset, reqs []Reque
res.ChangesetID = cs.ID
res.Applied = true
e.removeDerived(pl)
for _, op := range cs.Ops {
if strings.HasPrefix(op.Kind, "blocklist.") || op.Kind == OpBlockingPause {
e.KickBlocklists()
break
}
}
e.log.Info("changeset applied", "changeset", cs.ID, "user", p.Name(), "ops", len(cs.Ops), "files", len(res.Files), "description", cs.Description)
return res, nil
}
@@ -829,6 +874,9 @@ func (e *Engine) deployAndReload(ctx context.Context, pl *plan, res *Result, rel
return &Error{Kind: KindBIND, Message: "writing configuration files failed (nothing was changed): " + err.Error()}
}
pl.backup = backup
if err := e.ensureBlocklistFiles(pl.out.Blocklists); err != nil {
e.log.Warn("creating a blocklist's empty zone file failed", "err", err)
}
pl.reloaded = reload
if !reload {
return nil
@@ -860,6 +908,11 @@ func (e *Engine) rollback(ctx context.Context, pl *plan) {
e.log.Error("rndc reload after restore failed", "zone", z.Name, "err", err)
}
}
for _, z := range pl.reloadPolicy {
if err := e.tools.ReloadZone(ctx, z.Name, z.View); err != nil {
e.log.Error("rndc reload after restore failed", "zone", z.Name, "err", err)
}
}
}
// namedView returns the view name to pass to rndc for a zone ("" without
@@ -885,6 +938,16 @@ func (e *Engine) reload(ctx context.Context, pl *plan) ([]string, error) {
}
done = append(done, "reload "+zoneDesc(pl.work, z))
}
for _, z := range pl.reloadPolicy {
label := "blocking rules"
if z.View != "" {
label += " [view " + z.View + "]"
}
if err := e.tools.ReloadZone(ctx, z.Name, z.View); err != nil {
return done, fmt.Errorf("reloading %s failed: %v", label, err)
}
done = append(done, "reload "+label)
}
// named reloads zones asynchronously and reports load errors only in
// its log, so wait until it serves the new serials.
deadline := time.Now().Add(e.cfg.ReloadTimeout)
@@ -998,11 +1061,17 @@ func (e *Engine) Resync(ctx context.Context, p *auth.Principal, opts ApplyOption
pl.configChanged = true
pl.reloadZones = nil
for _, z := range work.SortedZones() {
if z.Type == model.ZonePrimary {
if z.Type == model.ZonePrimary && !z.Disabled {
pl.reloadZones = append(pl.reloadZones, z)
pl.verify[z.ID] = z.SOA.Serial
}
}
pl.reloadPolicy = nil
for _, z := range pl.out.Policy {
if z.ListID == 0 {
pl.reloadPolicy = append(pl.reloadPolicy, z)
}
}
reload := !e.cfg.NoReload && !opts.NoReload
if adopted, aerr := e.Adopted(); reload && (aerr != nil || !adopted) {
reload = false
@@ -1113,6 +1182,14 @@ type Status struct {
MainConfig string `json:"main_config"`
}
// NamedStatus asks named for its status (rndc status).
func (e *Engine) NamedStatus(ctx context.Context) (*bind.ServerStatus, error) {
if e.cfg.NoReload || e.tools == nil {
return nil, errors.New("named is not managed by this jarld (no_reload)")
}
return e.tools.Status(ctx)
}
// Status returns the current status.
func (e *Engine) Status(ctx context.Context) (*Status, error) {
st, err := e.store.LoadState(ctx)
@@ -1156,7 +1233,7 @@ func (e *Engine) Status(ctx context.Context) (*Status, error) {
}
}
}
s.Problems = nonNilProblems(e.validate(st))
s.Problems = nonNilProblems(append(e.validate(st), e.namedProblems(ctx, st, deployed)...))
s.Counts = map[string]int{"views": len(st.Views), "zones": len(st.Zones), "records": len(st.Records), "acls": len(st.ACLs), "tsig_keys": len(st.Keys), "options": len(st.Options)}
return s, nil
}
+12 -6
View File
@@ -25,15 +25,21 @@ const (
// Error is an engine error with details for the caller.
type Error struct {
Kind ErrorKind `json:"code"`
Message string `json:"message"`
OpSeq int `json:"op,omitempty"` // the operation that failed
Problems []validate.Problem `json:"problems,omitempty"`
Check []CheckMessage `json:"check,omitempty"`
Diff string `json:"diff,omitempty"`
Kind ErrorKind `json:"code"`
Message string `json:"message"`
OpSeq int `json:"op,omitempty"` // the operation that failed
// OpSummary describes that operation, so people can find it in the
// changeset (record IDs mean little to them).
OpSummary string `json:"op_summary,omitempty"`
Problems []validate.Problem `json:"problems,omitempty"`
Check []CheckMessage `json:"check,omitempty"`
Diff string `json:"diff,omitempty"`
}
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 {
return fmt.Sprintf("operation %d: %s", e.OpSeq, e.Message)
}
+233 -6
View File
@@ -145,6 +145,12 @@ func (x *expander) expand(req Request) ([]model.Op, error) {
if n := len(x.st.ZoneRecords(z.ID)); n > 0 {
verb += fmt.Sprintf(" (with its %d records)", n)
}
} else if d := zoneToggle(req, z); d != nil {
if *d {
x.warnings = append(x.warnings, disabledZoneEffect(x.st, z))
return []model.Op{x.versioned(req, z.Version, "disable zone "+zoneDesc(x.st, z))}, nil
}
return []model.Op{x.versioned(req, z.Version, "enable zone "+zoneDesc(x.st, z))}, nil
}
return []model.Op{x.versioned(req, z.Version, "zone "+verb+" "+zoneDesc(x.st, z))}, nil
case OpRecordCreate:
@@ -155,6 +161,8 @@ func (x *expander) expand(req Request) ([]model.Op, error) {
return x.recordDelete(req)
case OpRRSetReplace:
return x.rrsetReplace(req)
case OpRecordDisable, OpRecordEnable:
return x.recordSetDisabled(req, req.Op == OpRecordDisable)
case OpViewCreate:
return x.viewCreate(req)
case OpViewUpdate, OpViewDelete:
@@ -220,6 +228,66 @@ func (x *expander) expand(req Request) ([]model.Op, error) {
return []model.Op{x.versioned(req, o.Version, summary)}, nil
case OpSettingsUpdate:
return []model.Op{newOpRaw(OpSettingsUpdate, 0, 0, req.Data, "update settings")}, nil
case OpBlocklistCreate:
return x.blocklistCreate(req)
case OpBlocklistUpdate, OpBlocklistDelete:
b := x.st.Blocklists[req.ID]
if b == nil {
return nil, errf(KindNotFound, "blocklist %d does not exist", req.ID)
}
verb := "change"
if req.Op == OpBlocklistDelete {
verb = "delete"
}
return []model.Op{x.versioned(req, b.Version, verb+" blocklist "+b.Name)}, nil
case OpBlockingPause:
var in BlockingPauseInput
if err := decode(req.Data, &in); err != nil {
return nil, err
}
summary := "resume blocking"
if in.Until != nil {
summary = "pause blocking until " + in.Until.Local().Format("2006-01-02 15:04")
} else if in.Indefinitely {
summary = "pause blocking until resumed"
}
return []model.Op{newOpRaw(OpBlockingPause, 0, 0, req.Data, summary)}, nil
case OpBlockRuleCreate:
return x.blockRuleCreate(req)
case OpBlockRuleUpdate, OpBlockRuleDelete:
r := x.st.BlockRules[req.ID]
if r == nil {
return nil, errf(KindNotFound, "rule %d does not exist", req.ID)
}
verb := "change rule: "
if req.Op == OpBlockRuleDelete {
verb = "delete rule: "
}
return []model.Op{x.versioned(req, r.Version, verb+ruleLabel(r))}, nil
case OpBlockStepCreate:
return x.blockStepCreate(req)
case OpBlockStepUpdate, OpBlockStepDelete:
s := x.st.BlockSteps[req.ID]
if s == nil {
return nil, errf(KindNotFound, "blocking step %d does not exist", req.ID)
}
verb := "change blocking step "
if req.Op == OpBlockStepDelete {
verb = "delete blocking step "
}
return []model.Op{x.versioned(req, s.Version, verb+s.Name)}, nil
case OpClientGroupCreate:
return x.clientGroupCreate(req)
case OpClientGroupUpdate, OpClientGroupDelete:
g := x.st.ClientGroups[req.ID]
if g == nil {
return nil, errf(KindNotFound, "client group %d does not exist", req.ID)
}
verb := "change client group "
if req.Op == OpClientGroupDelete {
verb = "delete client group "
}
return []model.Op{x.versioned(req, g.Version, verb+g.Name)}, nil
}
return nil, errf(KindBadRequest, "unknown operation %q", req.Op)
}
@@ -413,6 +481,9 @@ func (x *expander) zoneCreate(req Request) ([]model.Op, error) {
if src != nil {
for _, r := range x.st.ZoneRecords(src.ID) {
ri := RecordInput{Name: model.Ptr(r.Name), Type: model.Ptr(r.Type), Data: model.Ptr(r.Data), Comment: model.Ptr(r.Comment)}
if r.Disabled {
ri.Disabled = model.Ptr(true)
}
if r.TTL != nil {
ri.TTL = model.Some(*r.TTL)
} else {
@@ -502,6 +573,10 @@ func (x *expander) ptrCreate(z *model.Zone, owner string, p *rr.Parsed) ([]model
for _, r := range x.st.ZoneRecords(rz.ID) {
if r.Name == ptrName && r.Type == "PTR" {
if strings.EqualFold(r.Data, owner) {
if r.Disabled {
in := RecordInput{Disabled: model.Ptr(false)}
return []model.Op{newOp(OpRecordUpdate, r.ID, r.Version, in, fmt.Sprintf("enable %s PTR %s", r.Name, r.Data))}, nil
}
return nil, nil // already there
}
x.warnings = append(x.warnings, fmt.Sprintf("%s already has a PTR record (%s); adding another one", ptrName, r.Data))
@@ -515,6 +590,34 @@ func (x *expander) ptrCreate(z *model.Zone, owner string, p *rr.Parsed) ([]model
return []model.Op{newOp(OpRecordCreate, ids[0], 0, in, fmt.Sprintf("add %s IN PTR %s", ptrName, owner))}, nil
}
// ptrSetDisabled returns operations disabling or enabling the PTR record
// of an address record.
func (x *expander) ptrSetDisabled(z *model.Zone, owner string, p *rr.Parsed, disabled bool) []model.Op {
addr, ok := addrOf(p)
if !ok {
return nil
}
rz, ptrName := reverseZoneFor(x.st, z.ViewID, addr)
if rz == nil {
return nil
}
var ops []model.Op
for _, r := range x.st.ZoneRecords(rz.ID) {
if r.Name == ptrName && r.Type == "PTR" && strings.EqualFold(r.Data, owner) && r.Disabled != disabled {
in := RecordInput{Disabled: model.Ptr(disabled)}
ops = append(ops, newOp(OpRecordUpdate, r.ID, r.Version, in, fmt.Sprintf("%s %s PTR %s", enableWord(disabled), r.Name, r.Data)))
}
}
return ops
}
func enableWord(disabled bool) string {
if disabled {
return "disable"
}
return "enable"
}
func (x *expander) ptrDelete(z *model.Zone, owner string, p *rr.Parsed) []model.Op {
addr, ok := addrOf(p)
if !ok {
@@ -635,8 +738,15 @@ func (x *expander) recordUpdate(req Request) ([]model.Op, error) {
if in.Comment != nil && *in.Comment != r.Comment {
changes = append(changes, "comment")
}
toggled := in.Disabled != nil && *in.Disabled != r.Disabled
summary := fmt.Sprintf("change %s %s %s", r.Name, r.Type, r.Data)
if len(changes) > 0 {
switch {
case toggled && len(changes) == 0:
summary = fmt.Sprintf("%s %s %s %s", enableWord(*in.Disabled), r.Name, r.Type, r.Data)
case toggled:
changes = append(changes, enableWord(*in.Disabled)+"d")
fallthrough
case len(changes) > 0:
summary += ": " + strings.Join(changes, ", ")
}
base := req.BaseVersion
@@ -644,15 +754,28 @@ func (x *expander) recordUpdate(req Request) ([]model.Op, error) {
base = r.Version
}
ops := []model.Op{newOp(OpRecordUpdate, r.ID, base, in, summary)}
if ptr && (p.Data != r.Data || owner != r.Name) {
disabled := r.Disabled
if in.Disabled != nil {
disabled = *in.Disabled
}
moved := p.Data != r.Data || owner != r.Name
if ptr && moved {
if old, err := rr.Parse(z.Name, r.Name, r.Type, r.Data); err == nil {
ops = append(ops, x.ptrDelete(z, r.Name, old)...)
}
more, err := x.ptrCreate(z, owner, p)
if err != nil {
return nil, err
if !disabled {
more, err := x.ptrCreate(z, owner, p)
if err != nil {
return nil, err
}
ops = append(ops, more...)
}
ops = append(ops, more...)
}
if ptr && !moved && toggled {
if _, ok := addrOf(p); !ok {
return nil, errf(KindBadRequest, "ptr only applies to A and AAAA records")
}
ops = append(ops, x.ptrSetDisabled(z, owner, p, disabled)...)
}
return ops, nil
}
@@ -725,6 +848,82 @@ func (x *expander) recordDelete(req Request) ([]model.Op, error) {
return ops, nil
}
// recordSetDisabled expands record.disable and record.enable: the record
// with the request's ID, or the records matching zone, name, type and
// (optionally) data.
func (x *expander) recordSetDisabled(req Request, disabled bool) ([]model.Op, error) {
var in RecordInput
if err := decode(req.Data, &in); err != nil {
return nil, err
}
var recs []*model.Record
if req.ID != 0 {
r := x.st.Records[req.ID]
if r == nil {
return nil, errf(KindNotFound, "record %d does not exist", req.ID)
}
recs = append(recs, r)
} else {
z, err := x.resolveZone(in.ZoneID, in.Zone, in.View)
if err != nil {
return nil, err
}
if in.Name == nil || in.Type == nil {
return nil, errf(KindBadRequest, "finding records needs a name and a type (or give the record id)")
}
owner, err := dnsname.Owner(*in.Name, z.Name)
if err != nil {
return nil, errf(KindBadRequest, "%v", err)
}
typ, _, err := rr.NormalizeType(*in.Type)
if err != nil {
return nil, errf(KindBadRequest, "%v", err)
}
data, what := "", owner+" "+typ
if in.Data != nil && strings.TrimSpace(*in.Data) != "" {
_, p, err := parseRecord(z, owner, typ, *in.Data)
if err != nil {
return nil, err
}
data, what = p.Data, what+" "+p.Data
}
for _, r := range x.st.ZoneRecords(z.ID) {
if r.Name == owner && r.Type == typ && (data == "" || strings.EqualFold(r.Data, data)) {
recs = append(recs, r)
}
}
if len(recs) == 0 {
if in.MissingOK {
return nil, nil
}
return nil, errf(KindNotFound, "no record %s in zone %s", dnsname.Display(what), dnsname.Display(z.Name))
}
}
var ops []model.Op
for _, r := range recs {
if r.Disabled == disabled {
continue // already so
}
base := 0
if req.ID != 0 {
base = req.BaseVersion
}
more, err := x.recordUpdate(Request{Op: OpRecordUpdate, ID: r.ID, BaseVersion: base, Data: mustJSON(RecordInput{Disabled: model.Ptr(disabled), PTR: in.PTR})})
if err != nil {
return nil, err
}
ops = append(ops, more...)
}
if len(ops) == 0 && !in.MissingOK {
what := fmt.Sprintf("%s %s %s is", recs[0].Name, recs[0].Type, recs[0].Data)
if len(recs) > 1 {
what = fmt.Sprintf("all %d %s %s records are", len(recs), recs[0].Name, recs[0].Type)
}
return nil, errf(KindConflict, "%s already %sd", what, enableWord(disabled))
}
return ops, nil
}
func (x *expander) rrsetReplace(req Request) ([]model.Op, error) {
var in RRSetInput
if err := decode(req.Data, &in); err != nil {
@@ -862,3 +1061,31 @@ func RedactOps(ops []model.Op) []model.Op {
}
return out
}
// zoneToggle returns the new disabled state when a zone update only
// disables or enables the zone (nil otherwise).
func zoneToggle(req Request, z *model.Zone) *bool {
var in ZoneInput
if decode(req.Data, &in) != nil || in.Disabled == nil || *in.Disabled == z.Disabled {
return nil
}
only := ZoneInput{Disabled: in.Disabled}
if string(mustJSON(in)) != string(mustJSON(only)) {
return nil
}
return in.Disabled
}
// disabledZoneEffect says what clients get once named no longer loads a
// zone: in a view that offers recursion, answers from recursion (the
// internet's version of the zone, if it has one); elsewhere, REFUSED.
func disabledZoneEffect(st *model.State, z *model.Zone) string {
where := "this server"
if v := st.ViewName(z); v != "" {
where = "view " + v
}
if st.RecursionOn(z.ViewID) {
return fmt.Sprintf("once zone %s is disabled, clients of %s that may use recursion get its names from recursion: the internet's version of the zone, if there is one", dnsname.Display(z.Name), where)
}
return fmt.Sprintf("once zone %s is disabled, %s answers REFUSED for its names", dnsname.Display(z.Name), where)
}
+90 -3
View File
@@ -2,6 +2,7 @@ package engine
import (
"encoding/json"
"time"
"jarl/internal/model"
)
@@ -29,12 +30,35 @@ const (
OpOptionUpdate = "option.update"
OpOptionDelete = "option.delete"
OpSettingsUpdate = "settings.update"
OpBlocklistCreate = "blocklist.create"
OpBlocklistUpdate = "blocklist.update"
OpBlocklistDelete = "blocklist.delete"
OpBlockRuleCreate = "blockrule.create"
OpBlockRuleUpdate = "blockrule.update"
OpBlockRuleDelete = "blockrule.delete"
OpBlockingPause = "blocking.pause"
OpBlockStepCreate = "blockstep.create"
OpBlockStepUpdate = "blockstep.update"
OpBlockStepDelete = "blockstep.delete"
OpClientGroupCreate = "clientgroup.create"
OpClientGroupUpdate = "clientgroup.update"
OpClientGroupDelete = "clientgroup.delete"
// Requests only: disabling and enabling records, by ID or by match
// (zone, name, type and optionally data), become record.update
// operations.
OpRecordDisable = "record.disable"
OpRecordEnable = "record.enable"
)
// OpKinds lists all operation kinds.
var OpKinds = []string{OpZoneCreate, OpZoneUpdate, OpZoneDelete, OpRecordCreate, OpRecordUpdate, OpRecordDelete, OpRRSetReplace,
OpViewCreate, OpViewUpdate, OpViewDelete, OpViewReorder, OpACLCreate, OpACLUpdate, OpACLDelete,
OpTSIGCreate, OpTSIGUpdate, OpTSIGDelete, OpOptionCreate, OpOptionUpdate, OpOptionDelete, OpSettingsUpdate}
OpTSIGCreate, OpTSIGUpdate, OpTSIGDelete, OpOptionCreate, OpOptionUpdate, OpOptionDelete, OpSettingsUpdate,
OpBlocklistCreate, OpBlocklistUpdate, OpBlocklistDelete, OpBlockRuleCreate, OpBlockRuleUpdate, OpBlockRuleDelete, OpBlockingPause,
OpBlockStepCreate, OpBlockStepUpdate, OpBlockStepDelete, OpClientGroupCreate, OpClientGroupUpdate, OpClientGroupDelete}
// Request is an operation as submitted by a client. Requests are expanded
// into one or more stored operations (for example an A record with "ptr"
@@ -92,6 +116,8 @@ type ZoneInput struct {
// finished, off ("").
DNSSECPolicy *string `json:"dnssec_policy,omitempty"`
Comment *string `json:"comment,omitempty"`
// Disabled keeps the zone in Jarl but out of named's configuration.
Disabled *bool `json:"disabled,omitempty"`
// Create only: name servers for the apex NS records (default: the
// configured default name servers), additional records, and a zone to
@@ -121,8 +147,10 @@ type RecordInput struct {
TTL model.Opt[uint32] `json:"ttl,omitzero"` // null: use the zone default
Data *string `json:"data,omitempty"`
Comment *string `json:"comment,omitempty"`
// PTR also creates (or deletes) the matching PTR record in the
// reverse zone, for A and AAAA records.
// Disabled keeps the record in Jarl but out of the zone.
Disabled *bool `json:"disabled,omitempty"`
// PTR also creates (or deletes, disables or enables) the matching PTR
// record in the reverse zone, for A and AAAA records.
PTR bool `json:"ptr,omitempty"`
// MissingOK makes a delete-by-match succeed when nothing matches.
MissingOK bool `json:"missing_ok,omitempty"`
@@ -205,3 +233,62 @@ type SettingsInput struct {
type DeleteInput struct {
Cascade bool `json:"cascade,omitempty"`
}
// BlocklistInput creates or updates a blocklist. Views are given by name
// (Views) or ID (ViewIDs); an empty list means every view that offers
// recursion.
type BlocklistInput struct {
Name *string `json:"name,omitempty"`
URL *string `json:"url,omitempty"`
Views *[]string `json:"views,omitempty"`
ViewIDs *[]int64 `json:"view_ids,omitempty"`
Disabled *bool `json:"disabled,omitempty"`
Comment *string `json:"comment,omitempty"`
// Step is the step the list starts at, by name ("normal" or a step
// above it) or ID (StepID; 0 is normal).
Step *string `json:"step,omitempty"`
StepID *int64 `json:"step_id,omitempty"`
}
// BlockStepInput creates or updates a blocking step above Normal.
// Position orders the steps (new steps go on top).
type BlockStepInput struct {
Name *string `json:"name,omitempty"`
Position *int `json:"position,omitempty"`
Comment *string `json:"comment,omitempty"`
}
// ClientGroupInput creates or updates a client group. Members are
// addresses, prefixes or ACL names; the step is given by name ("off",
// "normal" or a step) or ID; views as for BlocklistInput.
type ClientGroupInput struct {
Name *string `json:"name,omitempty"`
Members *[]string `json:"members,omitempty"`
Step *string `json:"step,omitempty"`
StepID *int64 `json:"step_id,omitempty"`
Views *[]string `json:"views,omitempty"`
ViewIDs *[]int64 `json:"view_ids,omitempty"`
Comment *string `json:"comment,omitempty"`
}
// BlockRuleInput creates or updates a block or allow rule. Views as for
// BlocklistInput.
type BlockRuleInput struct {
Name *string `json:"name,omitempty"`
Action *string `json:"action,omitempty"` // block (default) or allow
Exact *bool `json:"exact,omitempty"` // only the name, not the names below it
Views *[]string `json:"views,omitempty"`
ViewIDs *[]int64 `json:"view_ids,omitempty"`
Comment *string `json:"comment,omitempty"`
}
// BlockingPauseInput pauses blocking until a time, or resumes it (null).
type BlockingPauseInput struct {
Until *time.Time `json:"until"`
// Indefinitely pauses until someone resumes blocking (Until is nil).
Indefinitely bool `json:"indefinitely,omitempty"`
}
// MaxBlockingPause is the longest pause: blocking shouldn't stay off by
// mistake (disable lists to stop using them).
const MaxBlockingPause = 24 * time.Hour
+77
View File
@@ -0,0 +1,77 @@
package engine
import (
"context"
"fmt"
"os"
"strings"
"sync"
"time"
"jarl/internal/model"
"jarl/internal/render"
"jarl/internal/validate"
)
// namedCheckTTL is how long the result of checking named's running
// configuration is kept when nothing changed.
const namedCheckTTL = 10 * time.Minute
// namedCheck caches the warnings of named-checkconf on the configuration
// named runs, keyed by what it depends on.
type namedCheck struct {
mu sync.Mutex
key string
at time.Time
problems []validate.Problem
}
// namedProblems runs named-checkconf on the configuration named runs
// (without loading zones, which is quick) and returns what it reports as
// problems. An apply shows these too, but they are named's own checks,
// which Jarl doesn't repeat ("both recursion no and allow-recursion
// active"): the dashboard lists them with Jarl's so they don't go unseen
// after the apply. The result is reused while the deployed files and
// named's main file are unchanged.
func (e *Engine) namedProblems(ctx context.Context, st *model.State, deployed map[string]*model.DeployedFile) []validate.Problem {
if e.tools == nil || e.cfg.SkipCheck || e.cfg.MainConfig == "" || len(deployed) == 0 {
return nil
}
key := ""
if fi, err := os.Stat(e.cfg.MainConfig); err == nil {
key = fi.ModTime().String()
}
if f := deployed[e.cfg.Paths.MainConfig()]; f != nil {
key += "|" + f.SHA256
}
c := &e.namedCheck
c.mu.Lock()
defer c.mu.Unlock()
if c.key == key && e.Now().Sub(c.at) < namedCheckTTL {
return c.problems
}
res, err := e.tools.CheckConfig(ctx, e.cfg.MainConfig, false)
if err != nil {
return nil // named-checkconf couldn't run; an apply reports that
}
out := render.Render(st, e.cfg.Paths, e.extras(ctx))
var ps []validate.Problem
for _, m := range res.Messages {
p := validate.Problem{Severity: validate.Warning, Code: "named_warning", View: m.View, Zone: m.Zone}
if m.Error {
p.Severity, p.Code = validate.Error, "named_error"
}
where := ""
if m.File != "" {
where = fmt.Sprintf(" (%s:%d)", m.File, m.Line)
}
if span, ok := out.Lookup(m.File, m.Line); ok {
p.ObjectType, p.ObjectID = span.ObjectType, span.ObjectID
where = " (" + span.Label + ", " + where[2:]
}
p.Message = "named-checkconf: " + strings.TrimSpace(m.Text) + where
ps = append(ps, p)
}
c.key, c.at, c.problems = key, e.Now(), ps
return ps
}
+90 -7
View File
@@ -11,6 +11,7 @@ import (
"jarl/internal/auth"
"jarl/internal/catalog"
"jarl/internal/dnsname"
"jarl/internal/iprange"
"jarl/internal/model"
"jarl/internal/namedconf"
"jarl/internal/rr"
@@ -98,11 +99,29 @@ func applyOp(st *model.State, op model.Op, c *applyCtx) error {
err = applyOptionDelete(st, op, c)
case OpSettingsUpdate:
err = applySettings(st, op, c)
case OpBlocklistCreate, OpBlocklistUpdate:
err = applyBlocklistPut(st, op, c)
case OpBlocklistDelete:
err = applyBlocklistDelete(st, op, c)
case OpBlockRuleCreate, OpBlockRuleUpdate:
err = applyBlockRulePut(st, op, c)
case OpBlockRuleDelete:
err = applyBlockRuleDelete(st, op, c)
case OpBlockStepCreate, OpBlockStepUpdate:
err = applyBlockStepPut(st, op, c)
case OpBlockStepDelete:
err = applyBlockStepDelete(st, op, c)
case OpClientGroupCreate, OpClientGroupUpdate:
err = applyClientGroupPut(st, op, c)
case OpClientGroupDelete:
err = applyClientGroupDelete(st, op, c)
case OpBlockingPause:
err = applyBlockingPause(st, op, c)
default:
err = errf(KindBadRequest, "unknown operation %q", op.Kind)
}
if e, ok := AsError(err); ok && e.OpSeq == 0 {
e.OpSeq = op.Seq
e.OpSeq, e.OpSummary = op.Seq, op.Summary
}
return err
}
@@ -230,6 +249,9 @@ func setZoneFields(st *model.State, z *model.Zone, in *ZoneInput, creating bool)
if in.SerialPolicy != nil {
z.SerialPolicy = strings.ToLower(strings.TrimSpace(*in.SerialPolicy))
}
if in.Disabled != nil {
z.Disabled = *in.Disabled
}
if in.DNSSECPolicy != nil {
p := strings.Trim(strings.TrimSpace(*in.DNSSECPolicy), `"`)
switch strings.ToLower(p) {
@@ -373,7 +395,7 @@ func parseRecord(z *model.Zone, name, typ, data string) (string, *rr.Parsed, err
func rrsetTTL(st *model.State, zoneID int64, name, typ string, exceptID int64) (*uint32, bool) {
for _, r := range st.Records {
if r.ZoneID == zoneID && r.Name == name && r.Type == typ && r.ID != exceptID {
if r.ZoneID == zoneID && r.Name == name && r.Type == typ && r.ID != exceptID && !r.Disabled {
if r.TTL == nil {
return nil, true
}
@@ -422,6 +444,9 @@ func applyRecordCreate(st *model.State, op model.Op, c *applyCtx) error {
if in.Comment != nil {
r.Comment = *in.Comment
}
if in.Disabled != nil {
r.Disabled = *in.Disabled
}
for _, w := range p.Warnings {
c.warn("%s %s: %s", dnsname.Display(owner), p.Type, w)
}
@@ -433,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 {
r := st.Records[op.ID]
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 {
return err
@@ -498,6 +523,9 @@ func applyRecordUpdate(st *model.State, op model.Op, c *applyCtx) error {
if in.Comment != nil {
r.Comment = *in.Comment
}
if in.Disabled != nil {
r.Disabled = *in.Disabled
}
r.Touch(c.user(), c.now)
return nil
}
@@ -506,7 +534,10 @@ func applyRecordDelete(st *model.State, op model.Op, c *applyCtx) error {
if op.ID != 0 {
r := st.Records[op.ID]
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 {
return err
@@ -617,20 +648,32 @@ func applyRRSetReplace(st *model.State, op model.Op, c *applyCtx) error {
} else if t, ok := rrsetTTL(st, z.ID, owner, typ, 0); ok {
ttl, ttlKnown = t, true
}
// The RRset becomes exactly the given data. A disabled record with
// one of the values is enabled again; other disabled records are left
// alone.
keep := map[string]bool{}
for id, r := range st.Records {
// (In ID order, so which of two identical records is kept doesn't
// depend on map order.)
for _, id := range sortedKeys(st.Records) {
r := st.Records[id]
if r.ZoneID != z.ID || r.Name != owner || r.Type != typ {
continue
}
k := strings.ToLower(r.Data)
if seen[k] && !keep[k] {
keep[k] = true
if ttlKnown && !sameTTL(r.TTL, ttl) {
r.TTL = copyTTL(ttl)
if (ttlKnown && !sameTTL(r.TTL, ttl)) || r.Disabled {
if ttlKnown {
r.TTL = copyTTL(ttl)
}
r.Disabled = false
r.Touch(c.user(), c.now)
}
continue
}
if r.Disabled {
continue
}
delete(st.Records, id)
}
ids := in.NewIDs
@@ -813,6 +856,9 @@ func applyViewDelete(st *model.State, op model.Op, c *applyCtx) error {
if err := decode(op.Data, &in); err != nil {
return err
}
if users := blockingUsers(st, v.ID); len(users) > 0 {
return errf(KindConflict, "view %s is used by %s; change them to other views first", v.Name, strings.Join(users, ", "))
}
zones := st.ZonesInView(v.ID)
if len(zones) > 0 && !in.Cascade {
var names []string
@@ -1093,13 +1139,50 @@ func NormalizeOption(st *model.State, scope string, scopeID int64, name, value s
if (stmt.Kind == catalog.KindString) && !strings.HasPrefix(v, `"`) && !strings.ContainsAny(v, " \t{};") {
v = namedconf.QuoteString(v)
}
// Lists may be given without their braces: "10.0.0.53; key xfer".
v = stmt.WrapList(v)
parsed, err := namedconf.ParseOptionValue(n, v)
if err != nil {
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
}
// 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 {
var in OptionInput
if err := decode(op.Data, &in); err != nil {
+22
View File
@@ -0,0 +1,22 @@
package engine
import (
"testing"
"jarl/internal/model"
)
func TestNormalizeOptionWrapsLists(t *testing.T) {
st := model.NewState()
for in, want := range map[string]string{
"10.0.10.53": "{ 10.0.10.53; }",
"localhost; key xfer;": "{ localhost; key xfer; }",
"port 53 { any; }": "port 53 { any; }",
"localhost 10.0.10.53": "{ localhost; 10.0.10.53; }",
} {
_, v, err := NormalizeOption(st, model.ScopeOptions, 0, "allow-transfer", in)
if err != nil || v != want {
t.Errorf("%q: %q, %v; want %q", in, v, err, want)
}
}
}
+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)
}
}
for _, g := range st.SortedClientGroups() {
if _, hit := rewriteElements(g.Members, kind, name, name); hit {
out = append(out, "client group "+g.Name)
}
}
var opts []string
for _, o := range st.Options {
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)
}
}
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 {
stmt, err := namedconf.ParseOptionValue(o.Name, o.Value)
if err != nil {
+2 -2
View File
@@ -101,8 +101,8 @@ func (e *Engine) guardSerials(ctx context.Context, base, work *model.State, bump
continue
}
old := base.Zones[z.ID]
if old == nil || old.Type != model.ZonePrimary || old.ViewID != z.ViewID {
continue // new to named: nothing loaded to compare with
if old == nil || old.Type != model.ZonePrimary || old.ViewID != z.ViewID || old.Disabled || z.Disabled {
continue // new to named (or not in it): nothing loaded to compare with
}
path := render.ZoneFilePath(work, z, e.cfg.Paths)
if cur, ok, _ := readFile(path); ok && string(cur) == render.ZoneFile(work, z) {
+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)
}
}
+68
View File
@@ -0,0 +1,68 @@
package engine
import (
"context"
"crypto/rand"
"encoding/base64"
"errors"
"jarl/internal/model"
"jarl/internal/nscheck"
"jarl/internal/render"
"jarl/internal/store"
)
// viewKeySeedMeta is where the seed of the view keys is kept.
const viewKeySeedMeta = "view_key_seed"
// ViewKeySeed returns the seed the view keys' secrets are derived from
// (render/viewkeys.go), creating it the first time.
func ViewKeySeed(ctx context.Context, st *store.Store) ([]byte, error) {
v, err := st.GetMeta(ctx, viewKeySeedMeta)
if err == nil {
return base64.StdEncoding.DecodeString(v)
}
if !errors.Is(err, store.ErrNotFound) {
return nil, err
}
seed := make([]byte, 32)
if _, err := rand.Read(seed); err != nil {
return nil, err
}
if err := st.SetMeta(ctx, viewKeySeedMeta, base64.StdEncoding.EncodeToString(seed)); err != nil {
return nil, err
}
return seed, nil
}
// extras returns what jarld's configuration adds to the rendered
// configuration, with the view keys' seed.
func (e *Engine) extras(ctx context.Context) render.Extras {
x := e.cfg.Extras
e.seedMu.Lock()
defer e.seedMu.Unlock()
if e.seed == nil {
seed, err := ViewKeySeed(ctx, e.store)
if err != nil {
e.log.Warn("view keys: reading their seed failed", "err", err)
return x
}
e.seed = seed
}
x.ViewKeySeed = e.seed
return x
}
// ViewKey returns the TSIG key that selects a zone's view on this server
// (nil without views).
func (e *Engine) ViewKey(ctx context.Context, st *model.State, z *model.Zone) *nscheck.Key {
v := st.Views[z.ViewID]
if v == nil {
return nil
}
x := e.extras(ctx)
if len(x.ViewKeySeed) == 0 {
return nil
}
return &nscheck.Key{Name: render.ViewKeyName(v.Name), Algorithm: render.ViewKeyAlgorithm, Secret: render.ViewKeySecret(x.ViewKeySeed, v.ID)}
}
+81
View File
@@ -0,0 +1,81 @@
package engine
import (
"os"
"path/filepath"
"strings"
"testing"
"jarl/internal/model"
)
func TestZoneDisable(t *testing.T) {
env := newEnv(t)
setupZone(env)
z := env.state().ZonesNamed("example.com")[0]
confPath := filepath.Join(env.dir, "etc/named.conf")
zonePath := filepath.Join(env.dir, "zones/_default/example.com.zone")
res := env.submit(env.admin, NewRequest(OpZoneUpdate, z.ID, ZoneInput{Disabled: model.Ptr(true)}))
if res.Ops[0].Summary != "disable zone example.com" || len(res.Warnings) != 1 || !strings.Contains(res.Warnings[0], "recursion") {
t.Errorf("disable: %q, warnings %v", res.Ops[0].Summary, res.Warnings)
}
conf, _ := os.ReadFile(confPath)
if strings.Contains(string(conf), "zone \"example.com\" {") || !strings.Contains(string(conf), `// zone "example.com" is disabled in Jarl`) {
t.Errorf("named.conf with the zone disabled:\n%s", conf)
}
// Still edited, and its file kept up to date.
env.submit(env.admin, rec("example.com", "www", "A", "192.0.2.10"))
if f, err := os.ReadFile(zonePath); err != nil || !strings.Contains(string(f), "www\t\tIN\tA\t192.0.2.10") {
t.Errorf("zone file while disabled: %v\n%s", err, f)
}
// Enabling brings it back.
res = env.submit(env.admin, NewRequest(OpZoneUpdate, z.ID, ZoneInput{Disabled: model.Ptr(false)}))
if res.Ops[0].Summary != "enable zone example.com" {
t.Errorf("enable: %q", res.Ops[0].Summary)
}
if conf, _ := os.ReadFile(confPath); !strings.Contains(string(conf), "zone \"example.com\" {") {
t.Errorf("named.conf after enabling:\n%s", conf)
}
// Reverting a disable enables the zone again.
res = env.submit(env.admin, NewRequest(OpZoneUpdate, z.ID, ZoneInput{Disabled: model.Ptr(true)}))
rev, err := env.e.Revert(env.ctx, res.ChangesetID, env.admin)
if err != nil {
t.Fatal(err)
}
if _, err := env.e.Apply(env.ctx, rev.ID, env.admin, ApplyOptions{}); err != nil || env.state().Zones[z.ID].Disabled {
t.Errorf("revert of a disable: %v, disabled=%v", err, env.state().Zones[z.ID].Disabled)
}
// Signed zones can't be disabled.
env.submit(env.admin, NewRequest(OpZoneUpdate, z.ID, ZoneInput{DNSSECPolicy: model.Ptr("default")}))
if e := env.submitErr(env.admin, NewRequest(OpZoneUpdate, z.ID, ZoneInput{Disabled: model.Ptr(true)})); e.Kind != KindValidation || !strings.Contains(e.Message, "signed zone can't be disabled") {
t.Errorf("disabling a signed zone: %+v", e)
}
}
func TestZoneDisableInView(t *testing.T) {
env := newEnv(t)
setupZone(env)
env.submit(env.admin,
NewRequest(OpViewCreate, 0, ViewInput{Name: model.Ptr("internal"), MatchClients: &[]string{"10.0.0.0/8"}, AdoptZones: true}),
NewRequest(OpViewCreate, 0, ViewInput{Name: model.Ptr("external")}),
)
st := env.state()
ext := st.ViewByName("external")
env.submit(env.admin, NewRequest(OpZoneCreate, 0, ZoneInput{Name: model.Ptr("example.com"), ViewID: &ext.ID, Type: model.Ptr(model.ZoneInView), InView: model.Ptr("internal")}))
in := st.ZonesNamed("example.com")[0]
// The zone another view shares can't be disabled alone.
if e := env.submitErr(env.admin, NewRequest(OpZoneUpdate, in.ID, ZoneInput{Disabled: model.Ptr(true)})); e.Kind != KindValidation || !strings.Contains(e.Message, "is disabled, so this zone can't share it") {
t.Errorf("disabling a shared zone: %+v", e)
}
// Without recursion, the warning says REFUSED.
env.submit(env.admin, NewRequest(OpOptionCreate, 0, OptionInput{Scope: model.Ptr(model.ScopeView), ScopeID: &ext.ID, Name: model.Ptr("recursion"), Value: model.Ptr("no")}))
env.submit(env.admin, NewRequest(OpZoneCreate, 0, ZoneInput{Name: model.Ptr("public.example"), ViewID: &ext.ID}))
pub := env.state().FindZone("public.example", ext.ID)
res := env.submit(env.admin, NewRequest(OpZoneUpdate, pub.ID, ZoneInput{Disabled: model.Ptr(true)}))
if len(res.Warnings) != 1 || !strings.Contains(res.Warnings[0], "view external answers REFUSED") {
t.Errorf("warnings = %v", res.Warnings)
}
}
+25 -1
View File
@@ -393,6 +393,10 @@ func (im *importer) key(s *namedconf.Stmt) error {
return fmt.Errorf("%s: key without a block", s.Pos)
}
name := model.NormalizeKeyName(s.Arg(0))
if strings.HasPrefix(name, model.ViewKeyPrefix) {
// A view key from a configuration Jarl wrote: Jarl makes its own.
return nil
}
alg, secret := "", ""
if a := namedconf.FindOne(b.Stmts, "algorithm"); a != nil {
alg = strings.ToLower(a.Arg(0))
@@ -433,7 +437,7 @@ func (im *importer) view(s *namedconf.Stmt) error {
switch kw := st.Keyword(); kw {
case "match-clients":
if mb := st.FirstBlock(); mb != nil {
v.MatchClients = nonNil(elementStrings(mb))
v.MatchClients = withoutViewKeys(nonNil(elementStrings(mb)))
}
case "match-destinations":
if mb := st.FirstBlock(); mb != nil {
@@ -807,3 +811,23 @@ func allowsNone(o *namedconf.Stmt) bool {
}
return true
}
// withoutViewKeys drops the view keys Jarl puts in front of match-clients
// (render/viewkeys.go) from a list read from a configuration Jarl wrote,
// and the "any" it adds after them to a view that had no list.
func withoutViewKeys(elems []string) []string {
out := []string{}
dropped := false
for _, e := range elems {
f := strings.Fields(strings.TrimPrefix(strings.TrimSpace(e), "!"))
if len(f) == 2 && f[0] == "key" && strings.HasPrefix(strings.Trim(f[1], `"`), model.ViewKeyPrefix) {
dropped = true
continue
}
out = append(out, e)
}
if dropped && len(out) == 1 && out[0] == "any" {
return []string{}
}
return out
}
+12
View File
@@ -205,3 +205,15 @@ view "internal" {
t.Errorf("imported state has errors: %v", ps)
}
}
func TestWithoutViewKeys(t *testing.T) {
for _, tc := range []struct{ in, want []string }{
{[]string{"!key jarl-view-external", `key "jarl-view-internal"`, "homelab"}, []string{"homelab"}},
{[]string{"key jarl-view-external", "any"}, []string{}},
{[]string{"key xfer", "any"}, []string{"key xfer", "any"}},
} {
if got := withoutViewKeys(tc.in); strings.Join(got, ";") != strings.Join(tc.want, ";") || got == nil {
t.Errorf("%v: %v, want %v", tc.in, got, tc.want)
}
}
}
+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
}
+283
View File
@@ -0,0 +1,283 @@
package model
import (
"fmt"
"net/netip"
"slices"
"sort"
"strings"
"time"
"jarl/internal/iprange"
)
// Blocklist is a list of domains to block, downloaded from URL and kept up
// to date by jarld (docs/blocking.md).
type Blocklist struct {
ID int64 `json:"id"`
Name string `json:"name"` // a DNS label: it names the list's policy zone
URL string `json:"url"`
// ViewIDs are the views the list applies to; empty means every view
// that offers recursion (see BlockingViews).
ViewIDs []int64 `json:"view_ids"`
Disabled bool `json:"disabled,omitempty"`
// StepID is the blocking step the list starts at: StepNormal for
// every client, or a BlockStep for the clients of groups at that step
// or above.
StepID int64 `json:"step_id"`
Comment string `json:"comment"`
Meta
}
// Built-in blocking steps (BlockStep IDs are positive).
const (
StepOff int64 = -1 // no blocklists (rules still apply)
StepNormal int64 = 0 // the default: every client not in a group
)
// BlockStep is a blocking step above Normal. Steps are ordered: each
// includes the lists of the steps below it, plus its own (docs/blocking.md).
type BlockStep struct {
ID int64 `json:"id"`
Name string `json:"name"`
Position int `json:"position"`
Comment string `json:"comment"`
Meta
}
// ClientGroup gives the clients with these addresses a blocking step.
type ClientGroup struct {
ID int64 `json:"id"`
Name string `json:"name"`
// Members are addresses, prefixes, or names of ACLs made of them.
Members []string `json:"members"`
StepID int64 `json:"step_id"` // StepOff, StepNormal or a BlockStep
ViewIDs []int64 `json:"view_ids"` // as for Blocklist
Comment string `json:"comment"`
Meta
}
// OrderedBlockSteps returns the steps above Normal, lowest first.
func (s *State) OrderedBlockSteps() []*BlockStep {
out := make([]*BlockStep, 0, len(s.BlockSteps))
for _, st := range s.BlockSteps {
out = append(out, st)
}
sort.Slice(out, func(i, j int) bool {
if out[i].Position != out[j].Position {
return out[i].Position < out[j].Position
}
return out[i].ID < out[j].ID
})
return out
}
// StepRank orders steps: -1 for Off, 0 for Normal, 1, 2, ... for the
// steps above it. ok is false for a step that doesn't exist.
func (s *State) StepRank(id int64) (rank int, ok bool) {
switch id {
case StepOff:
return -1, true
case StepNormal:
return 0, true
}
for i, st := range s.OrderedBlockSteps() {
if st.ID == id {
return i + 1, true
}
}
return 0, false
}
// StepName returns a step's name ("off" and "normal" for the built-in
// ones).
func (s *State) StepName(id int64) string {
switch id {
case StepOff:
return "off"
case StepNormal:
return "normal"
}
if st := s.BlockSteps[id]; st != nil {
return st.Name
}
return fmt.Sprintf("step %d", id)
}
// StepByName finds a step by name, the built-in ones included.
func (s *State) StepByName(name string) (int64, bool) {
name = strings.ToLower(strings.TrimSpace(name))
switch name {
case "off":
return StepOff, true
case "normal":
return StepNormal, true
}
for _, st := range s.BlockSteps {
if strings.EqualFold(st.Name, name) {
return st.ID, true
}
}
return 0, false
}
// ClientGroupByName finds a client group by name.
func (s *State) ClientGroupByName(name string) *ClientGroup {
for _, g := range s.ClientGroups {
if strings.EqualFold(g.Name, name) {
return g
}
}
return nil
}
// SortedClientGroups returns the client groups ordered by name.
func (s *State) SortedClientGroups() []*ClientGroup {
out := make([]*ClientGroup, 0, len(s.ClientGroups))
for _, g := range s.ClientGroups {
out = append(out, g)
}
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
return out
}
// Block rule actions.
const (
BlockActionBlock = "block"
BlockActionAllow = "allow"
)
// BlockRule blocks or allows a domain, and unless Exact the names below
// it. Rules take precedence over blocklists.
type BlockRule struct {
ID int64 `json:"id"`
Name string `json:"name"` // lowercase, without the final dot
Action string `json:"action"` // block or allow
Exact bool `json:"exact,omitempty"`
ViewIDs []int64 `json:"view_ids"` // as for Blocklist
Comment string `json:"comment"`
Meta
}
// BlocklistStatus is what jarld knows about a list's downloads. It is not
// configuration: it is kept outside changesets.
type BlocklistStatus struct {
ID int64 `json:"id"`
URL string `json:"url"` // the URL last tried
ETag string `json:"-"`
LastModified string `json:"-"`
CheckedAt *time.Time `json:"checked_at,omitempty"` // last attempt
UpdatedAt *time.Time `json:"updated_at,omitempty"` // last time the content changed
Entries int `json:"entries"` // domains
Records int `json:"records"` // policy zone records
Skipped int `json:"skipped"` // lines that weren't usable
SHA256 string `json:"-"`
Error string `json:"error,omitempty"` // the last attempt failed
}
// BlocklistByName finds a blocklist by name.
func (s *State) BlocklistByName(name string) *Blocklist {
for _, b := range s.Blocklists {
if strings.EqualFold(b.Name, name) {
return b
}
}
return nil
}
// SortedBlocklists returns the blocklists ordered by name.
func (s *State) SortedBlocklists() []*Blocklist {
out := make([]*Blocklist, 0, len(s.Blocklists))
for _, b := range s.Blocklists {
out = append(out, b)
}
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
return out
}
// SortedBlockRules returns the rules ordered by domain, parents first.
func (s *State) SortedBlockRules() []*BlockRule {
out := make([]*BlockRule, 0, len(s.BlockRules))
for _, r := range s.BlockRules {
out = append(out, r)
}
sort.Slice(out, func(i, j int) bool {
a, b := out[i], out[j]
if a.Name != b.Name {
return CanonicalLess(a.Name+".", b.Name+".")
}
if a.Exact != b.Exact {
return !a.Exact
}
return a.ID < b.ID
})
return out
}
// RecursionOn reports whether a view (0: the server without views) offers
// recursion: its recursion option, else the server's, else BIND's default
// (yes).
func (s *State) RecursionOn(viewID int64) bool {
o := s.FindOption(ScopeView, viewID, "recursion")
if viewID == 0 || o == nil {
if g := s.FindOption(ScopeOptions, 0, "recursion"); o == nil {
o = g
}
}
if o == nil {
return true
}
switch strings.ToLower(strings.Trim(strings.TrimSpace(o.Value), `";`)) {
case "no", "false", "0":
return false
}
return true
}
// BlockingViews returns the views a blocklist or rule with viewIDs applies
// to, in view order: the given views, or when none are given every view
// that offers recursion (blocking only affects recursive queries). Without
// views it returns []int64{0}, the whole server, if it offers recursion.
func (s *State) BlockingViews(viewIDs []int64) []int64 {
views := s.OrderedViews()
if len(views) == 0 {
if !s.RecursionOn(0) {
return nil
}
return []int64{0}
}
var out []int64
for _, v := range views {
if len(viewIDs) == 0 && s.RecursionOn(v.ID) || slices.Contains(viewIDs, v.ID) {
out = append(out, v.ID)
}
}
return out
}
// GroupPrefixes returns the addresses of a client group as prefixes: its
// own, and those of the ACLs it names (and the ACLs they name). Elements
// that aren't addresses are left out (validation reports them).
func (s *State) GroupPrefixes(g *ClientGroup) []netip.Prefix {
var out []netip.Prefix
seen := map[string]bool{}
var add func(elems []string)
add = func(elems []string) {
for _, e := range elems {
e = strings.TrimSpace(e)
if a, err := netip.ParseAddr(e); err == nil {
a = a.Unmap()
out = append(out, netip.PrefixFrom(a, a.BitLen()))
} else if p, err := netip.ParsePrefix(e); err == nil {
out = append(out, p.Masked())
} else if r, ok, err := iprange.Parse(e); ok && err == nil {
out = append(out, r.Prefixes()...)
} else if acl := s.ACLByName(e); acl != nil && !seen[acl.Name] {
seen[acl.Name] = true
add(acl.Elements)
}
}
}
add(g.Members)
return out
}
+95 -6
View File
@@ -9,6 +9,7 @@ package model
import (
"encoding/json"
"slices"
"sort"
"strings"
"time"
@@ -128,6 +129,9 @@ type Zone struct {
// signing is being removed safely (docs/dnssec.md).
DNSSECPolicy string `json:"dnssec_policy,omitempty"`
Comment string `json:"comment"`
// Disabled zones stay in Jarl, records and all, but named doesn't
// load them: their zone statement is left out of named.conf.
Disabled bool `json:"disabled,omitempty"`
Meta
}
@@ -154,6 +158,9 @@ type Record struct {
TTL *uint32 `json:"ttl,omitempty"`
Data string `json:"data"` // canonical presentation format
Comment string `json:"comment"`
// Disabled records stay in Jarl but are left out of the zone (the
// zone file lists them as comments).
Disabled bool `json:"disabled,omitempty"`
Meta
}
@@ -205,7 +212,18 @@ type Settings struct {
Expire uint32 `json:"expire"`
NegativeTTL uint32 `json:"negative_ttl"`
SerialPolicy string `json:"serial_policy"`
Version int `json:"version"`
// BlockingPausedUntil, while set, lets every name through blocking
// (docs/blocking.md); jarld clears it when the time is up.
BlockingPausedUntil *time.Time `json:"blocking_paused_until,omitempty"`
// BlockingPausedIndefinitely pauses blocking until someone resumes it.
BlockingPausedIndefinitely bool `json:"blocking_paused_indefinitely,omitempty"`
Version int `json:"version"`
}
// BlockingPaused reports whether blocking is paused, for a time or until
// resumed.
func (s *Settings) BlockingPaused() bool {
return s.BlockingPausedUntil != nil || s.BlockingPausedIndefinitely
}
// DefaultSettings returns the settings of a fresh installation.
@@ -229,6 +247,11 @@ type State struct {
Zones map[int64]*Zone
Records map[int64]*Record
Options map[int64]*Option
// Blocking (docs/blocking.md).
Blocklists map[int64]*Blocklist
BlockRules map[int64]*BlockRule
BlockSteps map[int64]*BlockStep
ClientGroups map[int64]*ClientGroup
}
// NewState returns an empty state with default settings.
@@ -241,6 +264,11 @@ func NewState() *State {
Zones: map[int64]*Zone{},
Records: map[int64]*Record{},
Options: map[int64]*Option{},
Blocklists: map[int64]*Blocklist{},
BlockRules: map[int64]*BlockRule{},
BlockSteps: map[int64]*BlockStep{},
ClientGroups: map[int64]*ClientGroup{},
}
}
@@ -254,12 +282,17 @@ func (s *State) Clone() *State {
Zones: make(map[int64]*Zone, len(s.Zones)),
Records: make(map[int64]*Record, len(s.Records)),
Options: make(map[int64]*Option, len(s.Options)),
Blocklists: make(map[int64]*Blocklist, len(s.Blocklists)),
BlockRules: make(map[int64]*BlockRule, len(s.BlockRules)),
BlockSteps: make(map[int64]*BlockStep, len(s.BlockSteps)),
ClientGroups: make(map[int64]*ClientGroup, len(s.ClientGroups)),
}
c.Settings.Nameservers = append([]string(nil), s.Settings.Nameservers...)
c.Settings.Nameservers = slices.Clone(s.Settings.Nameservers)
for id, v := range s.Views {
n := *v
n.MatchClients = append([]string(nil), v.MatchClients...)
n.MatchDestinations = append([]string(nil), v.MatchDestinations...)
n.MatchClients = slices.Clone(v.MatchClients)
n.MatchDestinations = slices.Clone(v.MatchDestinations)
if v.MatchRecursiveOnly != nil {
b := *v.MatchRecursiveOnly
n.MatchRecursiveOnly = &b
@@ -268,7 +301,7 @@ func (s *State) Clone() *State {
}
for id, a := range s.ACLs {
n := *a
n.Elements = append([]string(nil), a.Elements...)
n.Elements = slices.Clone(a.Elements)
c.ACLs[id] = &n
}
for id, k := range s.Keys {
@@ -291,6 +324,26 @@ func (s *State) Clone() *State {
n := *o
c.Options[id] = &n
}
for id, b := range s.Blocklists {
n := *b
n.ViewIDs = slices.Clone(b.ViewIDs)
c.Blocklists[id] = &n
}
for id, r := range s.BlockRules {
n := *r
n.ViewIDs = slices.Clone(r.ViewIDs)
c.BlockRules[id] = &n
}
for id, st := range s.BlockSteps {
n := *st
c.BlockSteps[id] = &n
}
for id, g := range s.ClientGroups {
n := *g
n.Members = slices.Clone(g.Members)
n.ViewIDs = slices.Clone(g.ViewIDs)
c.ClientGroups[id] = &n
}
return c
}
@@ -348,6 +401,13 @@ func (s *State) KeyByName(name string) *TSIGKey {
return nil
}
// ViewKeyPrefix starts the names of the TSIG keys Jarl gives views
// (render/viewkeys.go); other keys can't use it.
const ViewKeyPrefix = "jarl-view-"
// ViewKeyName returns the name of a view's key.
func ViewKeyName(view string) string { return ViewKeyPrefix + view }
// NormalizeKeyName returns the canonical form of a key name: lowercase,
// without a trailing dot.
func NormalizeKeyName(name string) string {
@@ -446,6 +506,18 @@ func (s *State) ZoneRecords(zoneID int64) []*Record {
return out
}
// ServedRecords returns the records of a zone that are in the zone file
// (not disabled), in display order.
func (s *State) ServedRecords(zoneID int64) []*Record {
var out []*Record
for _, r := range s.ZoneRecords(zoneID) {
if !r.Disabled {
out = append(out, r)
}
}
return out
}
// RecordsByZone groups all records by zone ID (unsorted).
func (s *State) RecordsByZone() map[int64][]*Record {
out := map[int64][]*Record{}
@@ -455,7 +527,20 @@ func (s *State) RecordsByZone() map[int64][]*Record {
return out
}
// ScopedOptions returns the options of a scope, in position order.
// ServedRecordsByZone is RecordsByZone without disabled records.
func (s *State) ServedRecordsByZone() map[int64][]*Record {
out := map[int64][]*Record{}
for _, r := range s.Records {
if !r.Disabled {
out[r.ZoneID] = append(out[r.ZoneID], r)
}
}
return out
}
// ScopedOptions returns the options of a scope: in a fixed order by name
// for a block (see optionLess), in the order they were added for
// top-level statements, which are written as entered.
func (s *State) ScopedOptions(scope string, scopeID int64) []*Option {
var out []*Option
for _, o := range s.Options {
@@ -463,6 +548,10 @@ func (s *State) ScopedOptions(scope string, scopeID int64) []*Option {
out = append(out, o)
}
}
if scope != ScopeTopLevel {
sort.Slice(out, func(i, j int) bool { return optionLess(out[i], out[j]) })
return out
}
sort.Slice(out, func(i, j int) bool {
if out[i].Position != out[j].Position {
return out[i].Position < out[j].Position
+55
View File
@@ -0,0 +1,55 @@
package model
// optionGroups orders the options of a block (options, a view, a zone) the
// same way everywhere: in named.conf, the web UI and the CLI. Related
// options are grouped; within a group they are alphabetical, and options
// not listed here come last, alphabetically. named doesn't care about the
// order of options in a block.
var optionGroups = [][]string{
// Files and identity.
{"bindkeys-file", "directory", "dump-file", "hostname", "managed-keys-directory", "memstatistics-file", "pid-file", "recursing-file", "secroots-file", "server-id", "session-keyfile", "statistics-file", "version"},
// Listening and source addresses.
{"listen-on", "listen-on-v6", "notify-source", "notify-source-v6", "query-source", "query-source-v6", "transfer-source", "transfer-source-v6"},
// Access control.
{"allow-query", "allow-query-cache", "allow-query-cache-on", "allow-query-on", "allow-recursion", "allow-recursion-on", "allow-update", "allow-update-forwarding", "blackhole", "deny-answer-addresses", "deny-answer-aliases", "update-policy"},
// Recursion, forwarding and the cache.
{"forward", "forwarders", "max-cache-size", "max-cache-ttl", "max-ncache-ttl", "minimal-responses", "prefetch", "qname-minimization", "rate-limit", "recursion", "response-policy", "serve-stale", "stale-answer-enable", "stale-answer-ttl", "stale-cache-enable"},
// DNSSEC.
{"dnssec-accept-expired", "dnssec-must-be-secure", "dnssec-policy", "dnssec-validation", "inline-signing", "key-directory", "trust-anchor-telemetry", "validate-except"},
// Transfers and notify.
{"allow-notify", "allow-transfer", "also-notify", "catalog-zones", "ixfr-from-differences", "masters", "max-transfer-idle-in", "max-transfer-idle-out", "max-transfer-time-in", "max-transfer-time-out", "notify", "notify-to-soa", "primaries", "provide-ixfr", "request-ixfr", "serial-query-rate", "transfer-format", "transfers-in", "transfers-out", "transfers-per-ns"},
}
var optionGroup = func() map[string]int {
m := map[string]int{}
for i, g := range optionGroups {
for _, n := range g {
m[n] = i
}
}
return m
}()
// optionLess orders two options of the same block: by group, then name,
// then (for an option given more than once, like listen-on) the order they
// were added in.
func optionLess(a, b *Option) bool {
ga, ok := optionGroup[a.Name]
if !ok {
ga = len(optionGroups)
}
gb, ok := optionGroup[b.Name]
if !ok {
gb = len(optionGroups)
}
if ga != gb {
return ga < gb
}
if a.Name != b.Name {
return a.Name < b.Name
}
if a.Position != b.Position {
return a.Position < b.Position
}
return a.ID < b.ID
}
+40
View File
@@ -0,0 +1,40 @@
package model
import (
"strings"
"testing"
)
func TestScopedOptionsOrder(t *testing.T) {
s := NewState()
add := func(scope string, names ...string) {
for i, n := range names {
id := int64(len(s.Options) + 1)
s.Options[id] = &Option{ID: id, Scope: scope, Name: n, Position: i}
}
}
// Added in an arbitrary order; listen-on twice.
add(ScopeView, "zzz-unknown", "forwarders", "listen-on", "recursion", "dnssec-validation", "aaa-unknown", "allow-query", "forward", "directory", "listen-on", "allow-transfer")
var got []string
for _, o := range s.ScopedOptions(ScopeView, 0) {
got = append(got, o.Name)
}
want := "directory listen-on listen-on allow-query forward forwarders recursion dnssec-validation allow-transfer aaa-unknown zzz-unknown"
if strings.Join(got, " ") != want {
t.Errorf("order:\n got %s\nwant %s", strings.Join(got, " "), want)
}
// An option given twice keeps the order it was added in.
l := s.ScopedOptions(ScopeView, 0)
if l[1].Position > l[2].Position {
t.Error("repeated listen-on swapped")
}
// Top-level statements stay as entered.
add(ScopeTopLevel, "logging", "acl", "key")
got = nil
for _, o := range s.ScopedOptions(ScopeTopLevel, 0) {
got = append(got, o.Name)
}
if strings.Join(got, " ") != "logging acl key" {
t.Errorf("top level: %v", got)
}
}
+141 -8
View File
@@ -6,6 +6,8 @@ import (
"net/netip"
"strconv"
"strings"
"jarl/internal/iprange"
)
// AMKind is the kind of an address match list element.
@@ -17,6 +19,9 @@ const (
AMName // an ACL name (or built-in ACL)
AMNested // { ... }
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.
@@ -26,10 +31,11 @@ var BuiltinACLs = map[string]bool{"any": true, "none": true, "localhost": true,
type AMElement struct {
Negated bool
Kind AMKind
Prefix netip.Prefix // AMPrefix
Name string // AMKey, AMName
Nested []AMElement // AMNested
Raw string // AMOther
Prefix netip.Prefix // AMPrefix
Name string // AMKey, AMName
Nested []AMElement // AMNested
Raw string // AMOther
Range iprange.Range // AMRange
}
// String returns the element in canonical named.conf syntax, without the
@@ -55,6 +61,8 @@ func (e AMElement) String() string {
s = "{ " + strings.Join(parts, " ") + " }"
case AMOther:
s = e.Raw
case AMRange:
s = e.Range.String()
}
if e.Negated {
return "!" + s
@@ -137,6 +145,7 @@ func parseAMItems(items []Item) (AMElement, error) {
if len(items) == 0 {
return e, errors.New("empty address match element")
}
items = joinRange(items)
first := items[0]
if first.Block != nil {
if len(items) != 1 {
@@ -172,6 +181,16 @@ func parseAMItems(items []Item) (AMElement, error) {
}
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 p, ok, err := parsePrefix(word); ok {
if err != nil {
@@ -215,6 +234,9 @@ func CheckACLName(name string) error {
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
}
@@ -318,18 +340,18 @@ func AMRefs(elems []AMElement) (acls, keys []string) {
func Shadowed(elems []AMElement) []string {
var warns []string
for i, e := range elems {
if e.Kind != AMPrefix && e.Kind != AMName {
if e.Kind != AMPrefix && e.Kind != AMRange && e.Kind != AMName {
continue
}
er, eok := e.addrRange()
for j := 0; j < i; j++ {
prev := elems[j]
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()))
break
}
if prev.Kind == AMPrefix && e.Kind == AMPrefix &&
prev.Prefix.Addr().Is4() == e.Prefix.Addr().Is4() &&
prev.Prefix.Bits() <= e.Prefix.Bits() && prev.Prefix.Contains(e.Prefix.Addr()) {
if pr, ok := prev.addrRange(); ok && eok && pr.Start.Is4() == er.Start.Is4() &&
!er.Start.Less(pr.Start) && !pr.End.Less(er.End) {
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
}
@@ -338,6 +360,117 @@ func Shadowed(elems []AMElement) []string {
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
// negated element would restrict it, which makes the list match everyone.
// It is used for security lints (open resolvers, open transfers).
+3
View File
@@ -30,6 +30,9 @@ type Item struct {
type Stmt struct {
Items []Item
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
+24
View File
@@ -36,6 +36,24 @@ func Compact(items []Item) string {
// CompactStmt returns the statement on a single line with its semicolon.
func CompactStmt(s *Stmt) string { return Compact(s.Items) + ";" }
// FormatValue returns an option's value laid out as named.conf writes it:
// blocks over several lines, indented with tabs. Values without a block,
// and values that don't parse, are returned as they are.
func FormatValue(name, value string) string {
if !strings.Contains(value, "{") {
return value
}
st, err := ParseOptionValue(name, value)
if err != nil {
return value
}
out := strings.TrimSuffix(strings.TrimRight(FormatStmt(st, 0), "\n"), ";")
if rest, ok := strings.CutPrefix(out, st.Keyword()); ok {
return strings.TrimLeft(rest, " ")
}
return value
}
// QuoteString returns s as a named.conf quoted string.
func QuoteString(s string) string {
var sb strings.Builder
@@ -59,6 +77,9 @@ func valueText(v *Value) string {
func writeStmt(sb *strings.Builder, s *Stmt, indent int) {
tabs := strings.Repeat("\t", indent)
if s.Comment != "" {
sb.WriteString(tabs + "// " + s.Comment + "\n")
}
sb.WriteString(tabs)
inline := Compact(s.Items)
if !hasNestedBlocks(s.Items) && len(inline)+indent*8 <= maxInline+8 {
@@ -149,6 +170,9 @@ func writeItemsInline(sb *strings.Builder, items []Item) {
} else {
sb.WriteString("{ ")
for _, s := range it.Block.Stmts {
if s.Comment != "" {
sb.WriteString("/* " + s.Comment + " */ ")
}
writeItemsInline(sb, s.Items)
sb.WriteString("; ")
}
+18
View File
@@ -0,0 +1,18 @@
package namedconf
import "testing"
func TestFormatValue(t *testing.T) {
for _, c := range []struct{ name, value, want string }{
{"logging", `{ channel c { file "/var/log/named/x.log" versions 3; print-time yes; }; category default { c; }; }`,
// A short inner block stays on one line, as in named.conf.
"{\n\tchannel c { file \"/var/log/named/x.log\" versions 3; print-time yes; };\n\tcategory default { c; };\n}"},
{"allow-query", `{ allowed_clients; }`, `{ allowed_clients; }`}, // short: unchanged
{"directory", `"/var/cache/bind"`, `"/var/cache/bind"`},
{"logging", `{ broken`, `{ broken`}, // unparsable: as it is
} {
if got := FormatValue(c.name, c.value); got != c.want {
t.Errorf("FormatValue(%s, %q) =\n%s\nwant\n%s", c.name, c.value, got, c.want)
}
}
}
+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
}
}
+46
View File
@@ -0,0 +1,46 @@
package nscheck
import (
"net/netip"
"os"
"path/filepath"
"slices"
"testing"
)
// Without netlink (a sandboxed jarld), the host's addresses come from /proc.
func TestProcAddrs(t *testing.T) {
dir := t.TempDir()
os.WriteFile(filepath.Join(dir, "if_inet6"), []byte(
"00000000000000000000000000000001 01 80 10 80 lo\n"+
"2a0280137f3d00100000000000000005 02 40 00 80 eth0\n"), 0o644)
os.WriteFile(filepath.Join(dir, "fib_trie"), []byte(`Main:
+-- 0.0.0.0/0 3 0 5
|-- 0.0.0.0
/0 universe UNICAST
+-- 10.0.10.0/24 2 0 2
|-- 10.0.10.0
/24 link UNICAST
|-- 10.0.10.5
/32 host LOCAL
|-- 10.0.10.255
/32 link BROADCAST
Local:
+-- 0.0.0.0/0 3 0 5
|-- 10.0.10.5
/32 host LOCAL
`), 0o644)
got := procAddrs(dir)
for _, want := range []string{"::1", "2a02:8013:7f3d:10::5", "10.0.10.5"} {
if !slices.Contains(got, netip.MustParseAddr(want)) {
t.Errorf("%s missing from %v", want, got)
}
}
if len(got) != 3 {
t.Errorf("got %v (10.0.10.5 once, no broadcast or network addresses)", got)
}
// And the real ones on this machine: at least loopback.
if len(procAddrs("/proc/net")) == 0 {
t.Log("no addresses in /proc/net here")
}
}
+52
View File
@@ -0,0 +1,52 @@
package nscheck
import (
"context"
"net"
"net/netip"
"testing"
"time"
"github.com/miekg/dns"
)
// A query to this host's own address is signed with LocalKey, so a server
// with views answers from the view that key selects.
func TestLocalKey(t *testing.T) {
zone := "example.com."
key := &Key{Name: "jarl-view-external.", Algorithm: "hmac-sha256", Secret: "c2VjcmV0LXNlY3JldC1zZWNyZXQtc2VjcmV0LXNlY3I="}
pc, err := net.ListenPacket("udp", "127.0.0.1:0")
if err != nil {
t.Skip(err)
}
_, port, _ := net.SplitHostPort(pc.LocalAddr().String())
srv := &dns.Server{PacketConn: pc, TsigSecret: map[string]string{key.Name: key.Secret},
Handler: dns.HandlerFunc(func(w dns.ResponseWriter, req *dns.Msg) {
// Signed with the view key: the "external" view's serial.
serial := "1"
signed := req.IsTsig() != nil && w.TsigStatus() == nil
if signed {
serial = "2"
}
m := new(dns.Msg)
m.SetReply(req)
m.Authoritative = true
m.Answer = []dns.RR{rr(zone + " 3600 IN SOA ns1." + zone + " admin." + zone + " " + serial + " 3600 900 1209600 300")}
if signed {
m.SetTsig(key.Name, dns.HmacSHA256, 300, time.Now().Unix())
}
w.WriteMsg(m)
})}
go srv.ActivateAndServe()
t.Cleanup(func() { srv.Shutdown() })
ref := uint32(2)
c := &Checker{Timeout: time.Second, Port: port, IsLocal: func(netip.Addr) bool { return false }}
if res := c.Serials(context.Background(), zone, &ref, []Target{{Addresses: []string{"127.0.0.1"}}}); res[0].Status != "behind" {
t.Errorf("unsigned: %+v (want the other view's serial)", res[0])
}
c.LocalKey = key
if res := c.Serials(context.Background(), zone, &ref, []Target{{Addresses: []string{"127.0.0.1"}}}); res[0].Status != "in_sync" {
t.Errorf("signed with the view key: %+v", res[0])
}
}
+70 -11
View File
@@ -15,10 +15,13 @@ package nscheck
import (
"context"
"encoding/hex"
"errors"
"fmt"
"net"
"net/netip"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
@@ -95,6 +98,11 @@ type Checker struct {
// IsLocal reports whether an address belongs to this host (default:
// its interface addresses and loopback).
IsLocal func(netip.Addr) bool
// LocalKey, when set, signs the queries sent to this host's own
// addresses (when they are asked): with views, the key that selects
// the zone's view, so the answer comes from that view and not from
// whichever view matches this host's address.
LocalKey *Key
// Watch, if set, is told about each DNS query the checks send and
// its outcome as it happens (the web UI shows them while it waits).
// It is called from several goroutines at once.
@@ -252,20 +260,67 @@ func IsLocalAddr(a netip.Addr) bool {
if a.IsLoopback() {
return true
}
addrs, err := net.InterfaceAddrs()
if err != nil {
return false
}
for _, ia := range addrs {
if n, ok := ia.(*net.IPNet); ok {
if b, ok := netip.AddrFromSlice(n.IP); ok && b.Unmap() == a.Unmap() {
return true
}
for _, b := range LocalAddrs() {
if b == a.Unmap() {
return true
}
}
return false
}
// LocalAddrs returns this host's interface addresses. Listing them needs a
// netlink socket; where that is not allowed (a systemd unit without
// AF_NETLINK in RestrictAddressFamilies), they are read from /proc.
func LocalAddrs() []netip.Addr {
var out []netip.Addr
if addrs, err := net.InterfaceAddrs(); err == nil {
for _, ia := range addrs {
if n, ok := ia.(*net.IPNet); ok {
if b, ok := netip.AddrFromSlice(n.IP); ok {
out = append(out, b.Unmap())
}
}
}
return out
}
return procAddrs("/proc/net")
}
// procAddrs reads the host's addresses from /proc/net: IPv6 from
// if_inet6, IPv4 from the local (/32 host LOCAL) entries of fib_trie.
func procAddrs(dir string) []netip.Addr {
var out []netip.Addr
if b, err := os.ReadFile(filepath.Join(dir, "if_inet6")); err == nil {
for _, line := range strings.Split(string(b), "\n") {
f := strings.Fields(line)
if len(f) == 0 || len(f[0]) != 32 {
continue
}
raw, err := hex.DecodeString(f[0])
if err != nil {
continue
}
if a, ok := netip.AddrFromSlice(raw); ok {
out = append(out, a)
}
}
}
if b, err := os.ReadFile(filepath.Join(dir, "fib_trie")); err == nil {
seen := map[netip.Addr]bool{}
var last netip.Addr
for _, line := range strings.Split(string(b), "\n") {
t := strings.TrimSpace(line)
if strings.HasPrefix(t, "|-- ") {
last, _ = netip.ParseAddr(strings.TrimPrefix(t, "|-- "))
} else if strings.HasPrefix(t, "/32 host LOCAL") && last.IsValid() && !seen[last] {
seen[last] = true
out = append(out, last)
}
}
}
return out
}
// SystemResolver returns the first nameserver in /etc/resolv.conf as
// host:port.
func SystemResolver() (string, error) {
@@ -465,13 +520,17 @@ func (c *Checker) Serials(ctx context.Context, zone string, ref *uint32, targets
}
// Reported here rather than by exchange: the end carries the
// server's status.
id := c.startEvent(digLine(r.Address, port, zone, "SOA", true, t.Key))
key := t.Key
if key == nil && c.LocalKey != nil && IsLocalAddr(ip) {
key = c.LocalKey
}
id := c.startEvent(digLine(r.Address, port, zone, "SOA", true, key))
go func(r Result, key *Key) {
defer wg.Done()
c.soa(ctx, zone, key, ref, &r)
c.endEvent(Event{ID: id, Result: &r, RTTMs: r.RTTMs})
add(r)
}(r, t.Key)
}(r, key)
}
}
wg.Wait()
+239
View File
@@ -0,0 +1,239 @@
package render
import (
"fmt"
"net/netip"
"path/filepath"
"slices"
"strings"
"jarl/internal/blocking"
"jarl/internal/model"
"jarl/internal/namedconf"
)
// KindPolicy is the kind of a rendered rules policy zone file.
const KindPolicy = "policy"
// PolicyZone is a response policy zone in named's configuration
// (docs/blocking.md).
type PolicyZone struct {
Name string // without the final dot
ViewID int64 // 0 without views
View string // "" without views
File string
ListID int64 // the blocklist; 0 for the rules zone and gates
// Gate, for a gate zone: the clients it lets through (past every zone
// after it), and the step whose lists it is in front of.
Gate []netip.Prefix
GateStep string
}
// IsGate reports whether z is a gate zone.
func (z PolicyZone) IsGate() bool { return z.GateStep != "" }
// BlocklistsDir holds the zone files of downloaded blocklists, one per
// list, shared by the views that use it.
func (p Paths) BlocklistsDir() string { return filepath.Join(p.CacheDir, "blocklists") }
// BlocklistFile returns the zone file of a blocklist. It is named after
// the list's ID, so renaming the list doesn't move it.
func (p Paths) BlocklistFile(id int64) string {
return filepath.Join(p.BlocklistsDir(), fmt.Sprintf("list-%d.rpz", id))
}
// rulesFile returns a view's rules zone file.
func (p Paths) rulesFile(view string) string {
return filepath.Join(p.ZoneDir, ViewDir(view), blocking.RulesZone+".rpz")
}
// PolicyZones returns the policy zones named loads, per view in view
// order, each view's in the order named checks them: the rules zone first
// (rules win over lists), then for each step (normal, then the steps above
// it) a gate letting through the clients below that step, when there are
// any, and the step's enabled lists by name.
func PolicyZones(s *model.State, p Paths) []PolicyZone {
var out []PolicyZone
rules, lists := s.SortedBlockRules(), s.SortedBlocklists()
scopes := []int64{0}
if len(s.Views) > 0 {
scopes = nil
for _, v := range s.OrderedViews() {
scopes = append(scopes, v.ID)
}
}
for _, vid := range scopes {
view := ""
if v := s.Views[vid]; v != nil {
view = v.Name
}
var zones []PolicyZone
hasRules := false
for _, r := range rules {
if slices.Contains(s.BlockingViews(r.ViewIDs), vid) {
hasRules = true
break
}
}
assigns := ClientAssignments(s, vid)
for k, step := range append([]int64{model.StepNormal}, stepIDs(s)...) {
var tier []PolicyZone
for _, b := range lists {
if !b.Disabled && b.StepID == step && slices.Contains(s.BlockingViews(b.ViewIDs), vid) {
tier = append(tier, PolicyZone{Name: blocking.ListZone(b.Name), ViewID: vid, View: view, File: p.BlocklistFile(b.ID), ListID: b.ID})
}
}
if len(tier) == 0 {
continue
}
// The clients below this step pass, and never see its lists.
if pass := blocking.PassBelow(assigns, k); len(pass) > 0 {
name := "off"
if k > 0 {
name = s.StepName(step)
}
zones = append(zones, PolicyZone{Name: blocking.GateZone(name), ViewID: vid, View: view, File: p.gateFile(view, name), Gate: pass, GateStep: s.StepName(step)})
}
zones = append(zones, tier...)
}
// While paused, every view with blocking gets a rules zone: it
// lets everything through.
if hasRules || s.Settings.BlockingPaused() && len(zones) > 0 {
out = append(out, PolicyZone{Name: blocking.RulesZone, ViewID: vid, View: view, File: p.rulesFile(view)})
}
out = append(out, zones...)
}
return out
}
// gateFile returns a view's gate zone file for a step.
func (p Paths) gateFile(view, name string) string {
return filepath.Join(p.ZoneDir, ViewDir(view), blocking.GateZone(name)+".rpz")
}
// stepIDs returns the IDs of the steps above normal, lowest first.
func stepIDs(s *model.State) []int64 {
var out []int64
for _, st := range s.OrderedBlockSteps() {
out = append(out, st.ID)
}
return out
}
// ClientAssignments returns the step ranks the client groups of a view
// give addresses (blocking.PassBelow).
func ClientAssignments(s *model.State, viewID int64) []blocking.Assignment {
var out []blocking.Assignment
for _, g := range s.SortedClientGroups() {
if !slices.Contains(s.BlockingViews(g.ViewIDs), viewID) {
continue
}
rank, ok := s.StepRank(g.StepID)
if !ok {
continue
}
for _, p := range s.GroupPrefixes(g) {
out = append(out, blocking.Assignment{Prefix: p, Rank: rank})
}
}
return out
}
// ViewRules returns the rules that apply in a view (0 without views).
func ViewRules(s *model.State, viewID int64) []blocking.Rule {
var out []blocking.Rule
for _, r := range s.SortedBlockRules() {
if slices.Contains(s.BlockingViews(r.ViewIDs), viewID) {
out = append(out, blocking.Rule{Name: r.Name, Action: r.Action, Exact: r.Exact})
}
}
return out
}
// writeResponsePolicy writes the response-policy statement for a view's
// policy zones (or the server's, without views). Rewrites are logged to
// Jarl's response log when statistics read it, and not at all otherwise
// (they would go to named's default log).
func writeResponsePolicy(w *confWriter, zones []PolicyZone, logRewrites bool) {
if len(zones) == 0 {
return
}
w.begin("blocking", 0, "blocking (rules and blocklists)")
w.write("\t// Blocking (docs/blocking.md): rules first, then blocklists.\n\tresponse-policy {\n")
for _, z := range zones {
// Apply a new version at once: Jarl reloads a list only when it
// changed (at most every blocking.refresh), so named's default
// delay for batching frequent updates only adds a wait.
opts := []string{"min-update-interval 0"}
// A gate lets most queries through: logging that would double
// the response log, and it blocks nothing.
if !logRewrites || z.IsGate() {
opts = append(opts, "log no")
}
w.write(fmt.Sprintf("\t\tzone %s%s;\n", namedconf.QuoteString(z.Name), prefixSpace(strings.Join(opts, " "))))
}
// Block without waiting for the real answer, and also for clients
// that ask for DNSSEC data.
w.write("\t} qname-wait-recurse no break-dnssec yes;\n")
w.end()
}
func prefixSpace(s string) string {
if s == "" {
return ""
}
return " " + s
}
// writePolicyZones writes the zone statements of policy zones and adds
// their files to the output: rules zone files are rendered, blocklist
// files only listed (jarld downloads them; see Output.Blocklists).
func writePolicyZones(w *confWriter, s *model.State, zones []PolicyZone, out *Output, indent int) {
tabs := strings.Repeat("\t", indent)
for _, z := range zones {
w.write("\n")
label := "blocking: your rules"
switch {
case z.ListID != 0:
label = "blocking: blocklist " + s.Blocklists[z.ListID].Name
case z.IsGate():
label = "blocking: clients below step " + z.GateStep
}
w.begin("blocking", z.ListID, label)
w.write(fmt.Sprintf("%szone %s {\n%s\ttype primary;\n%s\tfile %s;\n%s\tallow-query { none; };\n%s\tallow-transfer { none; };\n%s\tnotify no;\n%s};\n",
tabs, namedconf.QuoteString(z.Name), tabs, tabs, namedconf.QuoteString(z.File), tabs, tabs, tabs, tabs))
w.end()
if z.ListID != 0 {
out.Blocklists = appendUnique(out.Blocklists, z.File)
continue
}
if z.IsGate() {
title := "Clients that skip the lists from step " + z.GateStep + " on"
if z.View != "" {
title += " in view " + z.View
}
out.Files = append(out.Files, &File{Path: z.File, Content: []byte(blocking.GateZoneFile(title, z.Gate)), Mode: 0o640, Kind: KindPolicy})
continue
}
title := "Blocking rules"
if z.View != "" {
title += " for view " + z.View
}
content := blocking.RulesZoneFile(title, ViewRules(s, z.ViewID))
if s.Settings.BlockingPaused() {
content = blocking.PausedZoneFile(title)
}
out.Files = append(out.Files, &File{Path: z.File, Content: []byte(content), Mode: 0o640, Kind: KindPolicy})
}
}
// scopeZones returns the policy zones of one view (0 without views).
func scopeZones(all []PolicyZone, viewID int64) []PolicyZone {
var out []PolicyZone
for _, z := range all {
if z.ViewID == viewID {
out = append(out, z)
}
}
return out
}
+42 -11
View File
@@ -69,6 +69,11 @@ type Output struct {
Spans map[string][]Span
// ZoneFiles maps zone IDs to the file named loads for the zone.
ZoneFiles map[int64]string
// Policy lists the response policy zones (docs/blocking.md).
Policy []PolicyZone
// Blocklists are the zone files of blocklists named loads. jarld
// downloads them; until then they must exist, as empty policy zones.
Blocklists []string
}
// File returns the rendered file at path, or nil.
@@ -144,7 +149,7 @@ const headerText = "Generated by Jarl. DO NOT EDIT: changes made here are overwr
// from jarld's configuration (e.g. for query statistics).
func Render(s *model.State, p Paths, x Extras) *Output {
out := &Output{Spans: map[string][]Span{}, ZoneFiles: map[int64]string{}}
keys := renderKeys(s)
keys := renderKeys(s) + renderViewKeys(s, x)
out.Files = append(out.Files, &File{Path: p.KeysConfig(), Content: []byte(keys), Mode: 0o640, Kind: KindKeys, Secret: true})
conf, spans := renderConfig(s, p, x, out)
out.Files = append(out.Files, &File{Path: p.MainConfig(), Content: []byte(conf), Mode: 0o640, Kind: KindConfig})
@@ -224,7 +229,7 @@ func optionText(o *model.Option, indent int) string {
// named-checkconf reports the problem.
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 {
@@ -264,6 +269,9 @@ func renderConfig(s *model.State, p Paths, x Extras, out *Output) (string, []Spa
st := x.Stats
logResponses := st != nil && st.LogFile != ""
pzs := PolicyZones(s, p)
out.Policy = pzs
logRewrites := logResponses && len(pzs) > 0
sawStats, sawLogging := false, false
if top := s.ScopedOptions(model.ScopeTopLevel, 0); len(top) > 0 {
w.write("\n")
@@ -274,7 +282,7 @@ func renderConfig(s *model.State, p Paths, x Extras, out *Output) (string, []Spa
w.write(st.statsStatement(o))
sawStats = true
case logResponses && o.Name == "logging" && !sawLogging:
w.write(st.loggingStatement(o))
w.write(st.loggingStatement(o, logRewrites))
sawLogging = true
default:
w.write(optionText(o, 0))
@@ -289,7 +297,7 @@ func renderConfig(s *model.State, p Paths, x Extras, out *Output) (string, []Spa
w.write(st.statsStatement(nil))
}
if logResponses && !sawLogging {
w.write(st.loggingStatement(nil))
w.write(st.loggingStatement(nil, logRewrites))
}
w.end()
}
@@ -305,7 +313,7 @@ func renderConfig(s *model.State, p Paths, x Extras, out *Output) (string, []Spa
if err != nil {
w.write("acl " + namedconf.QuoteString(a.Name) + " " + amlBlock(a.Elements) + ";\n")
} else {
w.write(namedconf.FormatStmt(st, 0))
w.write(formatStmt(st, 0))
}
w.end()
}
@@ -322,10 +330,14 @@ func renderConfig(s *model.State, p Paths, x Extras, out *Output) (string, []Spa
w.write("\t// " + statsComment + "\n\tresponselog yes;\n")
w.end()
}
w.write("};\n")
views := s.OrderedViews()
if len(views) == 0 {
writeResponsePolicy(w, scopeZones(pzs, 0), logResponses)
}
w.write("};\n")
if len(views) == 0 {
writePolicyZones(w, s, scopeZones(pzs, 0), out, 0)
for _, z := range s.ZonesInView(0) {
w.write("\n")
renderZone(w, s, z, p, out, 0)
@@ -338,8 +350,8 @@ func renderConfig(s *model.State, p Paths, x Extras, out *Output) (string, []Spa
w.write(commentBlock("//", v.Comment))
}
w.write(fmt.Sprintf("view %s {\n", namedconf.QuoteString(v.Name)))
if len(v.MatchClients) > 0 {
writeAML(w, "match-clients", v.MatchClients)
if mc := viewMatchClients(s, x, v); len(mc) > 0 {
writeAML(w, "match-clients", mc)
}
if len(v.MatchDestinations) > 0 {
writeAML(w, "match-destinations", v.MatchDestinations)
@@ -352,6 +364,8 @@ func renderConfig(s *model.State, p Paths, x Extras, out *Output) (string, []Spa
w.write(optionText(o, 1))
w.end()
}
writeResponsePolicy(w, scopeZones(pzs, v.ID), logResponses)
writePolicyZones(w, s, scopeZones(pzs, v.ID), out, 1)
for _, z := range s.ZonesInView(v.ID) {
w.write("\n")
renderZone(w, s, z, p, out, 1)
@@ -368,7 +382,7 @@ func writeAML(w *confWriter, name string, elems []string) {
w.write("\t" + name + " " + amlBlock(elems) + ";\n")
return
}
w.write(namedconf.FormatStmt(st, 1))
w.write(formatStmt(st, 1))
}
func yesNo(b bool) string {
@@ -384,6 +398,12 @@ func renderZone(w *confWriter, s *model.State, z *model.Zone, p Paths, out *Outp
if v := s.ViewName(z); v != "" {
label += " [view " + v + "]"
}
if z.Disabled {
// Kept in Jarl (its zone file too, so enabling it is quick), not
// loaded by named.
w.write(fmt.Sprintf("%s// zone %s is disabled in Jarl: named doesn't load it.\n", tabs, namedconf.QuoteString(dnsname.Display(z.Name))))
return
}
w.begin("zone", z.ID, label)
if z.Comment != "" {
w.write(indentLines(commentBlock("//", z.Comment), indent))
@@ -461,6 +481,10 @@ func ZoneFile(s *model.State, z *model.Zone) string {
if r.Comment != "" {
line += "\t; " + oneLine(r.Comment)
}
if r.Disabled {
// Kept in Jarl, left out of the zone.
line = "; disabled: " + line
}
sb.WriteString(line + "\n")
}
return sb.String()
@@ -478,8 +502,15 @@ func ZoneContentHash(s *model.State, z *model.Zone) string {
h := sha256.New()
soa := z.SOA
fmt.Fprintf(h, "%s|%d|%s|%s|%d|%d|%d|%d\n", z.Name, z.DefaultTTL, soa.MName, soa.RName, soa.Refresh, soa.Retry, soa.Expire, soa.Minimum)
for _, r := range s.ZoneRecords(z.ID) {
for _, r := range s.ServedRecords(z.ID) {
fmt.Fprintf(h, "%s|%d|%s|%s\n", r.Name, r.EffectiveTTL(z), r.Type, r.Data)
}
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)
}
+23 -13
View File
@@ -15,6 +15,9 @@ type Extras struct {
// Stats, when set, makes named publish what Jarl's statistics
// collector reads.
Stats *StatsHooks
// ViewKeySeed, when set, gives every view a TSIG key that selects it
// (viewkeys.go).
ViewKeySeed []byte
}
// StatsHooks describe the statistics channel and response log Jarl needs.
@@ -59,18 +62,19 @@ func (h *StatsHooks) statsStatement(user *model.Option) string {
}
}
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.
return optionText(user, 0)
}
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
// the responses category, merged into the user's own if there is one.
func (h *StatsHooks) loggingStatement(user *model.Option) string {
// the responses category (and rpz, for blocking's rewrites), merged into
// the user's own if there is one.
func (h *StatsHooks) loggingStatement(user *model.Option, rpz bool) string {
var st *namedconf.Stmt
if user != nil {
var err error
@@ -84,21 +88,27 @@ func (h *StatsHooks) loggingStatement(user *model.Option) string {
b := st.FirstBlock()
// Channels must be defined before the categories that use them.
b.Stmts = append([]*namedconf.Stmt{h.logChannel()}, b.Stmts...)
merged := false
for _, s := range b.Stmts {
if s.Keyword() == "category" && strings.EqualFold(s.Arg(0), "responses") && s.FirstBlock() != nil {
s.FirstBlock().Stmts = append(s.FirstBlock().Stmts, namedconf.NewStmt(StatsChannelName))
merged = true
}
categories := []string{"responses"}
if rpz {
categories = append(categories, "rpz")
}
if !merged {
b.Stmts = append(b.Stmts, namedconf.NewStmt("category", "responses", namedconf.NewBlock(namedconf.NewStmt(StatsChannelName))))
for _, cat := range categories {
merged := false
for _, s := range b.Stmts {
if s.Keyword() == "category" && strings.EqualFold(s.Arg(0), cat) && s.FirstBlock() != nil {
s.FirstBlock().Stmts = append(s.FirstBlock().Stmts, namedconf.NewStmt(StatsChannelName))
merged = true
}
}
if !merged {
b.Stmts = append(b.Stmts, namedconf.NewStmt("category", cat, namedconf.NewBlock(namedconf.NewStmt(StatsChannelName))))
}
}
prefix := ""
if user != nil {
prefix = commentLines(user.Comment)
}
return prefix + commentBlock("//", statsComment) + namedconf.FormatStmt(st, 0)
return prefix + commentBlock("//", statsComment) + formatStmt(st, 0)
}
func commentLines(c string) string {
+84
View File
@@ -0,0 +1,84 @@
package render
import (
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"strconv"
"strings"
"jarl/internal/model"
"jarl/internal/namedconf"
)
// View keys let Jarl choose a view regardless of where a query comes from:
// each view gets a TSIG key, and its match-clients starts with that key
// (after excluding the keys of the views listed after it, so no earlier
// view takes their queries). Queries signed with a view's key always land
// in that view. Jarl signs its own checks of this server's addresses with
// them (a query from this host would otherwise land in whichever view
// matches this host's address), and you can too: dig -k.
//
// The secrets are derived from a random seed kept in Jarl's database
// (Extras.ViewKeySeed), so they stay the same from one apply to the next
// and need no storage of their own. They are in keys.conf with the other
// keys.
// ViewKeyAlgorithm is the view keys' algorithm.
const ViewKeyAlgorithm = "hmac-sha256"
// ViewKeyName returns the name of a view's key.
func ViewKeyName(view string) string { return model.ViewKeyName(view) }
// IsViewKey reports whether a key name is a view key's.
func IsViewKey(name string) bool {
return strings.HasPrefix(strings.ToLower(strings.TrimSuffix(name, ".")), model.ViewKeyPrefix)
}
// ViewKeySecret returns the secret of the key of the view with this ID.
func ViewKeySecret(seed []byte, viewID int64) string {
m := hmac.New(sha256.New, seed)
m.Write([]byte("jarl view key " + strconv.FormatInt(viewID, 10)))
return base64.StdEncoding.EncodeToString(m.Sum(nil))
}
// viewKeysOn reports whether view keys are rendered.
func viewKeysOn(s *model.State, x Extras) bool { return len(x.ViewKeySeed) > 0 && len(s.Views) > 0 }
// renderViewKeys returns the key statements of the views' keys.
func renderViewKeys(s *model.State, x Extras) string {
if !viewKeysOn(s, x) {
return ""
}
var sb strings.Builder
sb.WriteString("\n" + commentBlock("//", "View keys: a query signed with one of these always lands in its view.\nJarl signs its own checks of this server with them; dig -k works too."))
for _, v := range s.OrderedViews() {
sb.WriteString("key " + namedconf.QuoteString(ViewKeyName(v.Name)) + " {\n\talgorithm " + ViewKeyAlgorithm + ";\n\tsecret " + namedconf.QuoteString(ViewKeySecret(x.ViewKeySeed, v.ID)) + ";\n};\n")
}
return sb.String()
}
// viewMatchClients returns a view's match-clients with the view keys in
// front: "!key" for the keys of the views after it, then its own key, then
// the view's own list ("any" when it has none, which is what an absent
// match-clients means).
func viewMatchClients(s *model.State, x Extras, v *model.View) []string {
if !viewKeysOn(s, x) {
return v.MatchClients
}
var out []string
after := false
for _, o := range s.OrderedViews() {
if after {
out = append(out, "!key "+namedconf.QuoteString(ViewKeyName(o.Name)))
}
if o.ID == v.ID {
after = true
}
}
out = append(out, "key "+namedconf.QuoteString(ViewKeyName(v.Name)))
if len(v.MatchClients) == 0 {
return append(out, "any")
}
return append(out, v.MatchClients...)
}
+51
View File
@@ -0,0 +1,51 @@
package render
import (
"strings"
"testing"
"jarl/internal/model"
)
func TestViewKeys(t *testing.T) {
s := model.NewState()
s.Views[1] = &model.View{ID: 1, Name: "internal", Position: 0, MatchClients: []string{"homelab", "localhost"}}
s.Views[2] = &model.View{ID: 2, Name: "external", Position: 1}
p := Paths{ConfigDir: "/etc/bind/jarl", ZoneDir: "/var/lib/bind/jarl", CacheDir: "/var/cache/bind/jarl"}
seed := []byte("0123456789abcdef0123456789abcdef")
files := func(x Extras) (conf, keys string) {
for _, f := range Render(s, p, x).Files {
switch f.Kind {
case KindConfig:
conf = string(f.Content)
case KindKeys:
keys = string(f.Content)
}
}
return
}
conf, keys := files(Extras{ViewKeySeed: seed})
for _, want := range []string{
// An earlier view mustn't take a later view's key.
"match-clients {\n\t\t!key jarl-view-external;\n\t\tkey jarl-view-internal;\n\t\thomelab;\n\t\tlocalhost;\n\t};",
// A view without a list matched everyone: it still does.
"match-clients { key jarl-view-external; any; };",
} {
if !strings.Contains(conf, want) {
t.Errorf("named.conf lacks\n%s\n---\n%s", want, conf)
}
}
sec := ViewKeySecret(seed, 2)
if !strings.Contains(keys, "key \"jarl-view-external\" {\n\talgorithm hmac-sha256;\n\tsecret \""+sec+"\";\n};") || !strings.Contains(keys, "jarl-view-internal") {
t.Errorf("keys.conf:\n%s", keys)
}
// The same seed gives the same secret; another seed doesn't.
if ViewKeySecret(seed, 2) != sec || ViewKeySecret([]byte("other"), 2) == sec || ViewKeySecret(seed, 1) == sec {
t.Error("secrets aren't derived from the seed and the view")
}
// Without a seed (or without views), nothing changes.
conf, keys = files(Extras{})
if strings.Contains(conf, "jarl-view-") || strings.Contains(keys, "jarl-view-") {
t.Errorf("view keys without a seed:\n%s", conf)
}
}
+111
View File
@@ -0,0 +1,111 @@
package server
import (
"net/http"
"net/http/httptest"
"testing"
)
func TestBlockingAPI(t *testing.T) {
e := newAPIEnv(t)
lists := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/missing" {
http.NotFound(w, r)
return
}
w.Write([]byte("||ads.example^\n0.0.0.0 tracker.example\n"))
}))
defer lists.Close()
b := e.do("POST", "/blocking/lists", e.token, map[string]any{"name": "ads", "url": lists.URL + "/list.txt"}, 201)["object"].(map[string]any)
if b["name"] != "ads" || b["state"] != "waiting" {
t.Errorf("new list = %v", b)
}
got := e.do("POST", "/blocking/lists/ads/refresh", e.token, nil, 200)
st, _ := got["status"].(map[string]any)
if got["state"] != "ok" || st["entries"] != 2.0 || st["records"] != 3.0 {
t.Errorf("after refresh = %v", got)
}
e.do("POST", "/blocking/rules", e.token, map[string]any{"name": "Good.Ads.Example.", "action": "allow"}, 201)
r := e.do("POST", "/blocking/rules", e.token, map[string]any{"name": "more.example", "exact": true}, 201)["object"].(map[string]any)
if r["name"] != "more.example" || r["action"] != "block" || r["exact"] != true {
t.Errorf("new rule = %v", r)
}
for name, want := range map[string]string{"x.ads.example": "blocked", "a.good.ads.example": "allowed", "more.example": "blocked", "sub.more.example": "none"} {
m := e.list("/blocking/check?name="+name, e.token)
if len(m) != 1 || m[0]["result"] != want {
t.Errorf("check %s = %v, want %s", name, m, want)
}
}
if _, status := e.req("GET", "/blocking/check?name=com", e.token, nil); status != 400 {
t.Errorf("check of a TLD = %d", status)
}
// Staged changes show as pending.
cs := itoa(int64(e.do("POST", "/changesets", e.token, map[string]any{"description": "more"}, 201)["id"].(float64)))
e.do("POST", "/blocking/rules?changeset="+cs, e.token, map[string]any{"name": "staged.example"}, 202)
e.do("PATCH", "/blocking/lists/ads?changeset="+cs, e.token, map[string]any{"disabled": true}, 202)
pending := map[string]any{}
for _, x := range e.list("/blocking/rules?changeset="+cs, e.token) {
pending[x["name"].(string)] = x["pending"]
}
if pending["staged.example"] != "add" || pending["more.example"] != nil {
t.Errorf("pending rules = %v", pending)
}
if l := e.list("/blocking/lists?changeset="+cs, e.token); len(l) != 1 || l[0]["pending"] != "change" || l[0]["state"] != "disabled" {
t.Errorf("pending list = %v", l)
}
e.do("POST", "/changesets/"+cs+"/discard", e.token, nil, 204)
// Delete rules by name; a missing one is a 404 unless missing_ok.
e.do("DELETE", "/blocking/rules?name=more.example", e.token, nil, 200)
if _, status := e.req("DELETE", "/blocking/rules?name=more.example", e.token, nil); status != 404 {
t.Errorf("deleting a missing rule = %d", status)
}
e.do("DELETE", "/blocking/rules?name=more.example&missing_ok=1", e.token, nil, 200)
// Readers see blocking but can't change it.
e.do("POST", "/users", e.token, map[string]any{"username": "reader", "password": "reader-password-123", "grant_strings": []string{"blocking:read"}}, 201)
var readerID int64
for _, u := range e.list("/users", e.token) {
if u["username"] == "reader" {
readerID = int64(u["id"].(float64))
}
}
rt := e.do("POST", "/users/"+itoa(readerID)+"/tokens", e.token, map[string]any{"name": "t"}, 201)["token"].(string)
if l := e.list("/blocking/lists", rt); len(l) != 1 {
t.Errorf("reader's lists = %v", l)
}
for _, c := range []struct{ method, path string }{{"POST", "/blocking/rules"}, {"POST", "/blocking/lists/ads/refresh"}, {"DELETE", "/blocking/lists/ads"}} {
if _, status := e.req(c.method, c.path, rt, map[string]any{"name": "x.example"}); status != 403 {
t.Errorf("reader %s %s = %d", c.method, c.path, status)
}
}
// Pausing.
if _, status := e.req("POST", "/blocking/pause", e.token, map[string]any{"minutes": 0}); status != 400 {
t.Errorf("a 0-minute pause = %d", status)
}
e.do("POST", "/blocking/pause", e.token, map[string]any{"minutes": 30}, 201)
if st := e.do("GET", "/blocking/status", e.token, nil, 200); st["paused_until"] == nil {
t.Errorf("status while paused = %v", st)
}
if st := e.do("GET", "/status", e.token, nil, 200); st["blocking_paused_until"] == nil {
t.Errorf("dashboard while paused = %v", st["blocking_paused_until"])
}
e.do("POST", "/blocking/resume", e.token, nil, 201)
if st := e.do("GET", "/blocking/status", e.token, nil, 200); st["paused_until"] != nil {
t.Errorf("status after resuming = %v", st)
}
// A list that can't be downloaded shows on the dashboard.
e.do("POST", "/blocking/lists", e.token, map[string]any{"name": "gone", "url": lists.URL + "/missing"}, 201)
if l := e.do("POST", "/blocking/lists/gone/refresh", e.token, nil, 200); l["state"] != "error" {
t.Errorf("failed list = %v", l)
}
errs, _ := e.do("GET", "/status", e.token, nil, 200)["blocklist_errors"].([]any)
if len(errs) != 1 || errs[0].(map[string]any)["name"] != "gone" {
t.Errorf("status blocklist_errors = %v", errs)
}
e.do("DELETE", "/blocking/lists/gone", e.token, nil, 200)
e.do("DELETE", "/blocking/lists/ads", e.token, nil, 200)
}
+38
View File
@@ -0,0 +1,38 @@
package server
import (
"net/netip"
"testing"
"jarl/internal/model"
)
func TestClientNames(t *testing.T) {
st := model.NewState()
add := func(id int64, name, typ, data string, disabled bool) {
st.Records[id] = &model.Record{ID: id, ZoneID: 1, Name: name, Type: typ, Data: data, Disabled: disabled}
}
add(1, "nas.adbraun.lol.", "A", "192.168.1.20", false)
add(2, "20.1.168.192.in-addr.arpa.", "PTR", "storage.adbraun.lol.", false) // PTR wins over A
add(3, "web.adbraun.lol.", "A", "192.168.1.30", false) // no PTR: the A record's name
add(4, "alias.adbraun.lol.", "A", "192.168.1.30", false) // first in name order wins
add(5, "old.adbraun.lol.", "A", "192.168.1.40", true) // disabled: ignored
add(6, "secret.adbraun.lol.", "A", "192.168.1.50", false) // not readable
add(7, "v6.adbraun.lol.", "AAAA", "2001:db8::7", false)
add(8, "printer.adbraun.lol.", "A", "192.168.1.60", false) // not asked for
want := map[netip.Addr]bool{}
for _, a := range []string{"192.168.1.20", "192.168.1.30", "192.168.1.40", "192.168.1.50", "2001:db8::7", "10.0.0.1"} {
want[netip.MustParseAddr(a)] = true
}
got := clientNames(st, want, func(r *model.Record) bool { return r.ID != 6 })
exp := map[string]string{"192.168.1.20": "storage.adbraun.lol", "192.168.1.30": "alias.adbraun.lol", "2001:db8::7": "v6.adbraun.lol"}
if len(got) != len(exp) {
t.Errorf("got %v", got)
}
for a, n := range exp {
if got[netip.MustParseAddr(a)] != n {
t.Errorf("%s = %q, want %q", a, got[netip.MustParseAddr(a)], n)
}
}
}
+61
View File
@@ -0,0 +1,61 @@
package server
import (
"strings"
"testing"
)
func TestDisableRecords(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)
www := e.do("POST", "/zones/example.com/records", e.token, map[string]any{"name": "www", "type": "A", "data": "192.0.2.10"}, 201)["object"].(map[string]any)
id := itoa(int64(www["id"].(float64)))
// By ID: the record comes back disabled.
if r := e.do("POST", "/records/"+id+"/disable", e.token, nil, 201)["object"].(map[string]any); r["disabled"] != true {
t.Errorf("disabled record = %v", r)
}
if _, status := e.req("POST", "/records/"+id+"/disable", e.token, nil); status != 409 {
t.Errorf("disabling twice = %d, want 409", status)
}
if z := e.do("GET", "/zones/example.com", e.token, nil, 200); z["disabled_count"] != 1.0 || z["record_count"] != 3.0 {
t.Errorf("zone counts = %v / %v", z["record_count"], z["disabled_count"])
}
off := e.list("/zones/example.com/records?disabled=1", e.token)
on := e.list("/zones/example.com/records?disabled=0", e.token)
if len(off) != 1 || off[0]["name"] != "www.example.com." || len(on) != 2 {
t.Errorf("filtered: %d disabled, %d enabled", len(off), len(on))
}
file := e.do("GET", "/zones/example.com/file", e.token, nil, 200)
if s, _ := file["_raw"].(string); !strings.Contains(s, "; disabled: www") {
t.Errorf("zone file = %v", file)
}
// Staged: listing with the changeset marks the record.
cs := itoa(int64(e.do("POST", "/changesets", e.token, map[string]any{"description": "back"}, 201)["id"].(float64)))
e.do("POST", "/records/"+id+"/enable?changeset="+cs, e.token, nil, 202)
for _, r := range e.list("/zones/example.com/records?changeset="+cs, e.token) {
if r["name"] == "www.example.com." && (r["pending"] != "enable" || r["disabled"] == true) {
t.Errorf("staged record = %v", r)
}
}
e.do("POST", "/changesets/"+cs+"/discard", e.token, nil, 204)
// By match.
e.do("POST", "/zones/example.com/records/enable?name=www&type=A", e.token, nil, 201)
if _, status := e.req("POST", "/zones/example.com/records/disable?name=nope&type=A", e.token, nil); status != 404 {
t.Errorf("no match = %d", status)
}
if _, status := e.req("POST", "/zones/example.com/records/disable?name=www", e.token, nil); status != 400 {
t.Errorf("no type = %d", status)
}
e.do("POST", "/zones/example.com/records/disable?name=www&type=A&data=192.0.2.10", e.token, nil, 201)
if r := e.do("GET", "/records/"+id, e.token, nil, 200); r["disabled"] != true {
t.Errorf("after match disable = %v", r)
}
// And in a batch.
e.do("POST", "/apply", e.token, map[string]any{"ops": []map[string]any{{"op": "record.enable", "id": www["id"]}}}, 201)
if r := e.do("GET", "/records/"+id, e.token, nil, 200); r["disabled"] == true {
t.Errorf("after batch enable = %v", r)
}
}
+1 -1
View File
@@ -157,7 +157,7 @@ func primaryZonesFor(st *model.State, p *auth.Principal) []acme.Zone {
var out []acme.Zone
for _, z := range st.SortedZones() {
vn := st.ViewName(z)
if z.Type == model.ZonePrimary && p.CanSeeZone(z.Name, vn) {
if z.Type == model.ZonePrimary && !z.Disabled && p.CanSeeZone(z.Name, vn) {
out = append(out, acme.Zone{Name: z.Name, View: vn})
}
}
+409
View File
@@ -0,0 +1,409 @@
package server
import (
"context"
"net/http"
"slices"
"strconv"
"strings"
"time"
"jarl/internal/api"
"jarl/internal/auth"
"jarl/internal/blocking"
"jarl/internal/engine"
"jarl/internal/model"
)
// ---- blocking (docs/blocking.md)
// bytesPerRecord is what named 9.20 needs per policy zone record and view,
// measured with lists of a few hundred thousand entries.
const bytesPerRecord = 630
func viewNames(st *model.State, ids []int64) []string {
out := []string{}
for _, id := range ids {
if v := st.Views[id]; v != nil {
out = append(out, v.Name)
}
}
return out
}
func presentBlocklist(st *model.State, b *model.Blocklist, status *model.BlocklistStatus) api.Blocklist {
views := st.BlockingViews(b.ViewIDs)
out := api.Blocklist{Blocklist: *b, Views: viewNames(st, views), AutoViews: len(b.ViewIDs) == 0 && len(st.Views) > 0, Status: status, Step: st.StepName(b.StepID)}
switch {
case b.Disabled:
out.State = "disabled"
case len(views) == 0 || !stepReached(st, b, views):
out.State = "idle"
case status != nil && status.Error != "":
out.State = "error"
case status == nil || status.UpdatedAt == nil:
out.State = "waiting"
default:
out.State = "ok"
}
if status != nil && !b.Disabled {
out.MemoryMB = int((int64(status.Records)*bytesPerRecord*int64(len(views)) + 1<<19) >> 20)
}
return out
}
func presentBlockRule(st *model.State, r *model.BlockRule) api.BlockRule {
return api.BlockRule{BlockRule: *r, Views: viewNames(st, st.BlockingViews(r.ViewIDs)), AutoViews: len(r.ViewIDs) == 0 && len(st.Views) > 0}
}
func requireBlocking(p *auth.Principal) error {
return require(p, auth.Action{Resource: auth.ResBlocking})
}
func (s *Server) listBlocklists(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
if err := requireBlocking(p); err != nil {
return err
}
st, base, err := s.states(r)
if err != nil {
return err
}
statuses, err := s.store.BlocklistStatuses(r.Context())
if err != nil {
return err
}
out := []api.Blocklist{}
for _, b := range st.SortedBlocklists() {
o := presentBlocklist(st, b, statuses[b.ID])
o.Pending = pending(base, base != nil && base.Blocklists[b.ID] == nil, base != nil && base.Blocklists[b.ID] != nil && base.Blocklists[b.ID].Version != b.Version)
out = append(out, o)
}
if base != nil {
for _, b := range base.SortedBlocklists() {
if st.Blocklists[b.ID] == nil {
o := presentBlocklist(base, b, statuses[b.ID])
o.Pending = "delete"
out = append(out, o)
}
}
}
writeJSON(w, http.StatusOK, out)
return nil
}
// pending returns the pending marker of an object listed with a changeset.
func pending(base *model.State, added, changed bool) string {
switch {
case base == nil:
return ""
case added:
return "add"
case changed:
return "change"
}
return ""
}
func (s *Server) blocklistByPath(r *http.Request, st *model.State) (*model.Blocklist, error) {
ref := r.PathValue("id")
var b *model.Blocklist
if id, err := strconv.ParseInt(ref, 10, 64); err == nil {
b = st.Blocklists[id]
} else {
b = st.BlocklistByName(ref)
}
if b == nil {
return nil, notFound("blocklist %s not found", ref)
}
return b, nil
}
func (s *Server) blocklistObject(ctx context.Context) func(*model.State, int64) any {
return func(st *model.State, id int64) any {
b := st.Blocklists[id]
if b == nil {
return nil
}
statuses, _ := s.store.BlocklistStatuses(ctx)
return presentBlocklist(st, b, statuses[id])
}
}
func (s *Server) getBlocklist(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
if err := requireBlocking(p); err != nil {
return err
}
st, err := s.state(r)
if err != nil {
return err
}
b, err := s.blocklistByPath(r, st)
if err != nil {
return err
}
writeJSON(w, http.StatusOK, s.blocklistObject(r.Context())(st, b.ID))
return nil
}
func (s *Server) createBlocklist(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
body, err := readBody(r)
if err != nil {
return err
}
return s.mutate(w, r, p, "", []engine.Request{request(engine.OpBlocklistCreate, 0, body)}, s.blocklistObject(r.Context()))
}
func (s *Server) updateBlocklist(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
st, err := s.state(r)
if err != nil {
return err
}
b, err := s.blocklistByPath(r, st)
if err != nil {
return err
}
body, err := readBody(r)
if err != nil {
return err
}
id := b.ID
return s.mutate(w, r, p, "", []engine.Request{request(engine.OpBlocklistUpdate, id, body)}, func(st *model.State, _ int64) any { return s.blocklistObject(r.Context())(st, id) })
}
func (s *Server) deleteBlocklist(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
st, err := s.state(r)
if err != nil {
return err
}
b, err := s.blocklistByPath(r, st)
if err != nil {
return err
}
return s.mutate(w, r, p, "", []engine.Request{request(engine.OpBlocklistDelete, b.ID, nil)}, nil)
}
// refreshBlocklist: POST /blocking/lists/{id}/refresh[?force=1] downloads
// the list now and waits for the result.
func (s *Server) refreshBlocklist(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
if err := require(p, auth.Action{Resource: auth.ResBlocking, Write: true}); err != nil {
return err
}
st, err := s.store.LoadState(r.Context())
if err != nil {
return err
}
b, err := s.blocklistByPath(r, st)
if err != nil {
return err
}
ctx, cancel := context.WithTimeout(r.Context(), 3*time.Minute)
defer cancel()
status, rerr := s.eng.RefreshBlocklist(ctx, b.ID, queryBool(r, "force"))
if status == nil {
return rerr
}
writeJSON(w, http.StatusOK, presentBlocklist(st, b, status))
return nil
}
func (s *Server) listBlockRules(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
if err := requireBlocking(p); err != nil {
return err
}
st, base, err := s.states(r)
if err != nil {
return err
}
out := []api.BlockRule{}
for _, rule := range st.SortedBlockRules() {
o := presentBlockRule(st, rule)
o.Pending = pending(base, base != nil && base.BlockRules[rule.ID] == nil, base != nil && base.BlockRules[rule.ID] != nil && base.BlockRules[rule.ID].Version != rule.Version)
out = append(out, o)
}
if base != nil {
for _, rule := range base.SortedBlockRules() {
if st.BlockRules[rule.ID] == nil {
o := presentBlockRule(base, rule)
o.Pending = "delete"
out = append(out, o)
}
}
}
writeJSON(w, http.StatusOK, out)
return nil
}
func ruleObject(st *model.State, id int64) any {
if rule := st.BlockRules[id]; rule != nil {
return presentBlockRule(st, rule)
}
return nil
}
func (s *Server) ruleByPath(r *http.Request, st *model.State) (*model.BlockRule, error) {
id, _ := strconv.ParseInt(r.PathValue("id"), 10, 64)
rule := st.BlockRules[id]
if rule == nil {
return nil, notFound("rule %s not found", r.PathValue("id"))
}
return rule, nil
}
func (s *Server) createBlockRule(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
body, err := readBody(r)
if err != nil {
return err
}
return s.mutate(w, r, p, "", []engine.Request{request(engine.OpBlockRuleCreate, 0, body)}, ruleObject)
}
func (s *Server) updateBlockRule(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
st, err := s.state(r)
if err != nil {
return err
}
rule, err := s.ruleByPath(r, st)
if err != nil {
return err
}
body, err := readBody(r)
if err != nil {
return err
}
id := rule.ID
return s.mutate(w, r, p, "", []engine.Request{request(engine.OpBlockRuleUpdate, id, body)}, func(st *model.State, _ int64) any { return ruleObject(st, id) })
}
func (s *Server) deleteBlockRule(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
st, err := s.state(r)
if err != nil {
return err
}
rule, err := s.ruleByPath(r, st)
if err != nil {
return err
}
return s.mutate(w, r, p, "", []engine.Request{request(engine.OpBlockRuleDelete, rule.ID, nil)}, nil)
}
// deleteBlockRulesByName: DELETE /blocking/rules?name=NAME[&view=VIEW]
// deletes the rules for a domain (in one view, or all).
func (s *Server) deleteBlockRulesByName(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
st, err := s.state(r)
if err != nil {
return err
}
q := r.URL.Query()
name := strings.TrimSuffix(strings.ToLower(strings.TrimSpace(q.Get("name"))), ".")
if name == "" {
return badRequest("give the domain of the rules to delete (name=)")
}
var viewID int64 = -1
if vn := q.Get("view"); vn != "" {
v := st.ViewByName(vn)
if v == nil {
return notFound("view %s not found", vn)
}
viewID = v.ID
}
var reqs []engine.Request
for _, rule := range st.SortedBlockRules() {
if rule.Name != name {
continue
}
if viewID >= 0 && !slices.Contains(st.BlockingViews(rule.ViewIDs), viewID) {
continue
}
reqs = append(reqs, request(engine.OpBlockRuleDelete, rule.ID, nil))
}
if len(reqs) == 0 {
if queryBool(r, "missing_ok") {
writeJSON(w, http.StatusOK, api.MutationResponse{})
return nil
}
return notFound("there is no rule for %s", name)
}
return s.mutate(w, r, p, "", reqs, nil)
}
// checkBlocking: GET /blocking/check?name=NAME says what blocking does
// with a name in each view.
func (s *Server) checkBlocking(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
if err := requireBlocking(p); err != nil {
return err
}
st, err := s.store.LoadState(r.Context())
if err != nil {
return err
}
out, err := s.eng.CheckBlocking(st, r.URL.Query().Get("name"))
if err != nil {
return err
}
if out == nil {
out = []blocking.Match{}
}
writeJSON(w, http.StatusOK, out)
return nil
}
// blocklistErrors lists the enabled blocklists whose last update failed,
// for the dashboard.
func (s *Server) blocklistErrors(ctx context.Context) []api.BlocklistError {
st, err := s.store.LoadState(ctx)
if err != nil || len(st.Blocklists) == 0 {
return nil
}
statuses, err := s.store.BlocklistStatuses(ctx)
if err != nil {
return nil
}
var out []api.BlocklistError
for _, b := range st.SortedBlocklists() {
if s := statuses[b.ID]; s != nil && s.Error != "" && !b.Disabled {
out = append(out, api.BlocklistError{Name: b.Name, Error: s.Error, UpdatedAt: s.UpdatedAt})
}
}
return out
}
// blockingStatus: GET /blocking/status says whether blocking is paused.
func (s *Server) blockingStatus(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
if err := requireBlocking(p); err != nil {
return err
}
st, err := s.store.LoadState(r.Context())
if err != nil {
return err
}
writeJSON(w, http.StatusOK, map[string]any{"paused_until": st.Settings.BlockingPausedUntil, "paused_indefinitely": st.Settings.BlockingPausedIndefinitely})
return nil
}
// pauseBlocking: POST /blocking/pause {"minutes": 10} lets every name
// through until then, {"indefinitely": true} until resumed; POST
// /blocking/resume ends it.
func (s *Server) pauseBlocking(resume bool) func(http.ResponseWriter, *http.Request, *auth.Principal) error {
return func(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
var in engine.BlockingPauseInput
if !resume {
var body struct {
Minutes int `json:"minutes"`
Indefinitely bool `json:"indefinitely"`
}
if err := decodeJSON(r, &body); err != nil {
return err
}
if body.Indefinitely {
in.Indefinitely = true
return s.mutate(w, r, p, "", []engine.Request{engine.NewRequest(engine.OpBlockingPause, 0, in)}, nil)
}
if body.Minutes < 1 || time.Duration(body.Minutes)*time.Minute > engine.MaxBlockingPause {
return badRequest("pause for 1 to %d minutes, or indefinitely", int(engine.MaxBlockingPause/time.Minute))
}
until := time.Now().Add(time.Duration(body.Minutes) * time.Minute).Truncate(time.Second)
in.Until = &until
}
return s.mutate(w, r, p, "", []engine.Request{engine.NewRequest(engine.OpBlockingPause, 0, in)}, nil)
}
}
+7
View File
@@ -279,6 +279,13 @@ func (s *Server) status(w http.ResponseWriter, r *http.Request, p *auth.Principa
resp.Backup = s.backupStatus(r.Context(), now)
resp.TOTP = s.totpOverview(r.Context())
}
if p.Can(auth.Action{Resource: auth.ResBlocking}) {
resp.BlocklistErrors = s.blocklistErrors(r.Context())
if st, err := s.store.LoadState(r.Context()); err == nil {
resp.BlockingPausedUntil = st.Settings.BlockingPausedUntil
resp.BlockingPausedIndefinitely = st.Settings.BlockingPausedIndefinitely
}
}
writeJSON(w, http.StatusOK, resp)
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
}
+63
View File
@@ -13,7 +13,9 @@ import (
"jarl/internal/catalog"
"jarl/internal/dnsname"
"jarl/internal/engine"
"jarl/internal/iprange"
"jarl/internal/model"
"jarl/internal/render"
"jarl/internal/rr"
)
@@ -92,6 +94,31 @@ func (s *Server) getView(w http.ResponseWriter, r *http.Request, p *auth.Princip
return nil
}
// viewKey: GET /views/{id}/key is the TSIG key that selects the view
// (render/viewkeys.go), with its secret: like revealing a TSIG key's, it
// needs tsig:write and is audited.
func (s *Server) viewKey(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
st, err := s.state(r)
if err != nil {
return err
}
v, err := s.viewByPath(r, st, p)
if err != nil {
return err
}
if err := require(p, auth.Action{Resource: auth.ResTSIG, Write: true}); err != nil {
return forbidden("showing a view's key needs tsig:write")
}
seed, err := engine.ViewKeySeed(r.Context(), s.store)
if err != nil {
return err
}
name := render.ViewKeyName(v.Name)
s.eng.Audit(r.Context(), p, "tsig.reveal", "view", v.ID, v.Name, "success", "view key "+name+" shown")
writeJSON(w, http.StatusOK, map[string]string{"name": name, "algorithm": render.ViewKeyAlgorithm, "secret": render.ViewKeySecret(seed, v.ID)})
return nil
}
func (s *Server) viewObject(r *http.Request) func(st *model.State, id int64) any {
return func(st *model.State, id int64) any {
if v := st.Views[id]; v != nil {
@@ -528,6 +555,42 @@ func (s *Server) catalogRRTypes(w http.ResponseWriter, r *http.Request, p *auth.
return nil
}
// 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 {
roles := map[string][]string{}
for name, gs := range auth.RoleTemplates {
+6 -1
View File
@@ -37,7 +37,12 @@ func (s *Server) zoneDNSSEC(w http.ResponseWriter, r *http.Request, p *auth.Prin
}
resp := api.DNSSECResponse{Zone: z.Name, View: st.ViewName(z), Type: z.Type, Status: s.eng.DNSSEC(r.Context(), st, z), Registrar: s.registrarFor(z)}
if pz := parentInJarl(st, z); pz != nil {
resp.ParentZone = pz.Name
resp.ParentZone, resp.ParentZoneID, resp.ParentView = pz.Name, pz.ID, st.ViewName(pz)
for _, rec := range st.ServedRecords(pz.ID) {
if rec.Name == z.Name && rec.Type == "DS" {
resp.ParentDS = append(resp.ParentDS, rec.Data)
}
}
}
writeJSON(w, http.StatusOK, resp)
return nil
+6 -3
View File
@@ -160,6 +160,9 @@ func (s *Server) zoneDelegation(w http.ResponseWriter, r *http.Request, p *auth.
return badRequest("the delegation check compares the parent's delegation with the zone's NS records, so it needs a primary zone (this is a %s zone; check it where its primary is)", z.Type)
}
c := s.checker()
// This server's own addresses are asked too: sign those queries with
// the key of the zone's view, so they get that view's answer.
c.LocalKey = s.eng.ViewKey(r.Context(), st, z)
if v := r.URL.Query().Get("resolver"); v != "" {
// An address only, always port 53: this is a DNS resolver.
a, err := netip.ParseAddr(v)
@@ -169,7 +172,7 @@ func (s *Server) zoneDelegation(w http.ResponseWriter, r *http.Request, p *auth.
c.Resolver = net.JoinHostPort(a.String(), "53")
}
var ns []string
for _, rec := range st.ZoneRecords(z.ID) {
for _, rec := range st.ServedRecords(z.ID) {
if rec.Type == "NS" && rec.Name == z.Name {
ns = append(ns, rec.Data)
}
@@ -226,7 +229,7 @@ func (s *Server) localSerial(ctx context.Context, st *model.State, z *model.Zone
// addresses Jarl has for them in the same view (or nil, to be looked up).
func nsTargets(st *model.State, z *model.Zone) []nscheck.Target {
var out []nscheck.Target
for _, rec := range st.ZoneRecords(z.ID) {
for _, rec := range st.ServedRecords(z.ID) {
if rec.Type != "NS" || rec.Name != z.Name {
continue
}
@@ -244,7 +247,7 @@ func jarlAddresses(st *model.State, viewID int64, name string) []string {
if !z.HasRecords() || !dnsname.IsSubdomain(name, z.Name) {
continue
}
for _, rec := range st.ZoneRecords(z.ID) {
for _, rec := range st.ServedRecords(z.ID) {
if rec.Name == name && (rec.Type == "A" || rec.Type == "AAAA") {
out = append(out, rec.Data)
}
+1 -1
View File
@@ -73,7 +73,7 @@ func lowerNoDot(n string) string { return strings.ToLower(strings.TrimSuffix(n,
// trailing dots.
func zoneNS(st *model.State, z *model.Zone) []string {
var out []string
for _, rec := range st.ZoneRecords(z.ID) {
for _, rec := range st.ServedRecords(z.ID) {
if rec.Type == "NS" && rec.Name == z.Name {
out = append(out, lowerNoDot(rec.Data))
}
Loaded 100 of 146 files, more files were not shown because too many files have changed in this diff. Show more