Compare commits
43
Commits
v0.1.0
...
v0.4.0-rc.3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ecffc2d5c1 | ||
|
|
9d9ae37367 | ||
|
|
384f6b3060 | ||
|
|
c696d02513 | ||
|
|
7be0762f48 | ||
|
|
b097e10bef | ||
|
|
73d97ecc0a | ||
|
|
33060f86ce | ||
|
|
89663e366b | ||
|
|
59baccd636 | ||
|
|
02c393a6bb | ||
|
|
d4756444ac | ||
|
|
67f14b030f | ||
|
|
ad8eb3fc3d | ||
|
|
27d1f9e538 | ||
|
|
9fc23310bd | ||
|
|
436cd79ec9 | ||
|
|
c645dae14f | ||
|
|
403f85486d | ||
|
|
4113d5d6fc | ||
|
|
f716fb8dd1 | ||
|
|
113a6913a2 | ||
|
|
295c549479 | ||
|
|
5f04ab523b | ||
|
|
c9590cc3d4 | ||
|
|
bb6b4ab64d | ||
|
|
1d6c28ba69 | ||
|
|
020223d1a5 | ||
|
|
d961cb8792 | ||
|
|
e1bcb5d479 | ||
|
|
fb1ee09250 | ||
|
|
88bb89a9ff | ||
|
|
ca0f148b78 | ||
|
|
a95dffb471 | ||
|
|
24a2c875fa | ||
|
|
4b79abf296 | ||
|
|
21cb14262d | ||
|
|
db9ebb14dd | ||
|
|
b05711b13e | ||
|
|
8f9cdd0a17 | ||
|
|
b22ffee1bb | ||
|
|
a3a1da54df | ||
|
|
5630e7a848 |
No files matched your search
@@ -2,7 +2,9 @@
|
||||
# is pushed:
|
||||
# * a Gitea release with the CHANGELOG notes, the linux tarballs, the CLI
|
||||
# for Linux, macOS and Windows, the .debs and SHA256SUMS;
|
||||
# * the .debs (amd64, arm64) in the owner's Debian package registry.
|
||||
# * the .debs (amd64, arm64) in the owner's Debian package registry:
|
||||
# distribution "stable", or "testing" for a pre-release (a version
|
||||
# with a hyphen), which Gitea also marks as a pre-release.
|
||||
#
|
||||
# Needs a repository secret RELEASE_TOKEN: an access token of a user who
|
||||
# can write to the repository, with the write:repository and write:package
|
||||
@@ -30,8 +32,10 @@ jobs:
|
||||
echo "::error::tag $GITHUB_REF_NAME does not match VERSION ($version)"
|
||||
exit 1
|
||||
fi
|
||||
if ! grep -q "^## \[$version\]" CHANGELOG.md; then
|
||||
echo "::error::CHANGELOG.md has no '## [$version]' section"
|
||||
# The release notes (a pre-release may use its final version's
|
||||
# section or Unreleased; see scripts/gitea-release.sh).
|
||||
if ! scripts/gitea-release.sh notes > /dev/null; then
|
||||
echo "::error::no release notes for $version in CHANGELOG.md"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -48,6 +52,10 @@ jobs:
|
||||
run: make test
|
||||
|
||||
- name: Build
|
||||
env:
|
||||
# Checked against VERSION above; don't depend on the checkout
|
||||
# having the tag.
|
||||
RELEASE_TAG: ${{ github.ref_name }}
|
||||
run: |
|
||||
export DEB_MAINTAINER="$(git log -1 --format='%an <%ae>')"
|
||||
make dist
|
||||
|
||||
+341
@@ -6,6 +6,347 @@ All notable changes to Jarl are documented here. The format follows
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- View keys: every view gets a TSIG key, `jarl-view-NAME`, at the front of
|
||||
its match-clients, so a query signed with it lands in that view from any
|
||||
address. Jarl signs its delegation check's queries to this server's own
|
||||
addresses with the key of the zone's view: before, they got whichever
|
||||
view matched the server's own address (usually internal), so the check
|
||||
could report the wrong serial for the external view. `jarl view key
|
||||
VIEW` shows a key, for `dig -k` (docs/views.md). The first apply after
|
||||
upgrading adds the keys to named.conf and keys.conf.
|
||||
- Server statistics: named's memory (resident, and its own count) and
|
||||
CPU use, its cache size, sockets, and recursive and TCP clients against
|
||||
their limits; the host's memory and load; free space where zone files,
|
||||
logs and Jarl's data are. Memory and CPU are kept over time like the
|
||||
other statistics. Statistics page → Server, `jarl stats server`, API
|
||||
`/stats/server`. Read from named's statistics channel, `rndc status` and
|
||||
/proc, with no extra privileges.
|
||||
- CLI: the record commands that change records take several views,
|
||||
`--view internal,external`, or `--view all`, and make the change in each,
|
||||
as one changeset (all views or none).
|
||||
- Blocking can be paused until resumed, like Pi-hole's "indefinitely":
|
||||
`jarl block pause indefinitely`, **Until I resume** on the Blocking page,
|
||||
`{"indefinitely": true}` in the API.
|
||||
- Disabled zones: a zone can be disabled instead of deleted. named stops
|
||||
loading it (its zone statement is left out of named.conf), while Jarl
|
||||
keeps it with its records, options and history, still editable;
|
||||
enabling it serves it as it is then. Disabling says what clients get
|
||||
instead (recursion, or REFUSED). Signed zones can't be disabled. Web
|
||||
UI: Disable/Enable on the zone page, a badge in the zone list. CLI:
|
||||
`jarl zone disable|enable`. API: `PATCH /zones/{zone}` with
|
||||
`{"disabled": true}`. Database schema 7.
|
||||
- Blocking (docs/blocking.md): named answers "doesn't exist" for ads,
|
||||
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.
|
||||
- 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
|
||||
|
||||
- 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
|
||||
enable|status|check-ds|disable|wipe` and the zone page's DNSSEC tab, or
|
||||
the zone's new `dnssec_policy` field in the API. named signs the zone
|
||||
with BIND's `default` policy (one ECDSA P-256 key that is never replaced)
|
||||
or a custom one, using inline signing, with keys in a per-view directory
|
||||
under `/var/lib/bind/jarl/dnssec-keys`. Jarl shows the keys and the DS
|
||||
record to give the registrar, says when it's safe to publish it, and
|
||||
checks the parent's DS records (`check-ds`, which also tells named with
|
||||
`rndc dnssec -checkds`). It refuses steps that would break a signed
|
||||
domain: switching straight to unsigned (it goes through BIND's
|
||||
`insecure` policy instead, and finishes only once named is done),
|
||||
deleting a signed zone or moving it to another view, and TTLs above the
|
||||
policy's `max-zone-ttl`. `wipe` starts over with a clean slate once the
|
||||
parent has no DS. Each step in the web UI opens a dialog that says what it
|
||||
needs first. `jarl zone delegation` now also compares the parent's DS
|
||||
records with the zone's keys.
|
||||
- Porkbun support (optional, docs/dnssec.md#porkbun): with an API key in
|
||||
the new `porkbun:` section of jarl.yaml (and nowhere else), `jarl zone
|
||||
registrar status|sync-glue|set-ns|publish-ds|remove-ds` and the zone
|
||||
page's Registrar tab show and change the domain's name servers, glue
|
||||
records (one per name server inside the domain, with all its IPv4 and
|
||||
IPv6 addresses) and DS records at Porkbun. Every change first checks that
|
||||
it won't take the domain offline (the name servers answer for the zone,
|
||||
glue matches, a DS goes out only once the domain is delegated to servers
|
||||
that serve the signed zone, DS records match the zone's keys before the
|
||||
name servers change); `--dry-run` shows the checks and `--force` skips
|
||||
them. Administrators only.
|
||||
- Backups include the DNSSEC keys (private keys too), stored inside the
|
||||
backup file, and `jarld restore` puts them back for named
|
||||
(`bind.named_user`, default `bind`). `jarl-backup.service` has
|
||||
`CAP_DAC_READ_SEARCH` to read them; `--without-keys` skips them.
|
||||
- Jarl raises a zone's serial above the one named has loaded whenever it
|
||||
writes a zone file whose serial isn't higher (after restoring an older
|
||||
database, say), so named loads it (it refuses otherwise for a signed
|
||||
zone) and secondaries pick it up.
|
||||
- Two-factor login to the web UI with an authenticator app (TOTP: SHA-1,
|
||||
6 digits, 30 s, as every app supports). Users turn it on under Account
|
||||
(password, QR code, a code to confirm) and get 10 one-time recovery
|
||||
codes. Logging in then asks for a code (or a recovery code) after the
|
||||
password; each code works once. In a session with two-factor login,
|
||||
creating or rotating API tokens, changing users and resetting someone's
|
||||
two-factor login need a code from the last 5 minutes: the web UI asks
|
||||
for one, API clients send `X-Jarl-TOTP`. API tokens and the local socket
|
||||
are not affected.
|
||||
- `totp.require` in jarl.yaml (`none`, `admins`, `all`) makes two-factor
|
||||
login compulsory; such a user who hasn't set it up can only set it up
|
||||
after logging in. `totp.issuer` names the server in authenticator apps
|
||||
(default `Jarl <hostname>`).
|
||||
- Resetting a user's two-factor login (lost phone): Users → Reset 2FA,
|
||||
`jarl user totp reset NAME`, or on the server
|
||||
`jarld user totp reset NAME` (also with jarld stopped).
|
||||
- The dashboard and `jarl status` list users who must use two-factor login
|
||||
but haven't set it up, and suggest disabling the first `admin` account
|
||||
when it is still active next to personal administrator accounts.
|
||||
`jarl user list`/`show` and the Users page show who uses it.
|
||||
- API: `POST /auth/login/totp`, `/me/totp` (`setup`, `enable`, `disable`,
|
||||
`recovery-codes`), `DELETE /users/{id}/totp`; database migration 4.
|
||||
- `scripts/testenv.sh`: turns a fresh Debian/Ubuntu machine into a test bed.
|
||||
It installs BIND and the build tools (Go from go.dev), writes a hand-made
|
||||
style configuration with internal and external views, a split-horizon
|
||||
zone (`example.com` or `JARL_TEST_ZONE`) and a reverse zone, starts named
|
||||
and prints the steps to install Jarl and adopt the configuration
|
||||
(docs/development.md).
|
||||
- View colours. Give a view a colour (Views → Edit, `jarl view color`, or
|
||||
`PUT /views/{id}/color`) and the zone list, the zone page and the views
|
||||
list show its name in that colour. The colours are muted, and pastel in
|
||||
dark mode. The default is the link colour. A colour isn't configuration,
|
||||
so setting one needs no changeset or deploy.
|
||||
- A "← Zones" link above a zone, back to the zone list, and "← History"
|
||||
above a changeset. The zone list keeps its filter, view and type in the
|
||||
address, so going back to it (with that link or the browser's back
|
||||
button) restores them. A view opened from a zone or the zone list has a
|
||||
"← example.com (external)" or "← Zones" link back.
|
||||
|
||||
### Changed
|
||||
|
||||
- The version moved from the header to a footer: "Powered by Jarl v0.1.0",
|
||||
linking to the release on Gitea. A build that isn't of a release tag
|
||||
shows its commit instead, linked to the commit and marked `-modified`
|
||||
with uncommitted changes. Before, every build of a commit after a
|
||||
release still showed the release's version. `GET /status` and
|
||||
`/auth/whoami` report the build as `build` (docs/development.md).
|
||||
- `jarld version`, `jarl --version`, `jarl status` and `jarl whoami` give
|
||||
a version number only for release builds. Other builds say they are
|
||||
development builds and how far past the last tag they are: `dev
|
||||
ca0f148b78b6 (3 commits after v0.2.0-rc.1)`. Before, they printed the
|
||||
version and the commit (`0.2.0-rc.1 (ca0f148b78b6)`), which read like a
|
||||
release.
|
||||
- `make deb` gives development builds their own package version after
|
||||
the last tag (`0.2.0~rc.1+git3.gca0f148`, plus the build time with
|
||||
uncommitted changes). Installing one over the RC or an older build is
|
||||
an upgrade: no more `apt install --reinstall`. It also removes older
|
||||
packages from `dist/` (docs/development.md).
|
||||
- The view in the zone list, and on the zone page, links to that view,
|
||||
and the views page highlights it. It used to be plain text, and the zone
|
||||
page linked to the views page as a whole.
|
||||
- The zone's Name servers tab shows at once and follows the check as it
|
||||
happens. Each query is listed as a dig command line (`dig @192.0.2.1
|
||||
example.org. SOA +norec`) with a "waiting 1.8 s" counter until it is
|
||||
answered. The finished result then replaces the progress, and the queries
|
||||
stay under "Queries sent". Results, the delegation check's included, are
|
||||
kept for 10 minutes, so returning to the tab shows them at once; "Check
|
||||
again" asks anew. A check keeps running if you leave the tab. Behind it,
|
||||
`GET /zones/{zone}/serials?stream=1` streams the queries as
|
||||
newline-delimited JSON (docs/api.md).
|
||||
- The delegation check streams its queries the same way, every one of
|
||||
them: finding the parent zone, its servers' addresses, the NS and DS
|
||||
queries to each parent server and the SOA from each delegated server.
|
||||
Each shows a short answer ("referral to katelyn.ns.cloudflare.com., …",
|
||||
"DS 2371", "NXDOMAIN"). The serials check now lists its address lookups
|
||||
as separate A and AAAA queries. `GET /zones/{zone}/delegation?stream=1`
|
||||
streams them.
|
||||
- Switching a zone's tabs (Records, Settings, …) no longer blanks the page
|
||||
while the tab loads: the page changes once the tab is ready, and
|
||||
"Loading…" appears only if a page takes a while.
|
||||
- `jarl zone serials` compares the serial named serves, which for a signed
|
||||
zone is the signed zone's (ahead of Jarl's).
|
||||
- `jarld import` turns `dnssec-policy` in an existing configuration into
|
||||
the zones' DNSSEC setting, and warns that their keys must be moved into
|
||||
Jarl's key directory before adopting.
|
||||
- `dnssec-policy` is no longer accepted as a zone, view or server option
|
||||
(zones have a DNSSEC setting instead), nor `key-directory` as a zone
|
||||
option.
|
||||
- Deleting a zone also removes the journal and signed files named kept
|
||||
next to it.
|
||||
- Release candidates (tags like `v0.2.0-rc.1`) publish their `.deb`s to a
|
||||
separate `testing` distribution of the package registry instead of
|
||||
`stable`. Servers that follow `stable` can't pick up an RC with
|
||||
`apt upgrade`. An RC may also take its release notes from the
|
||||
`## [0.2.0]` or `## [Unreleased]` section of the CHANGELOG instead of
|
||||
needing its own (docs/development.md, "Release candidates").
|
||||
|
||||
### Fixed
|
||||
|
||||
- Publishing a release candidate failed: `scripts/gitea-release.sh`
|
||||
rejected its `.deb`s, whose names carry the Debian version
|
||||
(`0.2.0~rc.1`) instead of `0.2.0-rc.1`.
|
||||
- `jarld init -u` and the install guide recommend naming the first
|
||||
administrator after yourself (one account per person).
|
||||
- `jarld import` no longer warns that a zone with `allow-update { none; }`
|
||||
uses dynamic updates (validation already allowed it).
|
||||
- docs/cli.md gives the CLI configuration file's location on macOS
|
||||
(`~/Library/Application Support/jarl/config.yaml`) and Windows
|
||||
(`%AppData%\jarl\config.yaml`). It was documented only as
|
||||
`~/.config/jarl/config.yaml`.
|
||||
|
||||
## [0.1.0] - 2026-10-09
|
||||
|
||||
The first version.
|
||||
|
||||
@@ -14,7 +14,15 @@
|
||||
# (jarl-cli-<version>-<os>-<arch>[.exe])
|
||||
|
||||
VERSION ?= $(shell cat VERSION 2>/dev/null || echo 0.0.0-dev)
|
||||
LDFLAGS := -s -w -X jarl/internal/version.Version=$(VERSION)
|
||||
# RELEASE_TAG marks a build of a release (the web UI then shows and links
|
||||
# the release rather than the commit): set when HEAD is tagged v$(VERSION)
|
||||
# and no tracked file is changed.
|
||||
RELEASE_TAG ?= $(shell t=v$(VERSION); git tag --points-at HEAD 2>/dev/null | grep -qx "$$t" && git diff --quiet HEAD 2>/dev/null && echo $$t)
|
||||
# DESCRIBE places other builds relative to the last tag ("3 commits after
|
||||
# v0.2.0-rc.1").
|
||||
DESCRIBE ?= $(shell git describe --tags --long --match 'v[0-9]*' --abbrev=12 HEAD 2>/dev/null)
|
||||
LDFLAGS := -s -w -X jarl/internal/version.Version=$(VERSION) -X jarl/internal/version.Tag=$(RELEASE_TAG) \
|
||||
-X jarl/internal/version.Describe=$(DESCRIBE)
|
||||
GOFLAGS := -trimpath
|
||||
export CGO_ENABLED := 0
|
||||
|
||||
@@ -33,9 +41,18 @@ DESTDIR ?=
|
||||
JARLD_BIN ?= bin/jarld
|
||||
JARL_BIN ?= bin/jarl
|
||||
|
||||
# make deb: the package's architecture (Debian name) and version.
|
||||
# make deb: the package's architecture (Debian name) and version. A
|
||||
# release is VERSION with ~ for - (0.2.0-rc.1 -> 0.2.0~rc.1, which apt
|
||||
# sorts before 0.2.0). A development build is versioned after the last
|
||||
# tag: 3 commits past v0.2.0-rc.1 is 0.2.0~rc.1+git3.gca0f148, which apt
|
||||
# sorts after that RC and before 0.2.0, so installing it upgrades without
|
||||
# --reinstall. Uncommitted changes add the build time (.m20261009153000),
|
||||
# so each such build is newer than the last.
|
||||
DEB_ARCH ?= $(shell dpkg --print-architecture 2>/dev/null || go env GOARCH)
|
||||
DEB_VERSION ?= $(subst -,~,$(VERSION))
|
||||
DEB_DEV_VERSION := $(shell [ -n '$(DESCRIBE)' ] || exit 0; \
|
||||
m=; git diff --quiet HEAD 2>/dev/null || m=.m$$(date -u +%Y%m%d%H%M%S); \
|
||||
echo '$(DESCRIBE)' | sed -E 's/^v(.*)-([0-9]+)-g([0-9a-f]{7})[0-9a-f]*$$/\1+git\2.g\3/' | tr - '~' | sed "s/$$/$$m/")
|
||||
DEB_VERSION ?= $(if $(RELEASE_TAG),$(subst -,~,$(VERSION)),$(or $(DEB_DEV_VERSION),$(subst -,~,$(VERSION))))
|
||||
|
||||
.PHONY: all build completion test vet e2e install install-files uninstall deb dist cli-dist clean
|
||||
|
||||
|
||||
@@ -43,14 +43,26 @@ error: www.adbraun.lol has a CNAME, so it can't have any other records (found A)
|
||||
using it).
|
||||
Passwords use argon2id, sessions have CSRF protection, logins are rate
|
||||
limited.
|
||||
* **Two-factor login** with any authenticator app (TOTP), with recovery
|
||||
codes; optional per user, or required for administrators or everyone
|
||||
(`totp.require`). With it on, creating API tokens and changing users
|
||||
asks for a fresh code, so a stolen password can't be turned into a token.
|
||||
* **Audit log** of every login, change and token, with user, token, IP and
|
||||
outcome.
|
||||
* **Daily backups** of the database by a systemd timer, checked as they are
|
||||
written, with retention that never deletes the last few; `jarld restore`
|
||||
puts one back safely.
|
||||
* **Daily backups** of the database and the DNSSEC keys by a systemd timer,
|
||||
checked as they are written, with retention that never deletes the last
|
||||
few; `jarld restore` puts one back safely, and raises zone serials so
|
||||
named and the secondaries take the restored data.
|
||||
* **Name server checks**: whether the secondaries serve the current serial,
|
||||
and, for zones on the internet, whether the parent zone delegates them
|
||||
to the right name servers with the right glue.
|
||||
to the right name servers with the right glue and DS records.
|
||||
* **DNSSEC signing** per zone, by BIND itself, with every step that involves
|
||||
the parent zone guarded: Jarl says when the DS record can be published,
|
||||
refuses to make a zone unsigned while its DS may still be out there, turns
|
||||
signing off in safe steps, and can wipe the slate clean.
|
||||
* **Porkbun support** (optional): the domain's name servers, glue records
|
||||
(any number, IPv4 and IPv6) and DS records at the registrar, set from Jarl
|
||||
after checks that the change won't take the domain offline.
|
||||
* **No token needed on the server**: root's `sudo jarl ...` talks to jarld
|
||||
over a local unix socket, so a lost token or password never locks you
|
||||
out.
|
||||
@@ -59,6 +71,10 @@ 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.
|
||||
* **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.
|
||||
@@ -99,10 +115,12 @@ in `/etc/jarl/jarl.yaml` to reach it from your management network.
|
||||
| [docs/configuration.md](docs/configuration.md) | `/etc/jarl/jarl.yaml` reference |
|
||||
| [docs/cli.md](docs/cli.md) | The `jarl` command line client |
|
||||
| [docs/api.md](docs/api.md) | REST API reference |
|
||||
| [docs/permissions.md](docs/permissions.md) | Users, tokens, grants and roles |
|
||||
| [docs/permissions.md](docs/permissions.md) | Users, tokens, grants and roles, two-factor login |
|
||||
| [docs/views.md](docs/views.md) | Split DNS: adding views to a server that has none |
|
||||
| [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/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 |
|
||||
@@ -123,8 +141,10 @@ these pages:
|
||||
fields adapt to the zone type), per-zone records
|
||||
with inline editing, type-specific hints and a preview of the exact record
|
||||
that will be added (reverse zones take addresses and suggest host names), settings (SOA, TTL, serial
|
||||
policy, view), options, the generated zone file, and pasting a zone file
|
||||
to merge or replace records.
|
||||
policy, view), options, the generated zone file, pasting a zone file
|
||||
to merge or replace records, name server checks, DNSSEC (keys, the DS
|
||||
record to copy, and each step with what it needs first) and, with a
|
||||
Porkbun key, the registrar.
|
||||
* **Views, ACLs, TSIG keys, Server options**: wherever an address match list
|
||||
is entered, the existing ACLs and keys are offered and unknown names are
|
||||
flagged. The options editor knows every
|
||||
@@ -133,7 +153,7 @@ these pages:
|
||||
* **Changes**: pending edits with the diff, named-checkconf output and
|
||||
problems, applied as one changeset. History lists applied changesets with
|
||||
their diffs and a revert button.
|
||||
* **Audit, Users, Account** (password, API tokens) and **Search**.
|
||||
* **Audit, Users, Account** (password, two-factor login, API tokens) and **Search**.
|
||||
|
||||
By default, edits are collected as pending changes. Pending rows are shown
|
||||
inline (green = new, yellow = changed, struck out = deleted) until you
|
||||
@@ -151,9 +171,13 @@ review step.
|
||||
|
||||
## Status
|
||||
|
||||
Version 0.1.0, the first release. It is feature-complete for one server's
|
||||
configuration. DNSSEC signing and managing several servers are not handled
|
||||
yet ([concepts.md](docs/concepts.md#what-jarl-deliberately-doesnt-do-yet)).
|
||||
Version 0.3.0 is the current release, and 0.4.0 is in testing
|
||||
(0.4.0-rc.3): it adds blocking (blocklists and your own rules, like
|
||||
Pi-hole, done by named), disabling zones, server statistics and view keys. 0.3.0 added disabled records, script-friendly
|
||||
CLI output and client names in the statistics, on top of 0.2.0's DNSSEC
|
||||
signing, Porkbun support, two-factor login and view colours. Jarl is feature-complete for one server's configuration.
|
||||
Managing several servers from one jarld and dynamic updates are not
|
||||
handled yet ([concepts.md](docs/concepts.md#what-jarl-deliberately-doesnt-do-yet)).
|
||||
|
||||
## License
|
||||
|
||||
|
||||
+70
-3
@@ -200,6 +200,14 @@ func statusCmd() *cobra.Command {
|
||||
for _, t := range resp.ExpiringTokens {
|
||||
fmt.Fprintf(w, "Token:\t%s/%s (id %d) expires %s\n", t.Username, t.Name, t.ID, fmtExpiry(t.ExpiresAt, nil))
|
||||
}
|
||||
if o := resp.TOTP; o != nil {
|
||||
if len(o.Missing) > 0 {
|
||||
fmt.Fprintf(w, "2FA:\trequired (%s) but not set up by %s\n", o.Policy, strings.Join(o.Missing, ", "))
|
||||
}
|
||||
if o.SharedAdmin != "" {
|
||||
fmt.Fprintf(w, "Accounts:\tthe first account %q is still active next to personal administrator accounts; disable it if it is shared ('jarl user update %s --disabled')\n", o.SharedAdmin, o.SharedAdmin)
|
||||
}
|
||||
}
|
||||
if len(st.Problems) > 0 {
|
||||
w.(interface{ Flush() error }).Flush()
|
||||
fmt.Println("Problems:")
|
||||
@@ -333,6 +341,19 @@ func userID(c *client.Client, name string) (int64, error) {
|
||||
return 0, fmt.Errorf("no user %q", name)
|
||||
}
|
||||
|
||||
// twoFA describes a user's two-factor login.
|
||||
func twoFA(u api.User) string {
|
||||
switch {
|
||||
case u.TOTPEnabledAt != nil:
|
||||
return "on since " + fmtTime(u.TOTPEnabledAt)
|
||||
case u.TOTPRequired:
|
||||
return "REQUIRED, not set up (they can only set it up until they do)"
|
||||
case !u.HasPassword:
|
||||
return "-"
|
||||
}
|
||||
return "off"
|
||||
}
|
||||
|
||||
func printUser(w io.Writer, u api.User) {
|
||||
fmt.Fprintf(w, "User:\t%s (id %d)\n", u.Username, u.ID)
|
||||
role := "user"
|
||||
@@ -346,6 +367,7 @@ func printUser(w io.Writer, u api.User) {
|
||||
role += ", no password (tokens only)"
|
||||
}
|
||||
fmt.Fprintf(w, "Role:\t%s\n", role)
|
||||
fmt.Fprintf(w, "2FA:\t%s\n", twoFA(u))
|
||||
for i, g := range u.GrantStrings {
|
||||
label := ""
|
||||
if i == 0 {
|
||||
@@ -384,9 +406,18 @@ func userCmd() *cobra.Command {
|
||||
return err
|
||||
}
|
||||
return output(users, func(w io.Writer) {
|
||||
fmt.Fprintln(w, "ID\tUSER\tADMIN\tDISABLED\tGRANTS\tLAST LOGIN")
|
||||
fmt.Fprintln(w, "ID\tUSER\tADMIN\tDISABLED\t2FA\tGRANTS\tLAST LOGIN")
|
||||
for _, u := range users {
|
||||
fmt.Fprintf(w, "%d\t%s\t%v\t%v\t%s\t%s\n", u.ID, u.Username, u.Admin, u.Disabled, strings.Join(u.GrantStrings, "; "), fmtTime(u.LastLoginAt))
|
||||
tfa := "off"
|
||||
switch {
|
||||
case u.TOTPEnabledAt != nil:
|
||||
tfa = "on"
|
||||
case u.TOTPRequired:
|
||||
tfa = "MISSING"
|
||||
case !u.HasPassword:
|
||||
tfa = "-"
|
||||
}
|
||||
fmt.Fprintf(w, "%d\t%s\t%v\t%v\t%s\t%s\t%s\n", u.ID, u.Username, u.Admin, u.Disabled, tfa, strings.Join(u.GrantStrings, "; "), fmtTime(u.LastLoginAt))
|
||||
}
|
||||
})
|
||||
},
|
||||
@@ -548,7 +579,43 @@ func userCmd() *cobra.Command {
|
||||
},
|
||||
}
|
||||
del.Flags().BoolVarP(&yes, "yes", "y", false, "don't ask for confirmation")
|
||||
cmd.AddCommand(list, show, create, update, del)
|
||||
totpCmd := &cobra.Command{Use: "totp", Short: "Two-factor login of a user"}
|
||||
totpReset := &cobra.Command{
|
||||
Use: "reset USER",
|
||||
Short: "Turn off a user's two-factor login (a lost phone without recovery codes)",
|
||||
Long: `Turns off a user's two-factor login, deletes their recovery codes and logs
|
||||
them out. They log in with the password alone next time and can set it up
|
||||
again under Account (they must, if totp.require applies to them).
|
||||
|
||||
Users set up two-factor login themselves, in the web UI. Without any
|
||||
administrator able to log in, use 'sudo -u jarl jarld user totp reset USER'
|
||||
on the server.`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if !confirm("Turn off two-factor login of "+args[0]+"?", yes) {
|
||||
return fmt.Errorf("canceled")
|
||||
}
|
||||
c, err := newClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
id, err := userID(c, args[0])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var u api.User
|
||||
if err := c.Do(ctx(), "DELETE", fmt.Sprintf("/users/%d/totp", id), nil, nil, &u); err != nil {
|
||||
return err
|
||||
}
|
||||
if !flagQuiet {
|
||||
fmt.Printf("two-factor login of %s is off\n", u.Username)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
totpReset.Flags().BoolVarP(&yes, "yes", "y", false, "don't ask for confirmation")
|
||||
totpCmd.AddCommand(totpReset)
|
||||
cmd.AddCommand(list, show, create, update, del, totpCmd)
|
||||
return cmd
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,498 @@
|
||||
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 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\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\n", b.Name, b.State, 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 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")
|
||||
|
||||
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
|
||||
}
|
||||
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")
|
||||
|
||||
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 != "":
|
||||
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)
|
||||
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"
|
||||
}
|
||||
+21
-9
@@ -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,6 +106,16 @@ 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 {
|
||||
|
||||
+14
-1
@@ -15,6 +15,7 @@ import (
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"jarl/internal/api"
|
||||
"jarl/internal/model"
|
||||
"jarl/internal/rr"
|
||||
)
|
||||
|
||||
@@ -23,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"} {
|
||||
@@ -71,8 +72,12 @@ func argCompletion(c *cobra.Command) cobra.CompletionFunc {
|
||||
return lookup(cmd, "zone")
|
||||
case p == "VIEW":
|
||||
return lookup(cmd, "view")
|
||||
case p == "COLOR":
|
||||
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":
|
||||
@@ -196,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 {
|
||||
|
||||
+77
-3
@@ -4,6 +4,7 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/url"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
@@ -27,9 +28,9 @@ func viewCmd() *cobra.Command {
|
||||
return err
|
||||
}
|
||||
return output(views, func(w io.Writer) {
|
||||
fmt.Fprintln(w, "#\tID\tVIEW\tMATCH-CLIENTS\tMATCH-DESTINATIONS\tZONES")
|
||||
fmt.Fprintln(w, "#\tID\tVIEW\tMATCH-CLIENTS\tMATCH-DESTINATIONS\tZONES\tCOLOR")
|
||||
for i, v := range views {
|
||||
fmt.Fprintf(w, "%d\t%d\t%s\t%s\t%s\t%d\n", i+1, v.ID, v.Name, orAny(v.MatchClients), orAny(v.MatchDestinations), v.Zones)
|
||||
fmt.Fprintf(w, "%d\t%d\t%s\t%s\t%s\t%d\t%s\n", i+1, v.ID, v.Name, orAny(v.MatchClients), orAny(v.MatchDestinations), v.Zones, orDefault(v.Color))
|
||||
}
|
||||
})
|
||||
},
|
||||
@@ -172,10 +173,83 @@ func viewCmd() *cobra.Command {
|
||||
return report(resp)
|
||||
},
|
||||
}
|
||||
cmd.AddCommand(list, create, update, del, order)
|
||||
color := &cobra.Command{
|
||||
Use: "color VIEW COLOR",
|
||||
Short: "Set the colour the web UI shows a view in",
|
||||
Long: "Set the colour the web UI shows a view in, so zones of the same view are easy to spot.\n" +
|
||||
"Colours: default (the link colour), " + strings.Join(model.ViewColors, ", ") + ".\n" +
|
||||
"This only changes the web UI: it applies at once, with no changeset or deploy.",
|
||||
Example: ` jarl view color internal green
|
||||
jarl view color internal default`,
|
||||
Args: cobra.ExactArgs(2),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := newClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
col := strings.ToLower(args[1])
|
||||
if col == "default" {
|
||||
col = ""
|
||||
} else if !slices.Contains(model.ViewColors, col) {
|
||||
return fmt.Errorf("unknown colour %q: use default, %s", args[1], strings.Join(model.ViewColors, ", "))
|
||||
}
|
||||
var v api.View
|
||||
if err := c.Do(ctx(), "PUT", "/views/"+url.PathEscape(args[0])+"/color", nil, api.ViewColor{Color: col}, &v); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("View %s: colour %s\n", v.Name, orDefault(v.Color))
|
||||
return nil
|
||||
},
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
func orDefault(s string) string {
|
||||
if s == "" {
|
||||
return "default"
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func orAny(list []string) string {
|
||||
if len(list) == 0 {
|
||||
return "any"
|
||||
|
||||
@@ -0,0 +1,389 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"jarl/internal/api"
|
||||
"jarl/internal/dnssec"
|
||||
)
|
||||
|
||||
func zoneDNSSECCmd() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "dnssec",
|
||||
Short: "Sign a zone with DNSSEC, and turn signing off safely",
|
||||
Long: `named signs the zone itself (BIND's dnssec-policy with inline signing): Jarl
|
||||
keeps writing the unsigned zone, and named keeps a signed copy next to it
|
||||
and its keys in Jarl's key directory. See docs/dnssec.md.
|
||||
|
||||
Turning signing on is safe at any time; validation only starts once you add
|
||||
the zone's DS record at the parent zone (your registrar), and 'status' says
|
||||
when that is safe. Turning it off goes through BIND's insecure policy, which
|
||||
keeps the zone signed until the DS is gone from the parent.`,
|
||||
}
|
||||
var view string
|
||||
status := &cobra.Command{
|
||||
Use: "status ZONE",
|
||||
Short: "Show the zone's keys, its DS record and the next step",
|
||||
Example: " jarl zone dnssec status adbraun.lol --view external",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
resp, err := dnssecStatus(args[0], view)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return output(resp, func(w io.Writer) { printDNSSEC(w, resp) })
|
||||
},
|
||||
}
|
||||
status.Flags().StringVar(&view, "view", "", "view of the zone")
|
||||
|
||||
var policy string
|
||||
enable := &cobra.Command{
|
||||
Use: "enable ZONE",
|
||||
Short: "Start signing the zone",
|
||||
Long: `Starts signing the zone with BIND's default policy (one ECDSA P-256 key that
|
||||
is never replaced, so the DS record at the parent never has to change) or a
|
||||
custom dnssec-policy statement.
|
||||
|
||||
Nothing changes for resolvers until the zone's DS record is added at the
|
||||
parent zone. named first publishes the key for long enough that every cache
|
||||
has it ('status' shows when: about a day with the default policy); add the
|
||||
DS after that.`,
|
||||
Example: " jarl zone dnssec enable adbraun.lol --view external",
|
||||
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{"dnssec_policy": policy})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := report(resp); err != nil || !resp.Applied {
|
||||
return err
|
||||
}
|
||||
if !flagQuiet {
|
||||
fmt.Printf("\nnamed is signing %s now. Add the DS record at your registrar only when\n'jarl zone dnssec status %s' says the zone is ready.\n", strings.TrimSuffix(args[0], "."), args[0]+viewFlag(view))
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
enable.Flags().StringVar(&view, "view", "", "view of the zone")
|
||||
enable.Flags().StringVar(&policy, "policy", "default", "dnssec-policy: default, or a custom policy defined as a top-level dnssec-policy statement")
|
||||
|
||||
var yes bool
|
||||
disable := &cobra.Command{
|
||||
Use: "disable ZONE",
|
||||
Short: "Turn signing off, one safe step at a time",
|
||||
Long: `Turns signing off in two steps, so the zone never goes unsigned while the
|
||||
parent zone still has its DS record:
|
||||
|
||||
1. The first run switches the zone to BIND's insecure policy. named keeps it
|
||||
signed and publishes a CDS "delete" record. Remove the DS record at your
|
||||
registrar, then run 'jarl zone dnssec check-ds' so named knows. named
|
||||
waits until resolvers have forgotten the DS (the parent's DS TTL, often a
|
||||
day) and then removes the keys and signatures.
|
||||
2. Run it again when 'status' says turning off has finished: DNSSEC is then
|
||||
switched off for the zone.
|
||||
|
||||
A zone whose DS never reached the parent can be wiped at once instead
|
||||
('jarl zone dnssec wipe').`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
st, err := dnssecStatus(args[0], view)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var next string
|
||||
switch {
|
||||
case st.Policy == "":
|
||||
fmt.Println("DNSSEC is already off for this zone.")
|
||||
if st.Phase.Code == dnssec.PhaseLeftover {
|
||||
fmt.Println("Old keys are still in its key directory; 'jarl zone dnssec wipe' removes them.")
|
||||
}
|
||||
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 from the parent zone.", displayName(st.Zone)), yes) {
|
||||
return fmt.Errorf("canceled")
|
||||
}
|
||||
next = "insecure"
|
||||
case !st.Phase.CanFinish:
|
||||
fmt.Fprintf(os.Stderr, "%s\n", st.Phase.Summary)
|
||||
if st.Phase.Next != "" {
|
||||
fmt.Fprintf(os.Stderr, "%s\n", st.Phase.Next)
|
||||
}
|
||||
return &checkFailed{"turning DNSSEC off hasn't finished yet"}
|
||||
}
|
||||
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{"dnssec_policy": next})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := report(resp); err != nil || !resp.Applied || flagQuiet {
|
||||
return err
|
||||
}
|
||||
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').")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
disable.Flags().StringVar(&view, "view", "", "view of the zone")
|
||||
disable.Flags().BoolVarP(&yes, "yes", "y", false, "don't ask for confirmation")
|
||||
|
||||
var resolver string
|
||||
checkDS := &cobra.Command{
|
||||
Use: "check-ds ZONE",
|
||||
Short: "Check the parent zone's DS records and tell named",
|
||||
Long: `Asks every server of the parent zone for the zone's DS records, and tells
|
||||
named (rndc dnssec -checkds) when its key's DS has appeared there (after you
|
||||
added it at the registrar) or is gone (while turning DNSSEC off). named
|
||||
moves to the next step only once it knows.
|
||||
|
||||
The parent is found through jarld's resolver, or --resolver. Exits with 2
|
||||
when the parent couldn't be checked.`,
|
||||
Example: " jarl zone dnssec check-ds adbraun.lol --view external",
|
||||
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)
|
||||
if resolver != "" {
|
||||
qv.Set("resolver", resolver)
|
||||
}
|
||||
var resp api.CheckDSResponse
|
||||
if err := c.Do(ctx(), "POST", path+"/dnssec/check-ds", qv, nil, &resp); err != nil {
|
||||
return err
|
||||
}
|
||||
err = output(resp, func(w io.Writer) {
|
||||
p := resp.Parent
|
||||
if p.Error != "" {
|
||||
fmt.Fprintf(w, "error: %s\n", p.Error)
|
||||
} else {
|
||||
fmt.Fprintf(w, "Parent zone:\t%s (%d of %d servers answered, found through %s)\n", displayName(p.Parent), p.Reached, len(p.Servers), p.Resolver)
|
||||
if !p.Delegated {
|
||||
fmt.Fprintf(w, "Delegation:\tnone (the zone isn't delegated from %s)\n", displayName(p.Parent))
|
||||
}
|
||||
if len(p.DS) == 0 {
|
||||
fmt.Fprintln(w, "DS records:\tnone")
|
||||
}
|
||||
for _, d := range p.DS {
|
||||
fmt.Fprintf(w, "DS record:\tkey %d, algorithm %d, digest type %d\n", d.KeyTag, d.Algorithm, d.DigestType)
|
||||
}
|
||||
}
|
||||
flush(w)
|
||||
for _, m := range resp.Marked {
|
||||
fmt.Printf("named told: %s\n", m)
|
||||
}
|
||||
for _, n := range resp.Notes {
|
||||
fmt.Printf("note: %s\n", n)
|
||||
}
|
||||
if resp.Status != nil {
|
||||
fmt.Printf("\n%s\n", resp.Status.Phase.Summary)
|
||||
if resp.Status.Phase.Next != "" {
|
||||
fmt.Println(resp.Status.Phase.Next)
|
||||
}
|
||||
}
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if resp.Parent.Error != "" {
|
||||
return &checkFailed{"the parent zone couldn't be checked"}
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
checkDS.Flags().StringVar(&view, "view", "", "view of the zone")
|
||||
checkDS.Flags().StringVar(&resolver, "resolver", "", "resolver to find the parent zone with, e.g. 9.9.9.9 (default: the server's)")
|
||||
|
||||
var force bool
|
||||
wipe := &cobra.Command{
|
||||
Use: "wipe ZONE",
|
||||
Short: "Switch signing off at once and delete the zone's keys (a clean slate)",
|
||||
Long: `Switches DNSSEC off for the zone at once (skipping the insecure policy) and
|
||||
deletes its keys and named's signed copy, so that signing it again starts
|
||||
from scratch. Also cleans up a zone that is off but still has old keys.
|
||||
|
||||
This is only safe when the parent zone has no DS record for the zone: Jarl
|
||||
checks the parent first and refuses while it has one. When the parent can't
|
||||
be checked (a zone that isn't on the internet, or no internet access),
|
||||
--force wipes anyway. Needs an administrator.`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if !confirm(fmt.Sprintf("Switch DNSSEC off for %s and delete its keys? Make sure the parent zone has no DS record for it.", strings.TrimSuffix(args[0], ".")), yes) {
|
||||
return fmt.Errorf("canceled")
|
||||
}
|
||||
c, err := newClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
path, qv := zonePath(args[0], view)
|
||||
if resolver != "" {
|
||||
qv.Set("resolver", resolver)
|
||||
}
|
||||
if flagDryRun {
|
||||
qv.Set("dry_run", "1")
|
||||
}
|
||||
var resp api.DNSSECWipeResponse
|
||||
if err := c.Do(ctx(), "POST", path+"/dnssec/wipe", qv, api.DNSSECWipe{Force: force, DryRun: flagDryRun}, &resp); err != nil {
|
||||
return err
|
||||
}
|
||||
return output(resp, func(w io.Writer) {
|
||||
verb := "removed"
|
||||
if resp.DryRun {
|
||||
verb = "would remove"
|
||||
}
|
||||
for _, f := range resp.Removed {
|
||||
fmt.Printf("%s %s\n", verb, f)
|
||||
}
|
||||
if resp.DryRun {
|
||||
fmt.Println("dry run: nothing was changed")
|
||||
} else {
|
||||
fmt.Println("DNSSEC is off and the zone's keys are gone.")
|
||||
}
|
||||
})
|
||||
},
|
||||
}
|
||||
wipe.Flags().StringVar(&view, "view", "", "view of the zone")
|
||||
wipe.Flags().BoolVar(&force, "force", false, "wipe although the parent zone couldn't be checked")
|
||||
wipe.Flags().StringVar(&resolver, "resolver", "", "resolver to find the parent zone with (default: the server's)")
|
||||
wipe.Flags().BoolVarP(&yes, "yes", "y", false, "don't ask for confirmation")
|
||||
|
||||
cmd.AddCommand(status, enable, disable, checkDS, wipe)
|
||||
return cmd
|
||||
}
|
||||
|
||||
func viewFlag(view string) string {
|
||||
if view == "" {
|
||||
return ""
|
||||
}
|
||||
return " --view " + view
|
||||
}
|
||||
|
||||
func dnssecStatus(zone, view string) (*api.DNSSECResponse, error) {
|
||||
c, err := newClient()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
path, qv := zonePath(zone, view)
|
||||
var resp api.DNSSECResponse
|
||||
if err := c.Get(ctx(), path+"/dnssec", qv, &resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
func printDNSSEC(w io.Writer, r *api.DNSSECResponse) {
|
||||
head := displayName(r.Zone)
|
||||
if r.View != "" {
|
||||
head += " [view " + r.View + "]"
|
||||
}
|
||||
fmt.Fprintf(w, "Zone:\t%s\n", head)
|
||||
policy := r.Policy
|
||||
switch policy {
|
||||
case "":
|
||||
policy = "none (not signed)"
|
||||
case "insecure":
|
||||
policy = "insecure (turning DNSSEC off)"
|
||||
}
|
||||
fmt.Fprintf(w, "Policy:\t%s\n", policy)
|
||||
fmt.Fprintf(w, "Status:\t%s\n", r.Phase.Summary)
|
||||
if r.Phase.Next != "" {
|
||||
fmt.Fprintf(w, "Next:\t%s\n", r.Phase.Next)
|
||||
}
|
||||
if r.Named != nil && r.Named.Secure {
|
||||
fmt.Fprintf(w, "named:\tsigned serial %d (zone file %d)", r.Named.SignedSerial, r.Named.Serial)
|
||||
if r.Named.NextKeyEvent != "" {
|
||||
fmt.Fprintf(w, ", next key event %s", r.Named.NextKeyEvent)
|
||||
}
|
||||
fmt.Fprintln(w)
|
||||
}
|
||||
if r.NamedError != "" {
|
||||
fmt.Fprintf(w, "named:\t%s\n", r.NamedError)
|
||||
}
|
||||
if r.Policy != "" || len(r.Keys) > 0 {
|
||||
fmt.Fprintf(w, "Key directory:\t%s\n", r.KeyDir)
|
||||
}
|
||||
if r.KeyError != "" {
|
||||
fmt.Fprintf(w, "Keys:\tcan't be read: %s\n", r.KeyError)
|
||||
}
|
||||
flush(w)
|
||||
if len(r.Keys) > 0 {
|
||||
fmt.Fprintln(w, "\nKEY\tROLE\tALGORITHM\tGOAL\tDNSKEY\tDS AT PARENT\tCREATED")
|
||||
for _, k := range r.Keys {
|
||||
created := "-"
|
||||
if k.Created != nil {
|
||||
created = k.Created.Local().Format("2006-01-02 15:04")
|
||||
}
|
||||
fmt.Fprintf(w, "%d\t%s\t%s\t%s\t%s\t%s\t%s\n", k.Tag, k.Role, k.AlgorithmName, dash(k.Goal), dash(k.DNSKEYState), dash(k.DSState), created)
|
||||
}
|
||||
flush(w)
|
||||
}
|
||||
var ds []*dnssec.Key
|
||||
for _, k := range r.Keys {
|
||||
if k.DS != nil && k.Goal == dnssec.Omnipresent {
|
||||
ds = append(ds, k)
|
||||
}
|
||||
}
|
||||
if len(ds) > 0 && r.Policy != "insecure" {
|
||||
where := "at your registrar"
|
||||
if r.ParentZone != "" {
|
||||
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 {
|
||||
fmt.Fprintf(w, " (not yet: wait until %s)\n", r.Phase.DSReadyAt.Local().Format(time.RFC1123))
|
||||
}
|
||||
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 "-"
|
||||
}
|
||||
return s
|
||||
}
|
||||
+108
-6
@@ -3,6 +3,7 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
@@ -11,6 +12,7 @@ import (
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -225,7 +227,7 @@ Exit codes: 0 ok, 1 error, 3 not authenticated or not allowed, 4 not found,
|
||||
5 conflict, 6 invalid or rejected by validation, 7 server or BIND error.`,
|
||||
SilenceUsage: true,
|
||||
SilenceErrors: true,
|
||||
Version: version.Version,
|
||||
Version: version.String(),
|
||||
PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
|
||||
return checkFanOut(cmd)
|
||||
},
|
||||
@@ -237,10 +239,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 +250,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 +264,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 +296,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 +473,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 +530,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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"jarl/internal/api"
|
||||
)
|
||||
|
||||
func zoneRegistrarCmd() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "registrar",
|
||||
Short: "The domain's name servers, glue and DS records at the registrar (Porkbun)",
|
||||
Long: `Shows and changes a domain's delegation at the registrar Porkbun: its name
|
||||
servers, the glue records of name servers inside the domain, and its DNSSEC
|
||||
DS records. Needs a Porkbun API key in jarl.yaml (porkbun:, see
|
||||
docs/dnssec.md#porkbun) and an administrator.
|
||||
|
||||
Every change checks first that it won't break the domain (for example that
|
||||
the new name servers serve the zone, or that the zone is signed with the
|
||||
key a DS record points at) and is refused otherwise; --dry-run shows those
|
||||
checks, --force skips them.`,
|
||||
}
|
||||
var view string
|
||||
status := &cobra.Command{
|
||||
Use: "status ZONE",
|
||||
Short: "Compare the registrar's name servers, glue and DS records with the zone",
|
||||
Example: " jarl zone registrar status adbraun.lol --view external",
|
||||
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)
|
||||
var resp api.RegistrarResponse
|
||||
if err := c.Get(ctx(), path+"/registrar", qv, &resp); err != nil {
|
||||
return err
|
||||
}
|
||||
err = output(resp, func(w io.Writer) {
|
||||
if resp.Error != "" {
|
||||
fmt.Fprintf(w, "error: %s\n", resp.Error)
|
||||
return
|
||||
}
|
||||
fmt.Fprintf(w, "Domain:\t%s at %s\n", resp.Domain, resp.Registrar)
|
||||
fmt.Fprintf(w, "Name servers:\t%s\n", strings.Join(resp.NS, ", "))
|
||||
fmt.Fprintf(w, "Zone's NS records:\t%s\n", strings.Join(resp.JarlNS, ", "))
|
||||
flush(w)
|
||||
if len(resp.Glue) > 0 {
|
||||
fmt.Fprintln(w, "\nGLUE FOR\tAT REGISTRAR\tIN THE ZONE\tTO DO")
|
||||
for _, g := range resp.Glue {
|
||||
todo := g.Action
|
||||
if todo == "" {
|
||||
todo = "-"
|
||||
if !g.Needed && !g.InUse {
|
||||
todo = "unused"
|
||||
}
|
||||
}
|
||||
fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", g.Host, firstNonEmpty(strings.Join(g.Registry, ", "), "none"), firstNonEmpty(strings.Join(g.Jarl, ", "), "-"), todo)
|
||||
}
|
||||
flush(w)
|
||||
}
|
||||
if len(resp.DS) > 0 {
|
||||
fmt.Fprintln(w, "\nDS KEY TAG\tALGORITHM\tDIGEST TYPE\tMATCHES THE ZONE")
|
||||
for _, d := range resp.DS {
|
||||
fmt.Fprintf(w, "%d\t%d\t%d\t%v\n", d.KeyTag, d.Algorithm, d.DigestType, d.Matches)
|
||||
}
|
||||
flush(w)
|
||||
}
|
||||
fmt.Println()
|
||||
for _, f := range resp.Findings {
|
||||
fmt.Printf("%-8s %s\n", f.Level+":", f.Message)
|
||||
}
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if resp.Error != "" {
|
||||
return &checkFailed{"the registrar couldn't be asked"}
|
||||
}
|
||||
for _, f := range resp.Findings {
|
||||
if f.Level == "error" {
|
||||
return &checkFailed{"the registrar's records don't match the zone"}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
status.Flags().StringVar(&view, "view", "", "view of the zone")
|
||||
|
||||
var force bool
|
||||
change := func(use, short, long, method, sub string, args cobra.PositionalArgs, body func(args []string) (string, any, error)) *cobra.Command {
|
||||
c := &cobra.Command{
|
||||
Use: use,
|
||||
Short: short,
|
||||
Long: long,
|
||||
Args: args,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
cl, err := newClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
path, qv := zonePath(args[0], view)
|
||||
if flagDryRun {
|
||||
qv.Set("dry_run", "1")
|
||||
}
|
||||
extra, b, err := body(args)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var resp api.RegistrarChangeResponse
|
||||
if err := cl.Do(ctx(), method, path+"/registrar/"+sub+extra, qv, b, &resp); err != nil {
|
||||
return err
|
||||
}
|
||||
return output(resp, func(w io.Writer) {
|
||||
for _, f := range resp.Checks {
|
||||
fmt.Printf("%-8s %s\n", f.Level+":", f.Message)
|
||||
}
|
||||
verb := "done:"
|
||||
if resp.DryRun {
|
||||
verb = "would:"
|
||||
}
|
||||
for _, d := range resp.Done {
|
||||
fmt.Printf("%-8s %s\n", verb, d)
|
||||
}
|
||||
if len(resp.Done) == 0 {
|
||||
fmt.Println("nothing to change")
|
||||
}
|
||||
for _, wn := range resp.Warnings {
|
||||
fmt.Printf("note: %s\n", wn)
|
||||
}
|
||||
if resp.DryRun {
|
||||
fmt.Println("dry run: nothing was changed")
|
||||
}
|
||||
})
|
||||
},
|
||||
}
|
||||
c.Flags().StringVar(&view, "view", "", "view of the zone")
|
||||
c.Flags().BoolVar(&force, "force", false, "skip the checks that the change won't break the domain")
|
||||
return c
|
||||
}
|
||||
syncGlue := change("sync-glue ZONE", "Create or update glue records for the zone's name servers inside the domain",
|
||||
`Gives every name server in the zone's NS records that is inside the domain
|
||||
(ns1.adbraun.lol for adbraun.lol) a glue record at the registrar with the
|
||||
zone's A and AAAA records for it: one glue record per name server, each with
|
||||
all its addresses. Resolvers need glue to find such a server at all.
|
||||
|
||||
Changing the glue of a name server the domain is delegated to sends
|
||||
resolvers to the new addresses, so they must answer for the zone first.`,
|
||||
"POST", "glue", cobra.ExactArgs(1), func([]string) (string, any, error) {
|
||||
return "", api.RegistrarChange{Force: force, DryRun: flagDryRun}, nil
|
||||
})
|
||||
var key uint16
|
||||
publishDS := change("publish-ds ZONE", "Publish the zone's DS record at the registrar (turns DNSSEC validation on)",
|
||||
`Adds the DS record of the zone's key at the registrar, which publishes it in
|
||||
the parent zone: from then on validating resolvers check the zone's
|
||||
signatures. Refused until named says the zone is ready, and unless the
|
||||
domain is delegated to name servers that serve the signed zone.
|
||||
|
||||
Afterwards run 'jarl zone dnssec check-ds ZONE' once the parent zone has it.`,
|
||||
"POST", "ds", cobra.ExactArgs(1), func([]string) (string, any, error) {
|
||||
return "", api.RegistrarChange{KeyTag: key, Force: force, DryRun: flagDryRun}, nil
|
||||
})
|
||||
publishDS.Flags().Uint16Var(&key, "key", 0, "key tag (default: every key that needs a DS)")
|
||||
removeDS := change("remove-ds ZONE KEYTAG", "Remove a DS record at the registrar",
|
||||
`Removes a DS record at the registrar. Without the zone's DS record, resolvers
|
||||
stop validating it (it stays signed). This is the step 'jarl zone dnssec
|
||||
disable' asks for; a DS record that matches none of the zone's keys breaks
|
||||
the domain and should go at once.`,
|
||||
"DELETE", "ds/", cobra.ExactArgs(2), func(args []string) (string, any, error) {
|
||||
return args[1], nil, nil
|
||||
})
|
||||
setNS := change("set-ns ZONE [NAMESERVER...]", "Delegate the domain to the zone's name servers (or the ones given)",
|
||||
`Sets the domain's name servers at the registrar: by default to the zone's NS
|
||||
records. Checked first: each name server answers for the zone with the
|
||||
current serial, name servers inside the domain have glue records that match
|
||||
the zone (run sync-glue before), and DS records at the registrar match the
|
||||
zone's keys (otherwise the domain goes dark for validating resolvers).`,
|
||||
"PUT", "ns", cobra.MinimumNArgs(1), func(args []string) (string, any, error) {
|
||||
return "", api.RegistrarChange{NS: args[1:], Force: force, DryRun: flagDryRun}, nil
|
||||
})
|
||||
cmd.AddCommand(status, syncGlue, publishDS, removeDS, setNS)
|
||||
return cmd
|
||||
}
|
||||
+109
-10
@@ -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
@@ -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())
|
||||
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]) + "…"
|
||||
}
|
||||
}
|
||||
}
|
||||
+6
-2
@@ -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
|
||||
@@ -383,7 +387,7 @@ func versionCmd() *cobra.Command {
|
||||
Use: "version",
|
||||
Short: "Print the version",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
fmt.Println("jarld", version.Version)
|
||||
fmt.Println("jarld", version.String())
|
||||
},
|
||||
}
|
||||
}
|
||||
+167
-14
@@ -7,8 +7,11 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/user"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
"text/tabwriter"
|
||||
"time"
|
||||
|
||||
@@ -18,11 +21,13 @@ import (
|
||||
"jarl/internal/auth"
|
||||
"jarl/internal/backup"
|
||||
"jarl/internal/config"
|
||||
"jarl/internal/render"
|
||||
"jarl/internal/store"
|
||||
)
|
||||
|
||||
func backupCmd() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
var withoutKeys bool
|
||||
cmd := &cobra.Command{
|
||||
Use: "backup [FILE]",
|
||||
Short: "Back up the database (safe while jarld runs)",
|
||||
Long: `Writes a consistent copy of Jarl's database and checks it.
|
||||
@@ -35,10 +40,18 @@ say. This is what the jarl-backup.timer systemd timer runs every day.
|
||||
With FILE it writes a single copy there, which is never deleted
|
||||
automatically.
|
||||
|
||||
Each backup also holds the DNSSEC keys of signed zones (from Jarl's key
|
||||
directory, private keys included), so that 'jarld restore' can put them
|
||||
back: a zone whose keys are lost stops validating while its parent still
|
||||
has a DS record for them. named's private keys are readable only by named's
|
||||
user, so run this as root; jarl-backup.service has the capability to read
|
||||
them. --without-keys backs up only the database.
|
||||
|
||||
Every backup is recorded in the database, and the dashboard shows the newest.
|
||||
Restore one with 'jarld restore'.`,
|
||||
Example: ` sudo -u jarl jarld backup
|
||||
sudo -u jarl jarld backup /root/jarl-before-upgrade.db`,
|
||||
Example: ` sudo jarld backup
|
||||
sudo jarld backup /root/jarl-before-upgrade.db
|
||||
sudo systemctl start jarl-backup.service`,
|
||||
Args: cobra.MaximumNArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
cfg, err := config.Load(configPath, true)
|
||||
@@ -59,7 +72,14 @@ Restore one with 'jarld restore'.`,
|
||||
defer newDirOwner(filepath.Dir(cfg.Database)).fix(filepath.Dir(cfg.Database))
|
||||
ctx := context.Background()
|
||||
now := time.Now()
|
||||
keysDir := render.Paths{ZoneDir: cfg.BIND.ZoneDir}.KeysDir()
|
||||
if withoutKeys {
|
||||
keysDir = ""
|
||||
}
|
||||
fail := func(err error) error {
|
||||
if strings.Contains(err.Error(), "DNSSEC keys") {
|
||||
err = fmt.Errorf("%w; or back up only the database with --without-keys", err)
|
||||
}
|
||||
if rerr := st.RecordBackup(ctx, store.BackupRecord{At: now, Error: err.Error()}); rerr != nil {
|
||||
fmt.Fprintln(os.Stderr, "warning: couldn't record the failure in the database:", rerr)
|
||||
}
|
||||
@@ -74,27 +94,33 @@ Restore one with 'jarld restore'.`,
|
||||
os.Remove(dst)
|
||||
return fail(fmt.Errorf("backing up %s: %w", cfg.Database, err))
|
||||
}
|
||||
if keysDir != "" {
|
||||
if _, err := backup.AddFiles(ctx, dst, keysDir); err != nil {
|
||||
os.Remove(dst)
|
||||
return fail(err)
|
||||
}
|
||||
}
|
||||
if err := backup.Verify(ctx, dst); err != nil {
|
||||
os.Remove(dst)
|
||||
return fail(fmt.Errorf("the backup failed its check: %w", err))
|
||||
}
|
||||
fi, _ := os.Stat(dst)
|
||||
st.RecordBackup(ctx, store.BackupRecord{At: now, File: dst, Size: fi.Size()})
|
||||
fmt.Printf("Database backed up to %s (%s)\n", dst, size(fi.Size()))
|
||||
fmt.Printf("Database backed up to %s (%s)%s\n", dst, size(fi.Size()), keysNote(ctx, dst))
|
||||
return nil
|
||||
}
|
||||
|
||||
dir := cfg.Backup.Dir
|
||||
defer newDirOwner(dir).fix(dir)
|
||||
f, err := backup.Write(ctx, cfg.Database, dir, backup.KindJarl, now)
|
||||
f, err := backup.Write(ctx, cfg.Database, dir, backup.KindJarl, now, keysDir)
|
||||
if err != nil {
|
||||
return fail(fmt.Errorf("backing up %s: %w", cfg.Database, err))
|
||||
}
|
||||
fmt.Printf("Database backed up to %s (%s)\n", f.Path, size(f.Size))
|
||||
fmt.Printf("Database backed up to %s (%s)%s\n", f.Path, size(f.Size), keysNote(ctx, f.Path))
|
||||
kinds := []string{backup.KindJarl}
|
||||
if cfg.Backup.Stats {
|
||||
if _, err := os.Stat(cfg.Stats.Database); err == nil {
|
||||
sf, err := backup.Write(ctx, cfg.Stats.Database, dir, backup.KindStats, now)
|
||||
sf, err := backup.Write(ctx, cfg.Stats.Database, dir, backup.KindStats, now, "")
|
||||
if err != nil {
|
||||
return fail(fmt.Errorf("backing up the statistics database %s: %w", cfg.Stats.Database, err))
|
||||
}
|
||||
@@ -117,10 +143,20 @@ Restore one with 'jarld restore'.`,
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cmd.Flags().BoolVar(&withoutKeys, "without-keys", false, "back up only the database, not the DNSSEC keys")
|
||||
return cmd
|
||||
}
|
||||
|
||||
// keysNote says how many DNSSEC key files a backup holds.
|
||||
func keysNote(ctx context.Context, path string) string {
|
||||
if in, err := backup.Inspect(ctx, path); err == nil && in.KeyFiles > 0 {
|
||||
return ", with " + plural(in.KeyFiles, "DNSSEC key file")
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func restoreCmd() *cobra.Command {
|
||||
var yes bool
|
||||
var yes, withoutKeys bool
|
||||
cmd := &cobra.Command{
|
||||
Use: "restore [FILE | latest]",
|
||||
Short: "Replace the database with a backup (jarld must be stopped)",
|
||||
@@ -132,13 +168,20 @@ confirm replaces the database with it. jarld must be stopped first. The
|
||||
current database is kept as DATABASE.before-restore-<UTC time>, and the
|
||||
restore is recorded in the restored database's audit log.
|
||||
|
||||
The DNSSEC keys stored in the backup go back into Jarl's key directory,
|
||||
owned by named's user (bind.named_user), which needs root. Key files that
|
||||
are there but not in the backup are left alone. --without-keys restores
|
||||
only the database.
|
||||
|
||||
Then start jarld and bring named's files in line with the restored database:
|
||||
|
||||
systemctl start jarl
|
||||
jarl resync --dry-run # what differs
|
||||
jarl resync --force # write everything and reload named
|
||||
|
||||
See "Restoring" in docs/operations.md, also for zone serials and secondaries.`,
|
||||
Jarl raises the serials of zones whose data goes back to an older state
|
||||
above what named and the secondaries have, so they pick it up. See
|
||||
"Restoring" in docs/operations.md.`,
|
||||
Example: ` sudo jarld restore
|
||||
sudo systemctl stop jarl
|
||||
sudo jarld restore latest
|
||||
@@ -180,6 +223,24 @@ See "Restoring" in docs/operations.md, also for zone serials and secondaries.`,
|
||||
if info.Schema > store.SchemaVersion() {
|
||||
return fmt.Errorf("%s was made by a newer Jarl (schema %d, this one knows %d); install that version to restore it", src, info.Schema, store.SchemaVersion())
|
||||
}
|
||||
keysDir := render.Paths{ZoneDir: cfg.BIND.ZoneDir}.KeysDir()
|
||||
var keyFiles []backup.StoredFile
|
||||
if !withoutKeys {
|
||||
var root string
|
||||
root, keyFiles, err = backup.Files(ctx, src)
|
||||
if err != nil {
|
||||
return fmt.Errorf("reading the DNSSEC keys in %s: %w", src, err)
|
||||
}
|
||||
if len(keyFiles) > 0 && os.Geteuid() != 0 && !runningAs(cfg.BIND.NamedUser) {
|
||||
return fmt.Errorf("the backup holds %s, and putting them back for named needs root: run this as root (or add --without-keys to restore only the database)", plural(len(keyFiles), "DNSSEC key file"))
|
||||
}
|
||||
if root != "" && root != keysDir && len(keyFiles) > 0 {
|
||||
fmt.Printf("The keys were backed up from %s; they go to %s now.\n", root, keysDir)
|
||||
}
|
||||
}
|
||||
if info.SignedZones > 0 && len(keyFiles) == 0 && !withoutKeys {
|
||||
fmt.Fprintf(os.Stderr, "warning: the backup has %s but no DNSSEC keys (it was made with --without-keys, or before Jarl backed them up). Unless the keys are still in %s, named makes new ones, and a DS record for the old ones at the parent stops matching.\n", plural(info.SignedZones, "signed zone"), keysDir)
|
||||
}
|
||||
unlock, err := lockDatabase(cfg.Database)
|
||||
if errors.Is(err, errInUse) {
|
||||
return fmt.Errorf("jarld is running; stop it first (systemctl stop jarl), then run this again")
|
||||
@@ -192,6 +253,9 @@ See "Restoring" in docs/operations.md, also for zone serials and secondaries.`,
|
||||
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
|
||||
fmt.Fprintf(w, "Backup:\t%s\n", src)
|
||||
describe(w, info, backupTime(src))
|
||||
if len(keyFiles) > 0 {
|
||||
fmt.Fprintf(w, " DNSSEC keys\t%s (go back to %s)\n", plural(len(keyFiles), "file"), keysDir)
|
||||
}
|
||||
if cur, err := backup.Inspect(ctx, cfg.Database); err == nil {
|
||||
fmt.Fprintf(w, "\nCurrent database:\t%s\n", cfg.Database)
|
||||
describe(w, cur, time.Time{})
|
||||
@@ -224,13 +288,27 @@ See "Restoring" in docs/operations.md, also for zone serials and secondaries.`,
|
||||
moved = true
|
||||
}
|
||||
}
|
||||
if err := copyFile(src, db, 0o640); err != nil {
|
||||
restoreErr := copyFile(src, db, 0o640)
|
||||
if restoreErr == nil {
|
||||
// The keys go back to their own files, not into the
|
||||
// live database.
|
||||
restoreErr = backup.DropFiles(ctx, db)
|
||||
}
|
||||
if restoreErr != nil {
|
||||
os.Remove(db)
|
||||
if moved {
|
||||
os.Rename(aside, db)
|
||||
os.Rename(aside+"-wal", db+"-wal")
|
||||
os.Rename(aside+"-shm", db+"-shm")
|
||||
}
|
||||
return fmt.Errorf("restoring: %w (the current database was left in place)", err)
|
||||
return fmt.Errorf("restoring: %w (the current database was left in place)", restoreErr)
|
||||
}
|
||||
if len(keyFiles) > 0 {
|
||||
n, err := restoreKeys(cfg, keysDir, keyFiles)
|
||||
if err != nil {
|
||||
return fmt.Errorf("the database was restored, but putting back the DNSSEC keys failed after %d files: %w", n, err)
|
||||
}
|
||||
fmt.Printf("Put back %s in %s.\n", plural(n, "DNSSEC key file"), keysDir)
|
||||
}
|
||||
// Open it as jarld will: an older backup gets the current
|
||||
// schema now, and the restore goes into its audit log.
|
||||
@@ -255,17 +333,89 @@ Next:
|
||||
jarl resync --dry-run # what differs between the restored database and named's files
|
||||
jarl resync --force # write them and reload named
|
||||
|
||||
If you have secondary servers, zone serials older than what they last saw
|
||||
are ignored by them: raise them with 'jarl zone update ZONE --serial N'
|
||||
(see "Restoring" in docs/operations.md).
|
||||
Zones whose data goes back to an older state get a serial above the one
|
||||
named has, so named and the secondaries load them (see "Restoring" in
|
||||
docs/operations.md).
|
||||
`)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cmd.Flags().BoolVarP(&yes, "yes", "y", false, "don't ask for confirmation")
|
||||
cmd.Flags().BoolVar(&withoutKeys, "without-keys", false, "restore only the database, not the DNSSEC keys")
|
||||
return cmd
|
||||
}
|
||||
|
||||
// runningAs reports whether this process runs as the named user.
|
||||
func runningAs(name string) bool {
|
||||
u, err := user.Lookup(name)
|
||||
return err == nil && u.Uid == strconv.Itoa(os.Geteuid())
|
||||
}
|
||||
|
||||
// restoreKeys writes DNSSEC key files from a backup into keysDir, owned
|
||||
// by named's user and Jarl's file group with their backed-up modes, so
|
||||
// named can use them. Directories get the owner of the zone directory
|
||||
// (jarld's user), like the ones jarld makes. Files already there are
|
||||
// replaced; others are left alone.
|
||||
func restoreKeys(cfg *config.Config, keysDir string, files []backup.StoredFile) (int, error) {
|
||||
uid, gid := -1, -1
|
||||
if u, err := user.Lookup(cfg.BIND.NamedUser); err == nil {
|
||||
uid, _ = strconv.Atoi(u.Uid)
|
||||
} else {
|
||||
return 0, fmt.Errorf("named's user %q (bind.named_user): %w", cfg.BIND.NamedUser, err)
|
||||
}
|
||||
if g, err := user.LookupGroup(cfg.BIND.FileGroup); err == nil {
|
||||
gid, _ = strconv.Atoi(g.Gid)
|
||||
}
|
||||
dirUID, dirGID := -1, gid
|
||||
if fi, err := os.Stat(cfg.BIND.ZoneDir); err == nil {
|
||||
if st, ok := fi.Sys().(*syscall.Stat_t); ok {
|
||||
dirUID = int(st.Uid)
|
||||
}
|
||||
}
|
||||
var mkdir func(dir string) error
|
||||
mkdir = func(dir string) error {
|
||||
if _, err := os.Stat(dir); err == nil {
|
||||
return nil
|
||||
}
|
||||
if err := mkdir(filepath.Dir(dir)); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Mkdir(dir, 0o770); err != nil && !os.IsExist(err) {
|
||||
return err
|
||||
}
|
||||
os.Chown(dir, dirUID, dirGID)
|
||||
return os.Chmod(dir, 0o770|os.ModeSetgid)
|
||||
}
|
||||
n := 0
|
||||
for _, f := range files {
|
||||
if filepath.IsAbs(f.Path) || strings.HasPrefix(filepath.Clean(f.Path), "..") {
|
||||
return n, fmt.Errorf("refusing the file name %q in the backup", f.Path)
|
||||
}
|
||||
dst := filepath.Join(keysDir, f.Path)
|
||||
if err := mkdir(filepath.Dir(dst)); err != nil {
|
||||
return n, err
|
||||
}
|
||||
tmp := dst + ".restore-tmp"
|
||||
if err := os.WriteFile(tmp, f.Data, 0o600); err != nil {
|
||||
return n, err
|
||||
}
|
||||
if err := os.Chown(tmp, uid, gid); err != nil {
|
||||
os.Remove(tmp)
|
||||
return n, err
|
||||
}
|
||||
if err := os.Chmod(tmp, f.Mode); err != nil {
|
||||
os.Remove(tmp)
|
||||
return n, err
|
||||
}
|
||||
if err := os.Rename(tmp, dst); err != nil {
|
||||
os.Remove(tmp)
|
||||
return n, err
|
||||
}
|
||||
n++
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
func listBackups(dir string) error {
|
||||
files, err := backup.List(dir)
|
||||
if err != nil {
|
||||
@@ -290,6 +440,9 @@ func describe(w io.Writer, in *backup.Info, made time.Time) {
|
||||
fmt.Fprintf(w, " made\t%s (%s ago)\n", made.Local().Format("2006-01-02 15:04:05"), fmtDuration(time.Since(made)))
|
||||
}
|
||||
fmt.Fprintf(w, " contents\t%s, %s, %s (schema %d)\n", plural(in.Zones, "zone"), plural(in.Records, "record"), plural(in.Users, "user"), in.Schema)
|
||||
if in.SignedZones > 0 {
|
||||
fmt.Fprintf(w, " signed zones\t%d\n", in.SignedZones)
|
||||
}
|
||||
if in.LastChange != nil {
|
||||
fmt.Fprintf(w, " last change\t%s\n", in.LastChange.Local().Format("2006-01-02 15:04:05"))
|
||||
}
|
||||
|
||||
+86
-7
@@ -27,6 +27,7 @@ import (
|
||||
"jarl/internal/config"
|
||||
"jarl/internal/engine"
|
||||
"jarl/internal/model"
|
||||
"jarl/internal/porkbun"
|
||||
"jarl/internal/render"
|
||||
"jarl/internal/server"
|
||||
"jarl/internal/stats"
|
||||
@@ -50,7 +51,7 @@ func rootCmd() *cobra.Command {
|
||||
Short: "Jarl: manage BIND (named) through a web UI, a REST API and a CLI",
|
||||
SilenceUsage: true,
|
||||
SilenceErrors: true,
|
||||
Version: version.Version,
|
||||
Version: version.String(),
|
||||
}
|
||||
def := os.Getenv("JARL_CONFIG")
|
||||
if def == "" {
|
||||
@@ -122,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
|
||||
}
|
||||
|
||||
@@ -143,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())
|
||||
@@ -162,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)
|
||||
@@ -291,12 +296,21 @@ func serveCmd() *cobra.Command {
|
||||
// database closes, however serving ends.
|
||||
defer func() { cancel(); <-collectorDone }()
|
||||
}
|
||||
var pb *porkbun.Client
|
||||
if a.cfg.Porkbun.Enabled() {
|
||||
pb = &porkbun.Client{BaseURL: a.cfg.Porkbun.APIURL, APIKey: a.cfg.Porkbun.APIKey, Secret: a.cfg.Porkbun.SecretAPIKey}
|
||||
if fi, err := os.Stat(configPath); err == nil && fi.Mode().Perm()&0o004 != 0 {
|
||||
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)
|
||||
}
|
||||
}
|
||||
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),
|
||||
SessionIdle: time.Duration(a.cfg.Session.IdleTimeout), CookieSecure: a.cfg.Session.CookieSecure,
|
||||
Version: version.Version, Stats: collector, Socket: a.cfg.Socket,
|
||||
Version: version.String(), Stats: collector, Socket: a.cfg.Socket,
|
||||
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(),
|
||||
HTTPReq: server.HTTPReq{
|
||||
Enabled: a.cfg.ACME.HTTPReq.Enabled,
|
||||
AllowPlainHTTP: !a.cfg.ACME.HTTPReq.RequireHTTPS,
|
||||
@@ -313,7 +327,8 @@ func serveCmd() *cobra.Command {
|
||||
}
|
||||
}()
|
||||
go auditRetention(ctx, a)
|
||||
a.log.Info("jarld starting", "version", version.Version, "database", a.cfg.Database, "bind_config", a.cfg.BIND.ConfigDir)
|
||||
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")
|
||||
a.log.Info("jarld stopped")
|
||||
@@ -322,6 +337,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
|
||||
@@ -464,7 +506,7 @@ Jarl starts empty. Either import your existing BIND configuration with
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cmd.Flags().StringVarP(&username, "user", "u", "admin", "administrator username")
|
||||
cmd.Flags().StringVarP(&username, "user", "u", "admin", "the first administrator's username: best your own name, as each person should have their own account")
|
||||
cmd.Flags().BoolVar(&stdin, "password-stdin", false, "read the password from standard input")
|
||||
cmd.Flags().BoolVar(&withToken, "token", false, "also create an API token for the CLI")
|
||||
cmd.Flags().StringSliceVar(&nameservers, "nameservers", nil, "default name servers for new zones (e.g. ns1.example.com.)")
|
||||
@@ -610,6 +652,9 @@ func userCmd() *cobra.Command {
|
||||
if !u.HasPassword {
|
||||
flags = append(flags, "no password")
|
||||
}
|
||||
if u.HasTOTP() {
|
||||
flags = append(flags, "2fa")
|
||||
}
|
||||
var gs []string
|
||||
for _, g := range u.Grants {
|
||||
gs = append(gs, auth.FormatGrant(g))
|
||||
@@ -619,6 +664,40 @@ func userCmd() *cobra.Command {
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cmd.AddCommand(add, passwd, token, list)
|
||||
totpCmd := &cobra.Command{Use: "totp", Short: "Two-factor login of a user"}
|
||||
totpReset := &cobra.Command{
|
||||
Use: "reset NAME",
|
||||
Short: "Turn off a user's two-factor login (a lost phone without recovery codes)",
|
||||
Long: `Turns off a user's two-factor login and deletes their recovery codes, and
|
||||
logs them out. They log in with the password alone next time; if
|
||||
totp.require applies to them, they must then set it up again before doing
|
||||
anything else. Works with jarld stopped too.`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
a, err := openApp(true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer a.close()
|
||||
ctx := context.Background()
|
||||
u, err := a.store.GetUserByName(ctx, args[0])
|
||||
if err != nil {
|
||||
return fmt.Errorf("user %s: %w", args[0], err)
|
||||
}
|
||||
if !u.HasTOTP() && u.TOTPPending == "" {
|
||||
fmt.Printf("%s doesn't use two-factor login.\n", u.Username)
|
||||
return nil
|
||||
}
|
||||
if err := a.store.DisableTOTP(ctx, u.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
a.store.DeleteUserSessions(ctx, u.ID, "")
|
||||
a.eng.Audit(ctx, &auth.Principal{User: &model.User{Username: "local"}, Method: "local"}, "user.totp_reset", "user", u.ID, u.Username, "success", "jarld user totp reset")
|
||||
fmt.Printf("Two-factor login of %s is off; they log in with the password and can set it up again under Account.\n", u.Username)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
totpCmd.AddCommand(totpReset)
|
||||
cmd.AddCommand(add, passwd, token, list, totpCmd)
|
||||
return cmd
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
# Jarl: one database backup, run daily by jarl-backup.timer.
|
||||
# Jarl: one database backup (with the DNSSEC keys), run daily by
|
||||
# jarl-backup.timer.
|
||||
# Installed as /etc/systemd/system/jarl-backup.service (see
|
||||
# docs/operations.md#backups). What to keep, and where, is in the backup:
|
||||
# section of /etc/jarl/jarl.yaml; when it runs is in jarl-backup.timer.
|
||||
@@ -28,8 +29,12 @@ PrivateTmp=yes
|
||||
PrivateDevices=yes
|
||||
PrivateNetwork=yes
|
||||
NoNewPrivileges=yes
|
||||
CapabilityBoundingSet=
|
||||
AmbientCapabilities=
|
||||
# Read access to every file, and nothing else: named's DNSSEC private keys
|
||||
# (under /var/lib/bind/jarl/dnssec-keys) are readable only by named's user,
|
||||
# and the backup includes them. Everything outside the paths above stays
|
||||
# read-only (ProtectSystem=strict).
|
||||
CapabilityBoundingSet=CAP_DAC_READ_SEARCH
|
||||
AmbientCapabilities=CAP_DAC_READ_SEARCH
|
||||
ProtectKernelTunables=yes
|
||||
ProtectKernelModules=yes
|
||||
ProtectKernelLogs=yes
|
||||
|
||||
@@ -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
|
||||
|
||||
+209
-12
@@ -26,7 +26,27 @@ change something (anything but GET/HEAD) must send it as `X-CSRF-Token`.
|
||||
`POST /auth/logout` ends the session.
|
||||
|
||||
`GET /auth/whoami` returns the user, the token (if any), the effective
|
||||
permissions and whether the caller is an administrator.
|
||||
permissions and whether the caller is an administrator. For sessions it
|
||||
also has `totp` (the user's two-factor login: `enabled`, `required`,
|
||||
`recovery_codes_left`) and `totp_setup_required`.
|
||||
|
||||
**Two-factor login** ([permissions.md](permissions.md#two-factor-login)):
|
||||
for a user with it, `POST /auth/login` answers a correct password with
|
||||
`{"totp_required": true, "challenge": "..."}` and no cookie. Send
|
||||
`POST /auth/login/totp` with `{"challenge": "...", "code": "123456"}` (or
|
||||
a recovery code) within 5 minutes to get the session. Wrong codes answer
|
||||
401 `totp_invalid`; after 5, or 5 minutes, `login_expired` (start again).
|
||||
|
||||
Creating or rotating tokens, changing users and resetting someone's
|
||||
two-factor login need, in a session of a user with two-factor login, a
|
||||
code proved in the last 5 minutes. Otherwise they answer 403
|
||||
`totp_step_up`: send the request again with the current code in
|
||||
`X-Jarl-TOTP: 123456` (a wrong one gives 403 `totp_invalid`). Tokens and
|
||||
the local socket never need one.
|
||||
|
||||
When `totp.require` applies to a user who hasn't set it up, a session can
|
||||
only call `/auth/whoami`, `/auth/logout`, `/me/password` and `/me/totp...`;
|
||||
everything else answers 403 `totp_setup_required`.
|
||||
|
||||
**The local socket**: on jarld's unix socket (`/run/jarl/jarl.sock`),
|
||||
requests from root or jarld's own user need no `Authorization` header and
|
||||
@@ -90,7 +110,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:
|
||||
|
||||
@@ -139,11 +160,19 @@ 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) |
|
||||
| GET | `/zones/{zone}/delegation` | checks the parent zone's delegation of a primary zone (below); `?resolver=IP` |
|
||||
| GET | `/zones/{zone}/dnssec` | the zone's DNSSEC keys, DS record and phase ([DNSSEC](#dnssec)) |
|
||||
| POST | `/zones/{zone}/dnssec/check-ds` | asks the parent for the zone's DS records and tells named; `?resolver=IP` |
|
||||
| POST | `/zones/{zone}/dnssec/wipe` | `{"force": false, "dry_run": false}`: signing off at once, keys deleted (administrators) |
|
||||
| GET | `/zones/{zone}/registrar` | name servers, glue and DS records at the registrar ([Registrar](#registrar-porkbun)) |
|
||||
| POST | `/zones/{zone}/registrar/glue` | create or update glue records |
|
||||
| PUT | `/zones/{zone}/registrar/ns` | set the domain's name servers |
|
||||
| POST | `/zones/{zone}/registrar/ds` | publish the zone's DS record |
|
||||
| DELETE | `/zones/{zone}/registrar/ds/{keytag}` | remove a DS record |
|
||||
| POST | `/zones/{zone}/import` | `{"text": "<zone file>", "mode": "merge"\|"replace", "use_soa": false, "dry_run": false}` |
|
||||
|
||||
ZoneInput:
|
||||
@@ -159,6 +188,7 @@ ZoneInput:
|
||||
"refresh": 3600, "retry": 900, "expire": 1209600, "minimum": 300},
|
||||
"default_ttl": 3600,
|
||||
"serial_policy": "date", // date, increment, unixtime
|
||||
"dnssec_policy": "default", // primary zones: "" (off), "default", a custom policy, "insecure" (turning off)
|
||||
"comment": "",
|
||||
// create only:
|
||||
"nameservers": ["ns1.adbraun.lol."], // default: settings
|
||||
@@ -191,25 +221,131 @@ when called ([operations.md](operations.md#checking-name-servers-and-the-delegat
|
||||
`error` (with `error`). `source` is `NS record`, `also-notify` or
|
||||
`primaries`; `port` is set for a non-default port.
|
||||
|
||||
`/serials?stream=1` and `/delegation?stream=1` answer with
|
||||
newline-delimited JSON (`application/x-ndjson`) while they work, which is
|
||||
how the web UI shows a check's progress. Each DNS query is an event when it
|
||||
is sent and another when it is answered: `{"kind": "start", "id": 2,
|
||||
"query": "dig @192.0.2.1 example.org. SOA +norec"}`, then `{"kind": "end",
|
||||
"id": 2, "answer": "serial 2026100900", "rtt_ms": 12}`. `answer` sums the
|
||||
answer up (addresses, `referral to …`, `NXDOMAIN`, `no such record`);
|
||||
`error` replaces it when there was none. The serials check's SOA queries
|
||||
end with `result`, the `servers` entry, instead. ID 0 is `rndc zonestatus`,
|
||||
which asks this server's named. The last line is `{"kind": "done",
|
||||
"response": {...}}`, the endpoint's usual response.
|
||||
|
||||
`/delegation` returns `parent`, `resolver`, `delegated`, `parent_ns`,
|
||||
`jarl_ns`, `parent_servers` (each parent server's `status`: `delegated`,
|
||||
`not_delegated`, `unreachable`, `error`, with `ns` and `glue`), `glue`
|
||||
(`name`, `parent`, `jarl`, `match`), `servers` (as above, for the delegated
|
||||
name servers) and `findings`: `{"level": "ok"|"info"|"warning"|"error",
|
||||
"message": "..."}`. A zone that isn't delegated gets one `info` finding.
|
||||
`parent_ds` lists the zone's DS records at the parent, and the findings
|
||||
compare them with the zone's keys (a DS that matches no key, or a DS for a
|
||||
zone that isn't signed, is an `error`).
|
||||
|
||||
Zones in responses also carry `registrar` (`"porkbun"`) when Jarl can
|
||||
manage the domain at a registrar.
|
||||
|
||||
#### DNSSEC
|
||||
|
||||
Signing is the zone's `dnssec_policy` field, changed with `PATCH
|
||||
/zones/{zone}` like any other field, so it goes through validation,
|
||||
changesets and history. Jarl refuses unsafe steps there: `""` straight from
|
||||
a signed policy (use `"insecure"` first), `""` before named has finished
|
||||
removing the signatures, deleting a signed zone, or moving it to another
|
||||
view ([dnssec.md](dnssec.md#what-jarl-refuses-and-why)).
|
||||
|
||||
`GET /zones/{zone}/dnssec`:
|
||||
|
||||
```json
|
||||
{
|
||||
"zone": "adbraun.lol.", "view": "external", "type": "primary",
|
||||
"policy": "default",
|
||||
"key_dir": "/var/lib/bind/jarl/dnssec-keys/view-3",
|
||||
"keys": [{
|
||||
"tag": 51126, "algorithm": 13, "algorithm_name": "ECDSAP256SHA256", "role": "CSK",
|
||||
"dnskey": "adbraun.lol.\t3600\tIN\tDNSKEY\t257 3 13 ...",
|
||||
"ds": {"key_tag": 51126, "algorithm": 13, "digest_type": 2, "digest": "FFBE...", "record": "adbraun.lol.\t3600\tIN\tDS\t51126 13 2 FFBE..."},
|
||||
"goal": "omnipresent", "dnskey_state": "rumoured", "ds_state": "hidden",
|
||||
"zone_rrsig_state": "rumoured", "key_rrsig_state": "rumoured",
|
||||
"created": "...", "published": "...", "active": "...", "publish_cds": "...",
|
||||
"files": ["Kadbraun.lol.+013+51126.key", "Kadbraun.lol.+013+51126.private", "Kadbraun.lol.+013+51126.state"]
|
||||
}],
|
||||
"phase": {
|
||||
"code": "signing", "summary": "named is signing the zone. ...", "next": "Wait until ...",
|
||||
"ds_ready_at": "...", "can_publish_ds": false, "can_finish": false
|
||||
},
|
||||
"named": {"secure": true, "inline_signing": true, "serial": 2026100802, "signed_serial": 2026100804, "next_key_event": "..."},
|
||||
"policies": ["default"],
|
||||
"parent_zone": "", // set when the parent zone is a primary zone in Jarl
|
||||
"registrar": "porkbun" // when the DS can be published through Jarl
|
||||
}
|
||||
```
|
||||
|
||||
`phase.code` is one of `off`, `leftover_keys`, `starting`, `signing`,
|
||||
`ready`, `ds_pending`, `secure`, `removing_ds`, `ds_expiring`,
|
||||
`removing_keys`, `unsigned` ([dnssec.md](dnssec.md#phases)).
|
||||
|
||||
`POST /dnssec/check-ds` (zones write) returns `parent` (the parent's
|
||||
servers with their `ds`, the union `ds`, `delegated`, `reached`,
|
||||
`consistent`, or `error`), `marked` (what named was told, `rndc dnssec
|
||||
-checkds`), `notes` and the updated `status`.
|
||||
|
||||
`POST /dnssec/wipe` (administrators) asks the parent first and answers
|
||||
`409` while it has a DS record for the zone, or while named still counts a
|
||||
removed one as cached; when the parent can't be asked, it needs `"force":
|
||||
true`. It returns `removed` (the files deleted) and `changeset_id`.
|
||||
|
||||
#### Registrar (Porkbun)
|
||||
|
||||
Administrators only, and only with a Porkbun key in jarl.yaml
|
||||
([dnssec.md](dnssec.md#porkbun)). `GET /zones/{zone}/registrar`:
|
||||
|
||||
```json
|
||||
{
|
||||
"registrar": "porkbun", "domain": "adbraun.lol",
|
||||
"ns": ["curitiba.ns.porkbun.com", "fortaleza.ns.porkbun.com"], "jarl_ns": ["ns1.adbraun.lol", "ns2.adbraun.lol"],
|
||||
"ns_match": false,
|
||||
"glue": [{"host": "ns1.adbraun.lol", "registry": [], "jarl": ["203.0.113.1", "2001:db8::1"],
|
||||
"needed": true, "in_use": false, "action": "create"}],
|
||||
"ds": [{"key_tag": 51126, "algorithm": 13, "digest_type": 2, "digest": "FFBE...", "matches": true}],
|
||||
"findings": [{"level": "warning", "message": "..."}],
|
||||
"error": "" // e.g. "Porkbun: ... (API_ACCESS_DISABLED)"; the rest is then empty
|
||||
}
|
||||
```
|
||||
|
||||
The changes take `{"force": false, "dry_run": false}` (`PUT /ns` also
|
||||
`"ns": [...]`, default the zone's NS records; `POST /ds` also `"key_tag"`)
|
||||
and answer:
|
||||
|
||||
```json
|
||||
{"done": ["create glue for ns1.adbraun.lol: 203.0.113.1, 2001:db8::1"], "dry_run": false,
|
||||
"checks": [{"level": "ok"|"info"|"warning"|"error", "message": "..."}], "warnings": []}
|
||||
```
|
||||
|
||||
Each change runs its checks first: the name servers answer for the zone,
|
||||
glue matches, the DS points at a key the zone has, and the domain is
|
||||
delegated to servers that serve the signed zone before a DS is published. An
|
||||
`error` check refuses the change with `409` unless `force` is set; a dry run
|
||||
returns the checks without changing anything. Errors from Porkbun are `502
|
||||
registrar_error`.
|
||||
|
||||
### Records
|
||||
|
||||
| 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:
|
||||
|
||||
@@ -220,12 +356,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
|
||||
|
||||
@@ -235,10 +385,21 @@ Responses add `zone`, `view`, `relative_name`, `effective_ttl`, and `text`
|
||||
| POST | `/views` | `{"name": "internal", "match_clients": ["homelab", "localhost"], "match_destinations": [], "match_recursive_only": null, "comment": "", "adopt_zones": true}` |
|
||||
| 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.
|
||||
|
||||
`color` is how the web UI shows the view: one of `green`, `teal`,
|
||||
`purple`, `pink`, `orange`, `yellow`, `red` and `grey`. When it's left out,
|
||||
the view uses the link colour. It isn't part of the BIND configuration, so
|
||||
`PUT /views/{id}/color` applies at once and creates no changeset. It needs
|
||||
write access to the view, and it is logged in the audit log as
|
||||
`view.color`. To colour a view that so far exists only in a changeset,
|
||||
send the request with that changeset (`?changeset=` or
|
||||
`X-Jarl-Changeset`).
|
||||
|
||||
### ACLs and TSIG keys
|
||||
|
||||
| Method | Path | Body |
|
||||
@@ -251,6 +412,29 @@ requires every zone to be in a view as soon as one view exists.
|
||||
| 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": ""}` (`views` empty or absent: automatic); 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}` (what applies when blocking isn't paused) |
|
||||
| GET | `/blocking/status` | `{paused_until, paused_indefinitely}` (null and false when not paused) |
|
||||
| POST | `/blocking/pause` | `{"minutes": 15}` (1 to 1440): let every name through until then; jarld resumes by itself. `{"indefinitely": true}`: until resumed |
|
||||
| POST | `/blocking/resume` | end a pause now |
|
||||
|
||||
Lists and rules are configuration like the rest: they can be staged, and
|
||||
listing with `?changeset=` marks them `pending`.
|
||||
|
||||
### Options and settings
|
||||
|
||||
| Method | Path | Body / query |
|
||||
@@ -261,7 +445,9 @@ requires every zone to be in a view as soon as one view exists.
|
||||
| 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
|
||||
|
||||
@@ -295,12 +481,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
|
||||
@@ -310,7 +500,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` |
|
||||
@@ -323,11 +513,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)"'
|
||||
@@ -354,13 +545,19 @@ Success is `200 {"ok": true, "fqdn": ..., "changeset_id": ..., "zones": [...]}`.
|
||||
|--------|------|------|
|
||||
| GET | `/users` | admin |
|
||||
| POST | `/users` | `{"username": "alice", "password": "...", "admin": false, "role": "dns-editor", "grant_strings": ["records:write zones=lab.adbraun.lol"], "comment": ""}` (`password: ""` = service account) |
|
||||
| GET / PATCH / DELETE | `/users/{id}` | PATCH also takes `disabled` |
|
||||
| GET / PATCH / DELETE | `/users/{id}` | PATCH also takes `disabled`, and `username` to rename. Users have `totp_enabled_at` (two-factor login on since) and `totp_required` |
|
||||
| GET / POST | `/users/{id}/tokens` | admin (or yourself) |
|
||||
| GET / POST | `/me/tokens` | `{"name": "laptop", "role": "", "grant_strings": [...], "expires_in": "90d"}`. The response's `token` field is the secret, shown once |
|
||||
| GET | `/tokens` | every token (admin); `?all=1` includes revoked tokens |
|
||||
| DELETE | `/tokens/{id}` | revoke |
|
||||
| POST | `/tokens/{id}/rotate` | New secret, same token. Optional body `{"grace": "1h", "expires_in": "90d", "expires_at": "...", "no_expiry": false}`. The response's `token` field is the new secret, shown once; `previous_secret_valid_until` says when the old one stops. A restricted token may rotate itself and tokens no wider than itself |
|
||||
| POST | `/me/password` | `{"current_password": "...", "password": "..."}` (sessions only) |
|
||||
| GET | `/me/totp` | Your two-factor login: `enabled`, `enabled_at`, `required`, `recovery_codes_left`, `policy` |
|
||||
| POST | `/me/totp/setup` | `{"password": "..."}` (sessions only). A new secret: `secret`, `uri` (`otpauth://`), `qr` (an SVG `data:` URI), `issuer`, `account`. Not active until confirmed. With two-factor login already on (a new phone), it needs a recent code |
|
||||
| POST | `/me/totp/enable` | `{"code": "123456"}`: confirms the secret from setup. Returns `{"recovery_codes": [...]}`, shown once, and logs out your other sessions |
|
||||
| POST | `/me/totp/disable` | `{"password": "...", "code": "..."}` (a code or a recovery code). Refused (409) while `totp.require` applies to you |
|
||||
| POST | `/me/totp/recovery-codes` | New recovery codes, replacing the old ones (needs a recent code) |
|
||||
| DELETE | `/users/{id}/totp` | admin: turn off someone's two-factor login, delete their recovery codes and end their sessions |
|
||||
|
||||
User and token changes take effect immediately. They are not changesets,
|
||||
because they don't touch named, but they are audited.
|
||||
|
||||
@@ -0,0 +1,267 @@
|
||||
# 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).
|
||||
|
||||
## 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.
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
+147
-12
@@ -44,8 +44,19 @@ jarl whoami
|
||||
| Changeset to stage into | `-C/--changeset` | `JARL_CHANGESET` | |
|
||||
|
||||
Flags win over the environment, and the environment wins over the file. The
|
||||
file is `~/.config/jarl/config.yaml` (mode 0600), or `--config` /
|
||||
`JARL_CLI_CONFIG`. `jarl config show` prints it with the token masked.
|
||||
file is `jarl/config.yaml` in the user's configuration directory, or
|
||||
`--config` / `JARL_CLI_CONFIG`:
|
||||
|
||||
| System | File |
|
||||
|--------|------|
|
||||
| Linux and other Unix | `~/.config/jarl/config.yaml` (`$XDG_CONFIG_HOME/jarl/config.yaml` if set) |
|
||||
| macOS | `~/Library/Application Support/jarl/config.yaml` |
|
||||
| Windows | `%AppData%\jarl\config.yaml` (`C:\Users\<you>\AppData\Roaming\jarl\config.yaml`) |
|
||||
|
||||
It is written with mode 0600 (on Windows, the AppData folder is already
|
||||
private to the user). `jarl config show` prints its path and the settings,
|
||||
with the token masked. On Windows and macOS there is no local jarld, so
|
||||
always set a server.
|
||||
|
||||
Get tokens from the web UI (Account → API tokens), from
|
||||
`jarl token create`, or from `jarld init --token` on the server.
|
||||
@@ -128,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
|
||||
|
||||
@@ -139,7 +151,7 @@ to stderr with `-o json` or `-o yaml`, so stdout holds just the documents.
|
||||
|------|---------|
|
||||
| 0 | Success |
|
||||
| 1 | Other error (bad arguments, connection failure, ...) |
|
||||
| 2 | A check found problems (`zone serials`, `zone delegation`); its report was printed |
|
||||
| 2 | A check found problems (`zone serials`, `zone delegation`, `zone dnssec check-ds`, `zone registrar status`), or a step can't be taken yet (`zone dnssec disable` before turning off has finished); the report was printed |
|
||||
| 3 | Not authenticated, or not allowed (token lacks the permission) |
|
||||
| 4 | Not found |
|
||||
| 5 | Conflict: concurrent change, object in use, drift |
|
||||
@@ -170,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
|
||||
@@ -200,10 +221,41 @@ zone's delegation and glue against the zone, and calls a zone that isn't
|
||||
delegated (an internal-only zone) a note, not a problem. Both only run when
|
||||
you run them; see [operations.md](operations.md#checking-name-servers-and-the-delegation).
|
||||
|
||||
### DNSSEC and the registrar
|
||||
|
||||
```sh
|
||||
jarl zone dnssec status adbraun.lol --view external # keys, DS record, what's next
|
||||
jarl zone dnssec enable adbraun.lol --view external [--policy NAME]
|
||||
jarl zone dnssec check-ds adbraun.lol --view external # has the parent got the DS? tells named
|
||||
jarl zone dnssec disable adbraun.lol --view external # step 1: insecure; run again to finish
|
||||
jarl zone dnssec wipe adbraun.lol --view external [--force] # off at once, keys deleted
|
||||
|
||||
jarl zone registrar status adbraun.lol --view external # Porkbun: NS, glue, DS vs the zone
|
||||
jarl zone registrar sync-glue adbraun.lol --view external [--dry-run]
|
||||
jarl zone registrar set-ns adbraun.lol --view external [NS...] [--dry-run]
|
||||
jarl zone registrar publish-ds adbraun.lol --view external [--key TAG]
|
||||
jarl zone registrar remove-ds adbraun.lol 51126 --view external
|
||||
```
|
||||
|
||||
[dnssec.md](dnssec.md) explains the steps. `enable` is safe at any time:
|
||||
validation only starts once the zone's DS record is at the parent, and
|
||||
`status` says when to publish it. `disable` never makes a zone unsigned
|
||||
while the parent may still have its DS: its first run switches to BIND's
|
||||
insecure policy, and it finishes (exit 0) only when named is done (exit 2
|
||||
before). `wipe` switches off at once and deletes the keys, and refuses
|
||||
while the parent zone has a DS record for the zone; `--force` wipes when the
|
||||
parent can't be checked.
|
||||
|
||||
The `registrar` commands need a Porkbun API key in jarl.yaml and an
|
||||
administrator. Each change checks first that it won't break the domain and
|
||||
is refused otherwise: `--dry-run` shows the checks, `--force` skips them.
|
||||
(Here `--force` means that; elsewhere it overwrites files changed outside
|
||||
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
|
||||
@@ -215,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
|
||||
@@ -231,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:
|
||||
@@ -245,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]}
|
||||
@@ -289,6 +367,8 @@ jarl view create external --match-clients any
|
||||
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 update homelab --add 10.20.0.0/16 --remove 10.10.0.0/16
|
||||
@@ -304,12 +384,21 @@ 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 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
|
||||
@@ -323,7 +412,9 @@ jarl user create alice --role dns-editor # asks for a password
|
||||
jarl user create ci --no-password --grant "records:write zones=lab.adbraun.lol"
|
||||
jarl user update alice --admin
|
||||
jarl user update alice --password # set a new password
|
||||
jarl user update bob --disabled # logs bob out, blocks his tokens
|
||||
jarl user update bob --disabled # logs bob out, blocks bob's tokens
|
||||
jarl user update admin --rename adam # rename (e.g. the first account, to your own name)
|
||||
jarl user totp reset alice # turn off alice's two-factor login (lost phone)
|
||||
jarl user delete bob -y
|
||||
|
||||
jarl token list [--user alice | --all] [--revoked]
|
||||
@@ -335,6 +426,11 @@ jarl token rotate laptop --save # new secret, same token
|
||||
jarl token rotate --current --save --grace 10m # the token this CLI uses
|
||||
jarl token revoke 7
|
||||
|
||||
# 'jarl user list' and 'jarl user show' include two-factor login (2FA: on,
|
||||
# off, or MISSING when totp.require applies and it isn't set up). Users set
|
||||
# it up themselves in the web UI (Account); the CLI's tokens never need a
|
||||
# code (permissions.md#two-factor-login).
|
||||
|
||||
jarl audit [--user alice] [--action auth.login_failed] [--outcome failure] [--since 2026-10-01] [--limit 100]
|
||||
jarl search 192.168.1.20
|
||||
jarl status # named, adoption, drift, open changesets, backups, expiring tokens, problems
|
||||
@@ -354,15 +450,37 @@ 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 resume
|
||||
```
|
||||
|
||||
See [blocking.md](blocking.md).
|
||||
|
||||
## ACME DNS-01
|
||||
|
||||
```sh
|
||||
@@ -376,8 +494,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.
|
||||
+11
-5
@@ -40,8 +40,8 @@ renders the complete configuration from the database:
|
||||
`jarld adopt` turns `/etc/bind/named.conf` into a single
|
||||
`include "/etc/bind/jarl/named.conf";`, so named loads only what Jarl
|
||||
generates. Zone files go under `/var/lib/bind` because Debian's AppArmor
|
||||
profile lets named write there. That keeps journals and future DNSSEC
|
||||
signing working.
|
||||
profile lets named write there: its journals, the signed copies of zones
|
||||
it signs, and their keys (under `dnssec-keys/`, see [dnssec.md](dnssec.md)).
|
||||
|
||||
Because files are always regenerated, anything you edit by hand is
|
||||
overwritten. Jarl records a checksum of every file it writes and reports
|
||||
@@ -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,12 @@ 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.
|
||||
* **Settings** are Jarl's defaults for new zones: name servers, SOA contact
|
||||
and timers, default TTL, serial policy.
|
||||
|
||||
@@ -290,8 +298,6 @@ journal through jarld's log.
|
||||
* **Dynamic updates** (`allow-update`, `update-policy`): named would write
|
||||
to zone files that Jarl regenerates, so the validator rejects them on Jarl
|
||||
zones. Use the API (for example `jarl acme`) instead of nsupdate.
|
||||
* **DNSSEC signing**: not managed yet. The zone directory is
|
||||
named-writable, so `dnssec-policy` can be added later.
|
||||
* **Several servers from one jarld**: each jarld manages the named on its
|
||||
own host. A second server runs its own jarld with secondary zones, which
|
||||
copy the primary's zones by normal AXFR/IXFR (see "A secondary server" in
|
||||
|
||||
+64
-4
@@ -18,13 +18,15 @@ 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. |
|
||||
| `session.lifetime` | `12h` | A web session ends this long after login... |
|
||||
| `session.idle_timeout` | `2h` | ...or after this long without requests. |
|
||||
| `session.cookie_secure` | `auto` | `auto` sets the cookie's Secure flag when the request came over HTTPS (directly or via a trusted proxy); `always` / `never` force it. |
|
||||
| `totp.require` | `none` | Who must use [two-factor login](permissions.md#two-factor-login): `none` (each user decides), `admins`, or `all` (every user with a password). Someone who must and hasn't set it up can only set it up after logging in. API tokens and the local socket are not affected. |
|
||||
| `totp.issuer` | `""` | The name authenticator apps show for the account. Empty means `Jarl <short hostname>`, so several servers are told apart. At most 64 characters, no `:`. Changing it doesn't affect apps already set up. |
|
||||
|
||||
Durations accept Go syntax (`90s`, `15m`, `12h`) and days (`30d`).
|
||||
|
||||
@@ -39,8 +41,8 @@ Durations accept Go syntax (`90s`, `15m`, `12h`) and days (`30d`).
|
||||
### Backups
|
||||
|
||||
`jarld backup` without a file name, which the `jarl-backup.timer` systemd
|
||||
timer runs daily, writes `jarl-<UTC time>.db` into `backup.dir` and then
|
||||
deletes old backups there. It reads these settings from this file each
|
||||
timer runs daily, writes `jarl-<UTC time>.db` into `backup.dir` (with the
|
||||
DNSSEC keys of signed zones inside it) and then deletes old backups there. It reads these settings from this file each
|
||||
time it runs; when it runs is the timer's (`systemctl edit
|
||||
jarl-backup.timer`). See [operations.md](operations.md#backups), also for
|
||||
restoring.
|
||||
@@ -91,9 +93,10 @@ jarld restarts.
|
||||
|-----|---------|---------|
|
||||
| `bind.main_config` | `/etc/bind/named.conf` | The file named loads. `jarld import` reads it, and `jarld adopt` replaces it with an include of Jarl's configuration. |
|
||||
| `bind.config_dir` | `/etc/bind/jarl` | Where Jarl writes `named.conf` and `keys.conf`. Must be readable by named. |
|
||||
| `bind.zone_dir` | `/var/lib/bind/jarl` | Primary zone files (`<view>/<zone>.zone`). named may write journals here. |
|
||||
| `bind.zone_dir` | `/var/lib/bind/jarl` | Primary zone files (`<view>/<zone>.zone`). named may write journals, signed copies of zones and, under `dnssec-keys/`, DNSSEC keys here. |
|
||||
| `bind.cache_dir` | `/var/cache/bind/jarl` | Where named stores secondary, stub and mirror zones. |
|
||||
| `bind.file_group` | `bind` | Group given to generated files and directories so named can read them. Empty leaves the group alone. |
|
||||
| `bind.named_user` | `bind` | The user named runs as. `jarld restore` gives it the DNSSEC keys it puts back. |
|
||||
| `bind.named_checkconf` | `/usr/bin/named-checkconf` | Used to check every candidate configuration (`-z` also loads the zones). |
|
||||
| `bind.named_checkzone` | `/usr/bin/named-checkzone` | Used to check zone files. |
|
||||
| `bind.rndc` | `/usr/sbin/rndc` | Used to reconfigure and reload named and to read zone serials. |
|
||||
@@ -111,6 +114,37 @@ there (`ReadWritePaths`). If you change them, update both
|
||||
ReadWritePaths=/srv/bind/jarl /srv/bind/zones
|
||||
```
|
||||
|
||||
### Porkbun (registrar)
|
||||
|
||||
With an API key, Jarl can show and set a domain's name servers, glue
|
||||
records and DNSSEC DS records at Porkbun, after checking that the change
|
||||
won't break the domain ([dnssec.md](dnssec.md#porkbun)). Without a key
|
||||
(the default) nothing about Porkbun shows up.
|
||||
|
||||
| Key | Default | Meaning |
|
||||
|-----|---------|---------|
|
||||
| `porkbun.api_key` | `""` | The API key (`pk1_...`). Set both or neither. |
|
||||
| `porkbun.secret_api_key` | `""` | Its secret (`sk1_...`). |
|
||||
| `porkbun.domains` | `[]` | Only these domains (default: every primary zone the key can reach). |
|
||||
|
||||
The key can move your domain anywhere, so it is only ever read from this
|
||||
file: it isn't stored in the database or backups, and there is no API to
|
||||
set it. Keep the file readable only by root and jarld (`chown root:jarl
|
||||
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
|
||||
@@ -126,6 +160,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
|
||||
|
||||
+98
-7
@@ -24,8 +24,12 @@ internal/
|
||||
web/static/ the web UI: plain ES modules, no build step, embedded with go:embed
|
||||
api/, client/ JSON types and the Go client shared by server and CLI
|
||||
acme/ ACME DNS-01 challenge records, shared by jarl acme and the httpreq endpoint
|
||||
backup/ database backups: write (VACUUM INTO) and check, list, prune by the time in the name
|
||||
nscheck/ DNS queries to other name servers: zone serials and the parent's delegation
|
||||
backup/ database backups: write (VACUUM INTO, plus the DNSSEC key files) and check, list, prune by the time in the name
|
||||
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
|
||||
@@ -110,10 +114,43 @@ checker tests work with the installed `bind9-utils` too.
|
||||
`scripts/e2e.sh` builds a legacy BIND setup, starts named, runs
|
||||
`jarld init`, `import`, `adopt` and `serve`, then drives every CLI command
|
||||
(records, batches, changesets, views, ACLs, TSIG, options, users, tokens,
|
||||
ACME hooks, audit) and checks the answers with `dig`. `KEEP=1` leaves named
|
||||
ACME hooks, DNSSEC signing, backup and restore with keys, audit) and checks
|
||||
the answers with `dig`. The Porkbun code is tested against
|
||||
`porkbun.Fake`, an in-memory stand-in for its API; nothing calls the real
|
||||
one. `KEEP=1` leaves named
|
||||
and jarld running afterwards. The output says where; set `JARL_CLI_CONFIG`
|
||||
to that directory's `cli.yaml` to keep using the CLI against it.
|
||||
|
||||
### A test machine
|
||||
|
||||
`scripts/testenv.sh` turns a fresh Debian/Ubuntu VM or container into a
|
||||
server to try Jarl on, the way a user would. Run it as root from the
|
||||
repository:
|
||||
|
||||
```sh
|
||||
sudo scripts/testenv.sh # zone example.com
|
||||
sudo JARL_TEST_ZONE=lab.test scripts/testenv.sh # another zone
|
||||
```
|
||||
|
||||
It installs BIND 9, make, git, curl and Go (from go.dev, at the version in
|
||||
`go.mod`, unless a new enough one is installed). It then writes a typical
|
||||
hand-made configuration for Jarl to import: an ACL, options with logging,
|
||||
an `internal` view (this machine's networks and the private ranges, with
|
||||
recursion) and an `external` view (everyone else, authoritative only).
|
||||
The zone is in both views with different answers. There is also a reverse
|
||||
zone for the machine's /24. The zone files in `/etc/bind/zones` use
|
||||
comments, `$TTL`, `$ORIGIN`, `$GENERATE`, legacy `type master` and most
|
||||
record types. The script checks the result with `named-checkconf -z`,
|
||||
(re)starts named (in the background where there is no systemd), and asks
|
||||
named a few questions with `dig`. At the end it prints the steps to build
|
||||
and install the packages, then run `init`, `import` and `adopt`.
|
||||
|
||||
`JARL_TEST_VIEWS=no` leaves out the views, and `JARL_TEST_IP` sets the
|
||||
machine's address (default: the address of the default route). The old
|
||||
`/etc/bind` is saved in `/root/bind-before-testenv-<time>.tgz`. Once Jarl
|
||||
has adopted the configuration, running the script again needs
|
||||
`JARL_TEST_FORCE=1`. Debian 13 has BIND 9.20; Ubuntu has it from 25.04.
|
||||
|
||||
### The web UI
|
||||
|
||||
The UI is plain JavaScript modules under `internal/web/static/`, with no
|
||||
@@ -155,7 +192,8 @@ files are embedded at build time, so rebuild jarld after changes.
|
||||
## Releasing
|
||||
|
||||
1. Move the "Unreleased" section of `CHANGELOG.md` under
|
||||
`## [<version>] - <date>`.
|
||||
`## [<version>] - <date>`. (A release candidate may skip this; see
|
||||
below.)
|
||||
2. Put the version in `VERSION`.
|
||||
3. `make test && JARL_BIND_ROOT=... make e2e` (CI runs only `make test`;
|
||||
it has no BIND).
|
||||
@@ -168,9 +206,62 @@ amd64 and arm64) and runs `scripts/gitea-release.sh`. That creates the
|
||||
Gitea release (the CHANGELOG section as notes; the tarballs, the CLI for
|
||||
Linux, macOS and Windows, the `.deb`s and `SHA256SUMS` attached) and
|
||||
uploads the `.deb`s to the owner's Debian registry, distribution `stable`,
|
||||
component `main` ([install.md](install.md#from-the-package-registry)). A
|
||||
version with a hyphen (`1.0.0-rc.1`) becomes a pre-release; its packages
|
||||
get version `1.0.0~rc.1`, which apt sorts before `1.0.0`.
|
||||
component `main` ([install.md](install.md#from-the-package-registry)).
|
||||
|
||||
### Release candidates
|
||||
|
||||
A version with a hyphen (`0.2.0-rc.1`, tag `v0.2.0-rc.1`) is a
|
||||
pre-release:
|
||||
|
||||
* **On Gitea** it is marked "Pre-Release". Gitea doesn't count it as the
|
||||
latest release: the repository page, `/releases/latest` and the API's
|
||||
latest release keep pointing at the last stable one.
|
||||
* **The `.deb`s go to the `testing` distribution**, not `stable`. Their
|
||||
version is `0.2.0~rc.1`, which apt sorts after `0.1.0` but before
|
||||
`0.2.0`. So in `stable` an RC would reach every server on its next
|
||||
`apt upgrade`. Servers that follow only `stable` never see RCs. A test
|
||||
machine that also lists `testing` gets each RC, and then the final
|
||||
`0.2.0` from `stable`, which supersedes it.
|
||||
* **Release notes**: its own `## [0.2.0-rc.1]` section if there is one;
|
||||
otherwise the section of the version it leads up to (`## [0.2.0]`), or
|
||||
else `## [Unreleased]`. So you can tag RCs while the changes are still
|
||||
under Unreleased, and move them under `## [0.2.0]` for the real release.
|
||||
A stable release still needs its own section.
|
||||
|
||||
`scripts/gitea-release.sh notes` prints the notes a release would get
|
||||
(`VERSION=0.2.0-rc.1 scripts/gitea-release.sh notes`), and the workflow
|
||||
runs it as a check before building.
|
||||
|
||||
### Which version a build reports
|
||||
|
||||
The footer of the web UI, `jarld version`, `jarl --version`, `jarl status`
|
||||
and `jarl whoami` say what a binary was built from (the footer links to
|
||||
it on Gitea). Only a release build reports a version number:
|
||||
|
||||
* **A release**: `make` run on the commit tagged `v<VERSION>` with no
|
||||
tracked file changed. The Makefile then sets `RELEASE_TAG`, and the
|
||||
footer reads "Powered by Jarl v0.2.0", linking to the release. The
|
||||
release workflow sets `RELEASE_TAG` itself.
|
||||
* **Any other build** is a development build, named by its commit (which
|
||||
Go records from git) rather than `VERSION`: "Powered by Jarl
|
||||
1a2b3c4d5e6f", linking to the commit. With uncommitted changes it is
|
||||
marked `-modified`. The other places add where it stands relative to
|
||||
the last tag, from `git describe` (the Makefile's `DESCRIBE`):
|
||||
`dev 1a2b3c4d5e6f (3 commits after v0.2.0-rc.1)`, or
|
||||
`dev 1a2b3c4d5e6f-modified (v0.2.0-rc.1 with uncommitted changes)`.
|
||||
* **No git** (a build from a source archive): `VERSION`, marked as such:
|
||||
`0.2.0 (source build)`.
|
||||
|
||||
`RELEASE_TAG=v0.2.0 make` forces the first case.
|
||||
|
||||
`make deb` versions the packages the same way. A release gets `VERSION`
|
||||
with `~` for `-` (`0.2.0~rc.1`). A development build gets a version after
|
||||
the last tag: 3 commits past `v0.2.0-rc.1` is `0.2.0~rc.1+git3.gca0f148`,
|
||||
and uncommitted changes add the build time (`.m20261009153000`). apt sorts
|
||||
these after that RC and before the next one or `0.2.0`. So installing a
|
||||
newer build over an older one, or over the RC, is an upgrade: plain
|
||||
`apt install ./dist/…` works, without `--reinstall`. `make deb` removes
|
||||
older packages of the same architecture from `dist/` first.
|
||||
|
||||
Pushing the same tag again (`git tag -f`, `git push -f`) re-runs it, and
|
||||
the script replaces the attachments and packages, so a failed release can
|
||||
|
||||
+344
@@ -0,0 +1,344 @@
|
||||
# DNSSEC signing
|
||||
|
||||
DNSSEC lets resolvers that validate (Cloudflare 1.1.1.1, Google 8.8.8.8,
|
||||
Quad9, many ISPs) detect forged answers for your domain. Jarl signs zones
|
||||
with BIND's own key and signing policy (`dnssec-policy`), and guides you
|
||||
through the steps that involve the parent zone, where getting the order
|
||||
wrong takes a domain offline.
|
||||
|
||||
- [Should you sign?](#should-you-sign)
|
||||
- [How it works](#how-it-works)
|
||||
- [Turning signing on](#turning-signing-on)
|
||||
- [Putting your own name servers on the internet](#putting-your-own-name-servers-on-the-internet)
|
||||
- [Porkbun](#porkbun)
|
||||
- [Turning signing off](#turning-signing-off)
|
||||
- [Wipe: a clean slate](#wipe-a-clean-slate)
|
||||
- [Keys, backups and moving servers](#keys-backups-and-moving-servers)
|
||||
- [What Jarl refuses, and why](#what-jarl-refuses-and-why)
|
||||
- [Phases](#phases)
|
||||
- [Custom policies](#custom-policies)
|
||||
- [Adopting a signed zone](#adopting-a-signed-zone)
|
||||
|
||||
## Should you sign?
|
||||
|
||||
Sign a zone that the internet sees through a delegation from its parent:
|
||||
your registered domain (`adbraun.lol`), as served to the outside world.
|
||||
Validation only happens once the parent zone (`lol`) publishes a DS
|
||||
record for the zone's key, which you add at your registrar.
|
||||
|
||||
Signing an internal-only zone (one no registrar delegates, or a split-DNS
|
||||
view only your own network reaches) adds nothing. There is no parent to
|
||||
publish a DS, and BIND doesn't validate the zones it serves itself.
|
||||
|
||||
**Split DNS.** In Jarl a zone belongs to a view, so you sign the external
|
||||
view's `adbraun.lol` and leave the internal view's copy unsigned. That is
|
||||
the usual setup, with one catch. Once the DS is at the parent, the world
|
||||
expects `adbraun.lol` to be signed. A device on your network that validates
|
||||
DNSSEC *itself* and gets the internal, unsigned copy rejects it. Examples
|
||||
are a Pi-hole or Unbound with DNSSEC on, systemd-resolved with
|
||||
`DNSSEC=yes`, and some routers. Devices that just ask this server are fine.
|
||||
If you run such a validator, sign the internal copy too and publish both
|
||||
DS records.
|
||||
|
||||
**What it costs.** It is one more way for the domain to break: if the DS at
|
||||
the parent stops matching the zone's keys (keys lost, wiped, or a server
|
||||
moved without them), every validating resolver rejects the whole domain.
|
||||
Most of Jarl's DNSSEC features exist to stop that from happening.
|
||||
|
||||
## How it works
|
||||
|
||||
named signs the zone itself, with *inline signing*:
|
||||
|
||||
- Jarl writes the zone file as before (`/var/lib/bind/jarl/<view>/<zone>.zone`).
|
||||
named keeps a signed copy next to it (`.signed`, `.signed.jnl`), signs
|
||||
every change Jarl makes, and re-signs before signatures expire.
|
||||
- The keys live in Jarl's key directory,
|
||||
`/var/lib/bind/jarl/dnssec-keys/<view>/`. The subdirectory is named after
|
||||
the view's ID, so renaming a view doesn't move the keys. Each key is
|
||||
three files, `K<zone>.+013+<tag>.key`, `.private` (readable only by
|
||||
named's user) and `.state`. Zones of the same name in two views have
|
||||
separate directories, because named requires that.
|
||||
- With BIND's `default` policy there is one key, a CSK (combined signing
|
||||
key) using ECDSA P-256 (algorithm 13). It is never replaced, so the DS
|
||||
record at the registrar never has to change.
|
||||
- The signed zone has its own serial, usually a little ahead of Jarl's.
|
||||
Jarl's reload check compares the serial of the file it wrote. The serials
|
||||
check (`jarl zone serials`) compares the signed serial, which is what
|
||||
secondaries serve.
|
||||
- Secondaries transfer the signed zone and need nothing else.
|
||||
- The server's clock must be right (NTP): signatures are valid for a fixed
|
||||
time window.
|
||||
|
||||
## Turning signing on
|
||||
|
||||
```sh
|
||||
jarl zone dnssec enable adbraun.lol --view external
|
||||
jarl zone dnssec status adbraun.lol --view external
|
||||
```
|
||||
|
||||
or, in the web UI, the zone's **DNSSEC** tab → *Turn signing on*. This is
|
||||
safe at any time. Resolvers keep treating the zone as unsigned until its
|
||||
DS record is at the parent.
|
||||
|
||||
`status` shows the key, the DS record, and when it is safe to publish:
|
||||
|
||||
```
|
||||
Status: named is signing the zone. Resolvers still treat it as unsigned, ...
|
||||
Next: Wait until Fri 9 Oct 21:13 UTC before adding the DS at the parent: ...
|
||||
|
||||
KEY ROLE ALGORITHM GOAL DNSKEY DS AT PARENT CREATED
|
||||
51126 CSK ECDSAP256SHA256 omnipresent rumoured hidden 2026-10-08 21:08
|
||||
|
||||
DS record for the parent zone (at your registrar):
|
||||
(not yet: wait until Fri, 09 Oct 2026 21:13:51 UTC)
|
||||
adbraun.lol. 3600 IN DS 51126 13 2 FFBECE3D...
|
||||
key tag 51126, algorithm 13 (ECDSAP256SHA256), digest type 2 (SHA-256), digest FFBECE3D...
|
||||
```
|
||||
|
||||
named first publishes the key for long enough that every resolver's cache
|
||||
has caught up: about a day with the default policy. Adding the DS earlier
|
||||
can make validating resolvers reject cached answers. Then:
|
||||
|
||||
1. **Make sure the domain is delegated to servers that serve the signed
|
||||
zone.** Your own servers, or secondaries that transfer from them.
|
||||
Publishing a DS while the registrar still points at other name servers
|
||||
(Porkbun's own, say) breaks the domain. `jarl zone delegation ZONE`
|
||||
shows where the parent points.
|
||||
2. **Publish the DS record at the registrar**, by hand (the DNSSEC tab has
|
||||
copy buttons for the whole record and for its four fields), or with
|
||||
Jarl's [Porkbun](#porkbun) support, which checks step 1 first.
|
||||
3. **Tell named.** Once the parent zone has the DS (minutes to hours after
|
||||
the registrar accepts it), run the DS check:
|
||||
|
||||
```sh
|
||||
jarl zone dnssec check-ds adbraun.lol --view external
|
||||
```
|
||||
|
||||
It asks every server of the parent zone for the zone's DS records, and
|
||||
tells named (`rndc dnssec -checkds`) when all of them have it. named
|
||||
waits for the parent's DS TTL before it counts the DS as in place.
|
||||
4. **Check.** `jarl zone delegation ZONE` now also compares the parent's
|
||||
DS with the zone's keys. A DS that matches no key is an error.
|
||||
[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 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
|
||||
|
||||
To serve your domain from your own server, the parent zone must delegate
|
||||
it to your server's *name* (NS records can't hold addresses). If that name
|
||||
is inside the domain itself (`ns1.adbraun.lol` for `adbraun.lol`),
|
||||
resolvers couldn't look it up without already reaching your server. **Glue
|
||||
records** solve this: the registry publishes `ns1.adbraun.lol`'s address
|
||||
together with the delegation. Each name server inside the domain needs its
|
||||
own glue record, with all of its addresses (IPv4 and IPv6).
|
||||
|
||||
The order matters:
|
||||
|
||||
1. In the zone (external view): NS records for your name servers, and A/AAAA
|
||||
records for the ones inside the domain.
|
||||
2. At the registrar: a glue record for each of those (Porkbun calls them
|
||||
"glue records", others "host records" or "child name servers").
|
||||
3. Check that every name server answers for the zone from the internet.
|
||||
4. At the registrar: set the domain's name servers.
|
||||
5. `jarl zone delegation ZONE` until it is all green. Resolvers may use the
|
||||
old name servers for a day or two after the change, so keep those
|
||||
serving the zone until then.
|
||||
|
||||
Jarl's [Porkbun](#porkbun) support does steps 2 to 4 with checks.
|
||||
|
||||
Before you go public:
|
||||
|
||||
- **Two name servers.** Registries and RFC 1034 expect at least two, on
|
||||
different networks: with one server at home, a power or internet outage
|
||||
takes the domain off the internet. A free secondary service (Hurricane
|
||||
Electric's dns.he.net, for example) or a small VPS transfers the zone
|
||||
from Jarl, signed zone included. A *hidden primary* goes one step
|
||||
further: your server feeds the secondaries but isn't in the NS records,
|
||||
so your home address isn't advertised.
|
||||
- **A dynamic home address** means the glue must follow it.
|
||||
- **Inbound port 53** (UDP and TCP) must reach the server: some ISPs block
|
||||
it, and CGNAT makes it impossible.
|
||||
|
||||
## Porkbun
|
||||
|
||||
With a Porkbun API key, Jarl manages a domain's name servers, glue records
|
||||
and DS records at Porkbun, and checks each change before making it.
|
||||
|
||||
**Setting it up**
|
||||
|
||||
1. At porkbun.com, *Account → API Access*: create a key. If you can,
|
||||
limit it to the domain and the permissions it needs (domains, read),
|
||||
and to your server's address.
|
||||
2. On the domain's page, switch **API Access** on (Porkbun refuses API
|
||||
calls for a domain until you do).
|
||||
3. Put the key in `/etc/jarl/jarl.yaml` and restart jarld:
|
||||
|
||||
```yaml
|
||||
porkbun:
|
||||
api_key: pk1_...
|
||||
secret_api_key: sk1_...
|
||||
domains: [adbraun.lol] # optional: only these
|
||||
```
|
||||
|
||||
The key can move your domain anywhere, so it lives only in that file:
|
||||
never in Jarl's database or its backups, and there is no API or CLI command
|
||||
to upload it. Keep the file readable by root and jarld only (`chown
|
||||
root:jarl`, `chmod 640`, as the package installs it); jarld warns at start
|
||||
when everyone can read it. Only administrators can use the registrar
|
||||
features.
|
||||
|
||||
**Using it**: the zone's **Registrar** tab, or `jarl zone registrar`:
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| `status ZONE` | Name servers, glue and DS records at Porkbun next to the zone's, with what doesn't match. |
|
||||
| `sync-glue ZONE` | Gives every name server inside the domain a glue record with the zone's A and AAAA records for it. Changing the glue of a name server the domain already uses sends resolvers to the new addresses, so Jarl checks first that they answer for the zone. |
|
||||
| `set-ns ZONE [NS...]` | Sets the domain's name servers (default: the zone's NS records). Checked first: each answers for the zone with the current serial, the ones inside the domain have matching glue, and DS records at Porkbun match the zone's keys (otherwise the domain goes dark for validating resolvers). |
|
||||
| `publish-ds ZONE` | Publishes the zone's DS record. Refused until named says the zone is ready, and unless the domain is delegated to the zone's name servers and they serve the signed zone. |
|
||||
| `remove-ds ZONE KEYTAG` | Removes a DS record (the step when turning signing off; also the fix for a DS that matches no key). |
|
||||
|
||||
Every change can be tried first with `--dry-run`, which shows the checks.
|
||||
Errors refuse the change; `--force` (in the web UI, a checkbox) overrides
|
||||
them, for example when this server can't reach its own public address to
|
||||
check it.
|
||||
|
||||
## Turning signing off
|
||||
|
||||
Never switch a signed zone straight to unsigned while its DS might still
|
||||
be at the parent: validating resolvers would reject the whole domain until
|
||||
the DS is gone *and* its TTL has passed (often a day). Jarl refuses that
|
||||
step and goes through BIND's `insecure` policy instead:
|
||||
|
||||
```sh
|
||||
jarl zone dnssec disable adbraun.lol --view external
|
||||
```
|
||||
|
||||
1. The zone switches to the `insecure` policy. named keeps it signed and
|
||||
publishes a CDS "delete" record. Nothing breaks.
|
||||
2. Remove the DS record at your registrar (`jarl zone registrar remove-ds`
|
||||
with Porkbun). Once the parent zone no longer has it, run `jarl zone
|
||||
dnssec check-ds` so named knows.
|
||||
3. named waits until resolvers have forgotten the DS (the parent's DS TTL
|
||||
plus a margin, about a day), then removes the keys and signatures.
|
||||
`status` shows the progress.
|
||||
4. Run `jarl zone dnssec disable` again (or *Finish turning off* in the web
|
||||
UI) when `status` says so: DNSSEC is then off for the zone.
|
||||
|
||||
If the DS never reached the parent, named skips the wait and you can
|
||||
finish at once. The old keys stay in the key directory (named keeps them
|
||||
retired; signing again makes a new key) until you wipe them.
|
||||
|
||||
## Wipe: a clean slate
|
||||
|
||||
```sh
|
||||
jarl zone dnssec wipe adbraun.lol --view external
|
||||
```
|
||||
|
||||
or *Wipe (clean slate)* on the DNSSEC tab. It switches signing off at once,
|
||||
without the insecure policy, and deletes the zone's keys and named's signed
|
||||
copy, so signing again starts from scratch. It also cleans up a zone that
|
||||
is off but still has old keys.
|
||||
|
||||
That is only safe when the parent zone has no DS record for the zone, so
|
||||
Jarl asks the parent's servers first and **refuses while they have one**.
|
||||
It also refuses while named still counts a recently removed DS as cached.
|
||||
When the parent can't be asked (no internet access, or a zone that isn't on
|
||||
the internet), `--force` wipes anyway. Wiping needs an administrator, and
|
||||
the web UI asks you to type the zone's name.
|
||||
|
||||
Deleting a zone that is signed is refused: turn signing off first.
|
||||
|
||||
## Keys, backups and moving servers
|
||||
|
||||
The zone's keys exist only in the key directory: Jarl's database doesn't
|
||||
hold them. Losing them while the parent has a DS for them breaks the
|
||||
domain.
|
||||
|
||||
- **Backups.** Every backup (`jarld backup`, and the daily
|
||||
`jarl-backup.timer`) stores the key directory's files inside the backup
|
||||
file, private keys included. Keep `/var/backups/jarl` private (it is
|
||||
`0700`). named's private keys are readable only by named's user, so
|
||||
`jarl-backup.service` runs with the one capability it needs to read them
|
||||
(`CAP_DAC_READ_SEARCH`); a manual backup must run as root. `--without-keys`
|
||||
backs up only the database.
|
||||
- **Restoring.** `jarld restore` puts the keys back, owned by named's user
|
||||
(`bind.named_user`), which needs root. Key files that exist but aren't in
|
||||
the backup are left alone.
|
||||
- **Serials after a restore.** A restored database may have older zone
|
||||
serials than named has. named won't load a signed zone's file unless its
|
||||
serial went up, and secondaries ignore a serial that went down. Jarl
|
||||
checks this whenever it writes a zone file, and raises the serial above
|
||||
named's when needed (it shows in the result, and in the audit log).
|
||||
- **Moving to a new server.** Restore the backup *before* named loads
|
||||
Jarl's configuration there. Otherwise named finds no keys and makes new
|
||||
ones, which the parent's DS doesn't match.
|
||||
|
||||
## What Jarl refuses, and why
|
||||
|
||||
| Change | Why |
|
||||
|---|---|
|
||||
| Switching a signed zone straight to unsigned | The parent may still have the DS: use `disable` (insecure first), or `wipe` when the parent has none. |
|
||||
| Finishing `disable` before named is done | Resolvers may still have the DS cached. |
|
||||
| Deleting a signed zone | Turn signing off first. |
|
||||
| Moving a signed zone to another view | It would get new keys (another key directory) that the DS doesn't match. |
|
||||
| A TTL above the policy's `max-zone-ttl` (a day by default) | named refuses to load such a signed zone and keeps serving the old one. |
|
||||
| `dnssec-policy` as a zone, view or server option | Jarl sets it per zone, so it can guard the steps. Top-level `dnssec-policy NAME { ... }` statements (custom policies) are fine. |
|
||||
| A custom policy with an empty `keys { }` list | It crashes named 9.20 when the configuration loads (named-checkconf accepts it). Use the built-in `insecure` policy. |
|
||||
| `inline-signing no` on a signed zone | Jarl rewrites zone files; named must keep its signatures in a separate copy. |
|
||||
| Signing a secondary zone | A secondary gets its signatures from its primary. |
|
||||
|
||||
## Phases
|
||||
|
||||
`status`, the DNSSEC tab and the API report a phase:
|
||||
|
||||
| Phase | Meaning | Next |
|
||||
|---|---|---|
|
||||
| `off` | Not signed. | |
|
||||
| `leftover_keys` | Not signed, but old keys are in the key directory. | Wipe them, or sign again. |
|
||||
| `starting` | Signing is on; named hasn't made the key yet. | Wait a moment. |
|
||||
| `signing` | Signed, but caches may not have the key yet. | Wait until the time shown, then publish the DS. |
|
||||
| `ready` | Safe to add the DS at the parent. | Publish it, then run the DS check. |
|
||||
| `ds_pending` | named knows the DS is at the parent and waits for its TTL. | |
|
||||
| `secure` | Signed, and the parent's DS points at the key. | |
|
||||
| `removing_ds` | Turning off; the DS must go from the parent first. | Remove it, then run the DS check. |
|
||||
| `ds_expiring` | The DS is gone; caches may still have it. | Wait. |
|
||||
| `removing_keys` | named removes the keys and signatures. | Finish when you like (it's safe now). |
|
||||
| `unsigned` | Turning off has finished. | Switch DNSSEC off for the zone. |
|
||||
|
||||
The key states come from named (`rndc dnssec -status ZONE` shows the same):
|
||||
*rumoured* means published, but caches may not have it yet; *omnipresent*
|
||||
means everyone has it; *unretentive* means removed, but caches may still
|
||||
have it; *hidden* means gone.
|
||||
|
||||
## Custom policies
|
||||
|
||||
`default` suits most domains. For anything else (scheduled key rollovers,
|
||||
NSEC3, other algorithms), define a policy as a top-level statement
|
||||
(`jarl option set dnssec-policy 'NAME { ... }' --toplevel`, or *Server
|
||||
options* in the web UI) and enable it with `jarl zone dnssec enable ZONE
|
||||
--policy NAME`. With a policy that rolls the key-signing key,
|
||||
the DS at the parent must change at every rollover: `status` and the DS
|
||||
check tell you when, and named waits for the DS check before it retires
|
||||
the old key.
|
||||
|
||||
## Adopting a signed zone
|
||||
|
||||
When `jarld import` finds a zone that named already signs, it imports its
|
||||
policy and warns. Before `jarld adopt`, copy the zone's key files
|
||||
(`K<zone>.+*`) from its old key directory into the one Jarl uses (`jarl
|
||||
zone dnssec status ZONE` shows it), owned by named's user. Otherwise named
|
||||
makes new keys, and if the parent has a DS for the old ones, the zone stops
|
||||
validating.
|
||||
+29
-12
@@ -60,6 +60,17 @@ sudo apt update
|
||||
sudo apt install jarl
|
||||
```
|
||||
|
||||
Release candidates (`0.2.0-rc.1`) are published to a separate `testing`
|
||||
distribution, so the `stable` line above only ever installs releases. To
|
||||
try release candidates on a test machine, add `testing` as well. apt then
|
||||
installs the newest of either, which is the RC until the final release
|
||||
replaces it:
|
||||
|
||||
```sh
|
||||
echo "deb [signed-by=/etc/apt/keyrings/jarl.asc] https://gitea.adbraun.lol/api/packages/adam/debian testing main" \
|
||||
| sudo tee -a /etc/apt/sources.list.d/jarl.list
|
||||
```
|
||||
|
||||
The packages are as visible as the Gitea account that owns them, not the
|
||||
repository: with a public account, apt needs no login even if the
|
||||
repository is private. For a private or limited account, give apt a token
|
||||
@@ -77,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.
|
||||
@@ -128,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
|
||||
```
|
||||
@@ -156,7 +167,7 @@ This installs:
|
||||
| `/usr/local/bin/jarld`, `/usr/local/bin/jarl` | daemon and CLI |
|
||||
| `/usr/local/share/bash-completion/completions/jarl`, `.../jarld` | bash completion (loaded automatically by the `bash-completion` package) |
|
||||
| `/etc/systemd/system/jarl.service` | systemd unit (`Type=notify`, sandboxed) |
|
||||
| `/etc/systemd/system/jarl-backup.service`, `jarl-backup.timer` | the daily database backup ([operations.md](operations.md#backups)) |
|
||||
| `/etc/systemd/system/jarl-backup.service`, `jarl-backup.timer` | the daily database backup, DNSSEC keys included ([operations.md](operations.md#backups)) |
|
||||
| `/usr/lib/sysusers.d/jarl.conf` | creates the `jarl` system user and adds it to group `bind` |
|
||||
| `/usr/lib/tmpfiles.d/jarl.conf` | creates `/etc/jarl`, `/var/lib/jarl`, `/etc/bind/jarl`, `/var/lib/bind/jarl`, `/var/cache/bind/jarl`, `/var/backups/jarl` |
|
||||
| `/etc/jarl/jarl.yaml` | example configuration (only if none exists) |
|
||||
@@ -230,12 +241,14 @@ tar czf /root/bind-before-jarl-$(date +%F).tgz /etc/bind /var/lib/bind /var/cach
|
||||
## 5. Create the database and the first administrator
|
||||
|
||||
```sh
|
||||
sudo -u jarl jarld init --token \
|
||||
sudo -u jarl jarld init --token -u adam \
|
||||
--nameservers ns1.adbraun.lol. --contact hostmaster@adbraun.lol
|
||||
```
|
||||
|
||||
It asks for the administrator's password (user `admin`, change it with
|
||||
`-u NAME`). `--token` also prints an API token for the CLI, shown only
|
||||
It asks for the administrator's password. Name the account after yourself
|
||||
(`-u`, default `admin`): each person should have their own account, with
|
||||
their own [two-factor login](permissions.md#two-factor-login), which you
|
||||
can turn on under Account once you have logged in. `--token` also prints an API token for the CLI, shown only
|
||||
once. `--nameservers` and `--contact` are the defaults for zones you create
|
||||
later, and you can change them in the UI or with `jarl settings set`.
|
||||
|
||||
@@ -322,7 +335,8 @@ jarl status
|
||||
jarl zone list
|
||||
```
|
||||
|
||||
The CLI's settings are stored in `~/.config/jarl/config.yaml` (mode 0600).
|
||||
The CLI's settings are stored in `~/.config/jarl/config.yaml` (mode 0600;
|
||||
macOS and Windows use their own folders, see [cli.md](cli.md#setup)).
|
||||
`JARL_SERVER` and `JARL_TOKEN` override them, which is handy in scripts.
|
||||
Create more tokens in the web UI (Account → API tokens) or with
|
||||
`jarl token create NAME`. See [cli.md](cli.md).
|
||||
@@ -373,12 +387,15 @@ sudo apt update && sudo apt upgrade # Debian packages from the registry
|
||||
make && sudo make install && sudo systemctl restart jarl # make install
|
||||
```
|
||||
|
||||
Database migrations run automatically at startup. Before an upgrade, take
|
||||
a backup (it is safe while jarld runs); `jarld restore` puts it back if
|
||||
needed:
|
||||
Database migrations run automatically at startup. An older Jarl refuses
|
||||
to start on a database a newer one has upgraded ("database schema version
|
||||
4 is newer than this Jarl"). So going back to an earlier version, after
|
||||
trying a release candidate say, means restoring a backup taken before the
|
||||
upgrade. Before an upgrade, take a backup (it is safe while jarld runs);
|
||||
`jarld restore` puts it back if needed:
|
||||
|
||||
```sh
|
||||
sudo -u jarl jarld backup
|
||||
sudo jarld backup
|
||||
```
|
||||
|
||||
## Uninstalling
|
||||
|
||||
+38
-18
@@ -6,7 +6,8 @@
|
||||
|------|-----|
|
||||
| Is everything healthy? | `jarl status`, or the web UI dashboard: named reachable, adoption, drift, open changesets, configuration problems, the newest backup, tokens about to expire |
|
||||
| Are the secondaries up to date? | `jarl zone serials ZONE`, or the zone's Name servers tab ([below](#checking-name-servers-and-the-delegation)) |
|
||||
| Back up / restore | Daily by `jarl-backup.timer`; by hand `sudo -u jarl jarld backup`; restore with `jarld restore` ([below](#backups)) |
|
||||
| Back up / restore | Daily by `jarl-backup.timer`; by hand `sudo jarld backup`; restore with `jarld restore` ([below](#backups)) |
|
||||
| DNSSEC | `jarl zone dnssec status ZONE`, or the zone's DNSSEC tab ([dnssec.md](dnssec.md)) |
|
||||
| What changed and who did it? | `jarl history`, `jarl changeset show ID` (with diff), `jarl audit` |
|
||||
| Undo a change | `jarl changeset revert ID`, review it, then `jarl changeset apply NEW_ID` (or History → Revert in the UI) |
|
||||
| Check the configuration | `jarl check`: full validation plus `named-checkconf -z` |
|
||||
@@ -23,8 +24,10 @@ loaded. jarld is only needed to make changes.
|
||||
|
||||
Everything Jarl knows is in its database (`/var/lib/jarl/jarl.db`): zones,
|
||||
records, views, ACLs, TSIG keys, options, users, tokens, history and the
|
||||
audit log. named's files are generated from it, so the database is the one
|
||||
thing to back up.
|
||||
audit log. named's files are generated from it. The one exception is the
|
||||
DNSSEC keys of signed zones, which named keeps in Jarl's key directory
|
||||
(`/var/lib/bind/jarl/dnssec-keys`): every backup stores those too, inside the
|
||||
backup file ([dnssec.md](dnssec.md#keys-backups-and-moving-servers)).
|
||||
|
||||
### The daily backup
|
||||
|
||||
@@ -41,17 +44,19 @@ Each run:
|
||||
1. writes a consistent copy of the database with SQLite's `VACUUM INTO`
|
||||
(safe while jarld runs and changes things) to a temporary file in the
|
||||
backup directory;
|
||||
2. checks the copy with SQLite's `integrity_check`, and only then gives it
|
||||
2. stores the DNSSEC keys in it (every file of the key directory, private
|
||||
keys included);
|
||||
3. checks the copy with SQLite's `integrity_check`, and only then gives it
|
||||
its name, `jarl-<UTC time>.db`, e.g. `jarl-20261008T031512Z.db`. A file
|
||||
with that name is always a complete, intact backup;
|
||||
3. deletes backups older than `retention`, but always keeps the newest
|
||||
4. deletes backups older than `retention`, but always keeps the newest
|
||||
`keep_at_least`, however old they are. If backups stop for a while (the
|
||||
machine was off, the disk was full), the old ones aren't all deleted by
|
||||
the next run. Ages come from the time in the name, not the file's
|
||||
modification time, which copying the directory would change. Only files
|
||||
named `jarl-<time>.db` (and `stats-<time>.db`) are ever deleted, so
|
||||
anything else you put in the directory is safe;
|
||||
4. records the result in the database, so the dashboard and `jarl status`
|
||||
5. records the result in the database, so the dashboard and `jarl status`
|
||||
show when the newest backup was made, and whether the last attempt
|
||||
failed.
|
||||
|
||||
@@ -87,7 +92,9 @@ systemctl edit jarl-backup.timer
|
||||
|
||||
The backup service runs as the `jarl` user in a sandbox that may only
|
||||
write to `/var/lib/jarl` and `/var/backups/jarl` (created by
|
||||
`tmpfiles.d/jarl.conf`, mode 0700). To keep backups elsewhere, set
|
||||
`tmpfiles.d/jarl.conf`, mode 0700). It has one capability,
|
||||
`CAP_DAC_READ_SEARCH`, to read named's DNSSEC private keys, which only
|
||||
named's user may read. To keep backups elsewhere, set
|
||||
`backup.dir`, create the directory (owned by `jarl`, mode 0700) and allow
|
||||
it:
|
||||
|
||||
@@ -104,18 +111,23 @@ well.
|
||||
### By hand
|
||||
|
||||
```sh
|
||||
sudo -u jarl jarld backup # into backup.dir, as the timer does
|
||||
sudo -u jarl jarld backup /root/before-upgrade.db # one copy, never deleted automatically
|
||||
sudo jarld restore # list the backups in backup.dir
|
||||
sudo jarld backup # into backup.dir, as the timer does
|
||||
sudo jarld backup /root/before-upgrade.db # one copy, never deleted automatically
|
||||
sudo jarld restore # list the backups in backup.dir
|
||||
sudo systemctl start jarl-backup # or exactly what the timer runs
|
||||
```
|
||||
|
||||
Query statistics live in a separate database (`/var/lib/jarl/stats.db`).
|
||||
DNS doesn't need it, so it is backed up only with `backup.stats: true`, as
|
||||
`stats-<UTC time>.db`.
|
||||
|
||||
Run it as root (files it creates are given to `jarl`): named's DNSSEC
|
||||
private keys are readable only by named's user. As another user it fails
|
||||
when there are keys; `--without-keys` backs up only the database.
|
||||
|
||||
The database holds password and token *hashes*, and TSIG secrets in the
|
||||
clear. Backups are written with mode 0600 into a 0700 directory; keep any
|
||||
copies just as private.
|
||||
clear, and backups hold the DNSSEC private keys too. Backups are written
|
||||
with mode 0600 into a 0700 directory; keep any copies just as private.
|
||||
|
||||
### Restoring
|
||||
|
||||
@@ -138,7 +150,11 @@ changing anything (`--yes` skips the question). Then it:
|
||||
`jarl.db.before-restore-<UTC time>` (with its `-wal` and `-shm` files),
|
||||
so a restore can be undone the same way;
|
||||
* copies the backup in place, upgrades it to the current schema if it is
|
||||
older, and records the restore in its audit log.
|
||||
older, and records the restore in its audit log;
|
||||
* puts the DNSSEC keys stored in the backup back into the key directory,
|
||||
owned by named's user (`bind.named_user`) with the modes they had. Key
|
||||
files that are there but not in the backup are left alone.
|
||||
`--without-keys` skips this; without it, a backup with keys needs root.
|
||||
|
||||
Then start jarld and bring named in line with the restored database:
|
||||
|
||||
@@ -152,11 +168,14 @@ jarl resync --force # write everything and reload named
|
||||
Things to know:
|
||||
|
||||
* **Zone serials.** An older backup can carry lower zone serials than named
|
||||
last served. Your own named doesn't mind, but secondaries and caches
|
||||
ignore a serial that went backwards, so they keep the newer data. If you
|
||||
have secondaries, raise the serials after a restore:
|
||||
`jarl zone update ZONE --serial <higher than before>`, then check with
|
||||
`jarl zone serials ZONE`.
|
||||
last served. Secondaries ignore a serial that went backwards, and named
|
||||
refuses one for a signed zone. Jarl handles it: whenever it writes a zone
|
||||
file whose serial isn't above the one named has loaded, it raises the
|
||||
serial past named's (the resync result and the audit log show it). Check
|
||||
the secondaries afterwards with `jarl zone serials ZONE`.
|
||||
* **DNSSEC keys** must be back before named loads a signed zone, or named
|
||||
makes new keys that the parent's DS record doesn't match. On a new
|
||||
machine, restore before adopting.
|
||||
* **Tokens and passwords** go back to what they were when the backup was
|
||||
made: tokens created since are gone, and revoked ones work again. Rotate
|
||||
or revoke as needed.
|
||||
@@ -218,6 +237,7 @@ socket, without a token.
|
||||
| Forgot the admin password | `sudo jarl user update admin --password`, or (also with jarld stopped) `sudo -u jarl jarld user passwd admin` |
|
||||
| Locked out by the login rate limit | wait 15 minutes, or `systemctl restart jarl` (the limiter is in memory) |
|
||||
| Lost every admin account | `sudo -u jarl jarld user add --admin rescue` |
|
||||
| Lost the phone with the authenticator app, and the recovery codes | another administrator: Users → Reset 2FA, or `jarl user totp reset NAME`; on the server: `sudo -u jarl jarld user totp reset NAME` |
|
||||
| Need a token without the web UI | `sudo jarl token create cli --user admin`, or `sudo -u jarl jarld user token admin cli` |
|
||||
|
||||
## A secondary server
|
||||
|
||||
+96
-5
@@ -4,7 +4,7 @@
|
||||
|
||||
| Who | How | Notes |
|
||||
|-----|-----|-------|
|
||||
| People in the web UI | Username + password → session cookie | argon2id password hashes. The session cookie is HttpOnly and SameSite=Strict and lasts 12h (2h idle). Every change also needs the session's CSRF token, which the UI sends in `X-CSRF-Token`. Changing a password logs out the user's other sessions. |
|
||||
| People in the web UI | Username + password (+ a code from an authenticator app, with [two-factor login](#two-factor-login)) → session cookie | argon2id password hashes. The session cookie is HttpOnly and SameSite=Strict and lasts 12h (2h idle). Every change also needs the session's CSRF token, which the UI sends in `X-CSRF-Token`. Changing a password logs out the user's other sessions. |
|
||||
| CLI, scripts, certbot | API token: `Authorization: Bearer jarl_...` | Shown once at creation, stored hashed. Optional expiry. Revocable. Last use time and IP are recorded. |
|
||||
| lego / Traefik (`httpreq`) | HTTP Basic: the token's user and the API token | Only on `/api/v1/acme/httpreq/...`, only when `acme.httpreq.enabled` is set, and by default only over HTTPS ([acme.md](acme.md)). The token's grants apply as usual. |
|
||||
| Root on the server, with the CLI | jarld's local socket (`/run/jarl/jarl.sock`): no token | `sudo jarl ...` with no server configured. The kernel tells jarld which system user is connecting; root and the `jarl` user are administrators, anyone else must send a token. Audited as the system user (e.g. `root`) via `socket`. See [The local socket](#the-local-socket). |
|
||||
@@ -24,11 +24,100 @@ A user **without a password** (`--no-password`) is a service account. It
|
||||
can't log in to the web UI and is used only through its tokens, which suits
|
||||
certbot, CI jobs and backups.
|
||||
|
||||
## Two-factor login
|
||||
|
||||
Users can protect their web login with a second factor: a 6-digit code
|
||||
from an authenticator app on their phone (TOTP: Aegis, Google
|
||||
Authenticator, 1Password, Bitwarden and the like). A stolen password alone
|
||||
then gets nobody in.
|
||||
|
||||
**Setting it up** (Account → Two-factor login): enter your password, scan
|
||||
the QR code (or type the key shown under it), and enter the code the app
|
||||
shows. Only then is it on, so a bad scan can't lock you out. Jarl then
|
||||
shows **10 recovery codes**, once: save them in a password manager or print
|
||||
them. Each logs you in once instead of a code, for when the phone is lost.
|
||||
Turning it on logs out your other sessions.
|
||||
|
||||
**Logging in**: username and password first; the second screen asks for
|
||||
the code (or, under "Lost your phone?", a recovery code). The password
|
||||
step alone gives no session: it is good for 5 minutes and 5 tries. Codes
|
||||
from one 30-second step either side of now are accepted, for clocks that
|
||||
are a little off, and each code works only once. Wrong codes count
|
||||
against the same rate limit as wrong passwords.
|
||||
|
||||
**Actions that ask for a code again.** Some things would let a stolen
|
||||
session or password outlive the second factor, so with two-factor login
|
||||
they need a code from the last 5 minutes (logging in counts): creating
|
||||
or rotating API tokens, creating, changing or deleting users, and
|
||||
resetting someone's two-factor login. The web UI asks for a code when
|
||||
needed; API clients send it in the `X-Jarl-TOTP` header
|
||||
([api.md](api.md#authentication)).
|
||||
|
||||
**What it doesn't cover**: API tokens and the local socket. A token is
|
||||
already its own long random secret, and scripts can't answer a code
|
||||
prompt, which is why making one asks for a code. Root on the server can
|
||||
read the database anyway, so a code would add nothing there.
|
||||
|
||||
**Making it compulsory**: `totp.require` in jarl.yaml
|
||||
([configuration.md](configuration.md#web-ui-and-api)):
|
||||
|
||||
| `totp.require` | Who must use it |
|
||||
|--|--|
|
||||
| `none` (default) | nobody; each user decides |
|
||||
| `admins` | administrators |
|
||||
| `all` | every user with a password (service accounts can't log in anyway) |
|
||||
|
||||
A user who must use it and hasn't set it up yet can log in with the
|
||||
password, but then only sees "Set up two-factor login" until they do. They
|
||||
can't turn it off while it is required (moving to a new phone is "Move to
|
||||
a new phone" under Account). The setting lives in jarl.yaml rather than in
|
||||
the web UI on purpose: someone who steals an administrator's login can't
|
||||
switch it off. The dashboard and `jarl status` list users who must use it
|
||||
but haven't set it up.
|
||||
|
||||
**Lost phone**:
|
||||
|
||||
* With a recovery code: log in with it, then Account → Move to a new
|
||||
phone. New recovery codes replace the old ones (Account → New recovery
|
||||
codes; Jarl warns when 3 or fewer are left).
|
||||
* Without: an administrator resets it (Users → Reset 2FA, or `jarl user
|
||||
totp reset NAME`). That turns it off, deletes the recovery codes and
|
||||
logs the user out; they log in with the password and set it up again.
|
||||
* No administrator can log in: on the server, `sudo -u jarl jarld user
|
||||
totp reset NAME` (works with jarld stopped too).
|
||||
|
||||
Every setup, change, failed code, recovery code use and reset is in the
|
||||
audit log (`user.totp_*`, `auth.totp_failed`).
|
||||
|
||||
The secret is stored in the database, like TSIG secrets (it can't be
|
||||
hashed: the server needs it to work out the codes), so it is as safe as
|
||||
the database file and its backups (mode 0600, root and `jarl` only).
|
||||
Recovery codes are stored hashed.
|
||||
|
||||
### One account per person
|
||||
|
||||
`jarld init` creates the first administrator, `admin` unless you name it
|
||||
(`-u NAME`). If several people use that one account, nobody can tell from
|
||||
the audit log who did what, and it can't have two-factor login per person.
|
||||
So:
|
||||
|
||||
* **Just you**: rename it to your name (`jarl user update admin --rename
|
||||
adam`, or Users → Edit) and turn on two-factor login.
|
||||
* **Several people**: give each their own administrator account with their
|
||||
own two-factor login, then disable `admin` (`jarl user update admin
|
||||
--disabled`). The dashboard and `jarl status` point this out while an
|
||||
enabled `admin` exists next to other administrators.
|
||||
|
||||
Nothing is lost by disabling it: root on the server always gets back in
|
||||
([Recovering access](#recovering-access)), and Jarl refuses to disable,
|
||||
demote or delete the last enabled administrator.
|
||||
|
||||
## Grants
|
||||
|
||||
A user's permissions are a list of grants. Administrators (`admin: true`)
|
||||
may do everything, and only they can manage users, see everyone's tokens
|
||||
and run `resync`.
|
||||
may do everything, and only they can manage users, see everyone's tokens,
|
||||
run `resync`, wipe a zone's DNSSEC keys, and use the registrar (Porkbun,
|
||||
whose key can move the domain).
|
||||
|
||||
A grant is written as:
|
||||
|
||||
@@ -38,7 +127,7 @@ RESOURCE:ACCESS [zones=PATTERNS] [views=PATTERNS] [names=PATTERNS] [types=TYPES]
|
||||
|
||||
| Resource | Covers |
|
||||
|----------|--------|
|
||||
| `zones` | zone definitions (create, delete, SOA, TTL, view) and zone options |
|
||||
| `zones` | zone definitions (create, delete, SOA, TTL, view, DNSSEC signing and the DS check) and zone options |
|
||||
| `records` | records in primary zones |
|
||||
| `views` | views and view options |
|
||||
| `acls` | ACLs |
|
||||
@@ -46,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.
|
||||
@@ -79,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` |
|
||||
|
||||
@@ -237,4 +327,5 @@ sudo -u jarl jarld user list
|
||||
sudo -u jarl jarld user passwd admin # set a new password and re-enable the account
|
||||
sudo -u jarl jarld user add --admin rescue # a new administrator
|
||||
sudo -u jarl jarld user token admin cli # a new API token for a user
|
||||
sudo -u jarl jarld user totp reset admin # turn off two-factor login (a lost phone)
|
||||
```
|
||||
+40
-2
@@ -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,6 +168,8 @@ jarl stats timeline --since 6h # sparkline; --table for every bucket
|
||||
jarl stats timeline --since 7d --key rcode:NXDOMAIN --key rcode:SERVFAIL
|
||||
jarl stats 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)
|
||||
```
|
||||
|
||||
|
||||
+46
-2
@@ -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 |
|
||||
@@ -68,17 +71,51 @@ showing them.
|
||||
| `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 |
|
||||
| `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) |
|
||||
| `option_syntax`, `option_value_invalid` | error | The value must parse (balanced braces, strings, the grammar's value type: yes/no, enums, numbers, durations, sizes, address match lists) |
|
||||
| `option_duplicate` | error | Options that may appear once, appear once |
|
||||
| `option_managed` | error | Statements Jarl manages itself can't be raw options: `zone`, `view`, `acl`, `key`, `options`, a zone's `type`/`file`/`in-view`, a view's `match-*` |
|
||||
| `option_managed` | error | Statements Jarl manages itself can't be raw options: `zone`, `view`, `acl`, `key`, `options`, a zone's `type`/`file`/`in-view`/`key-directory`, a view's `match-*`, and `dnssec-policy` in zones, views and server options (set per zone instead; top-level `dnssec-policy NAME { }` definitions are options) |
|
||||
| `option_deprecated`, `option_obsolete`, `option_unavailable` | warning | Flags from the BIND 9.20 reference |
|
||||
| `option_experimental` | info | |
|
||||
| `settings_invalid` | error | Defaults for new zones are valid |
|
||||
|
||||
## DNSSEC
|
||||
|
||||
These rules come from [dnssec.md](dnssec.md#what-jarl-refuses-and-why).
|
||||
Those that compare the change with the current state are checked when it is
|
||||
applied.
|
||||
|
||||
| Code | Severity | Rule |
|
||||
|------|----------|------|
|
||||
| `dnssec_zone_type` | error | Only primary zones are signed by Jarl |
|
||||
| `dnssec_policy_unknown` | error | The zone's policy must be `default`, `insecure` or a top-level `dnssec-policy` statement |
|
||||
| `dnssec_policy_no_keys` | error | A custom policy with an empty `keys { }` list (named 9.20 crashes loading it) |
|
||||
| `dnssec_ttl_too_long` | error | TTLs (and the default TTL) of a signed zone must be at most the policy's `max-zone-ttl` (a day for the built-in policies); named refuses to load the zone otherwise |
|
||||
| `dnssec_inline_signing` | error | No `inline-signing no` on a signed zone |
|
||||
| `dnssec_off_unsafe` | error | A signed zone can't go straight to unsigned: use the `insecure` policy first (or wipe, when the parent has no DS) |
|
||||
| `dnssec_off_too_early`, `dnssec_off_unknown` | error | Switching off after `insecure` waits until named is done removing the signatures |
|
||||
| `dnssec_delete_signed` | error | A signed zone can't be deleted; turn signing off first |
|
||||
| `dnssec_keys_moved` | error | A signed zone can't move to another view (its keys would change) |
|
||||
| `dnssec_policy_change` | warning | Changing the policy may replace the keys, and with them the DS at the parent |
|
||||
| `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 |
|
||||
|
||||
## Security lints
|
||||
|
||||
| Code | Severity | What it catches |
|
||||
@@ -95,3 +132,10 @@ values Jarl accepts as raw named.conf syntax, such as `logging` channels
|
||||
and `rrset-order`, are the main example. Their errors come back as
|
||||
`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.
|
||||
+45
-1
@@ -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,5 +167,12 @@ 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`.
|
||||
* 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`
|
||||
goes back to the link colour.
|
||||
* Zone files are stored per view: `/var/lib/bind/jarl/internal/adbraun.lol.zone`,
|
||||
`/var/lib/bind/jarl/external/adbraun.lol.zone`.
|
||||
@@ -11,6 +11,7 @@ require (
|
||||
golang.org/x/net v0.58.0
|
||||
golang.org/x/term v0.46.0
|
||||
modernc.org/sqlite v1.60.1
|
||||
rsc.io/qr v0.2.0
|
||||
)
|
||||
|
||||
require (
|
||||
|
||||
@@ -72,3 +72,5 @@ modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
||||
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||
rsc.io/qr v0.2.0 h1:6vBLea5/NRMVTz8V66gipeLycZMl/+UlFmk8DvqQ6WY=
|
||||
rsc.io/qr v0.2.0/go.mod h1:IF+uZjkb9fqyeF/4tlBoynqmQxUoPfWEKh921coOuXs=
|
||||
+255
-7
@@ -6,8 +6,11 @@ import (
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
"jarl/internal/dnssec"
|
||||
"jarl/internal/model"
|
||||
"jarl/internal/nscheck"
|
||||
"jarl/internal/porkbun"
|
||||
"jarl/internal/version"
|
||||
)
|
||||
|
||||
// Version is the API path prefix.
|
||||
@@ -16,11 +19,16 @@ 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"`
|
||||
// Pending is set when listing with a changeset: add, change or delete.
|
||||
Pending string `json:"pending,omitempty"`
|
||||
}
|
||||
@@ -34,14 +42,21 @@ 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"`
|
||||
}
|
||||
|
||||
// View is a view with its zone count.
|
||||
type View struct {
|
||||
model.View
|
||||
Zones int `json:"zone_count"`
|
||||
Zones int `json:"zone_count"`
|
||||
Color string `json:"color,omitempty"` // web UI colour, see model.ViewColors
|
||||
}
|
||||
|
||||
// ViewColor sets the colour the web UI shows a view in.
|
||||
type ViewColor struct {
|
||||
Color string `json:"color"`
|
||||
}
|
||||
|
||||
// ACL is an ACL with the objects that use it.
|
||||
@@ -62,12 +77,19 @@ 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.
|
||||
type User struct {
|
||||
model.User
|
||||
GrantStrings []string `json:"grant_strings"`
|
||||
// TOTPRequired says that totp.require in jarl.yaml makes two-factor
|
||||
// login compulsory for the user.
|
||||
TOTPRequired bool `json:"totp_required,omitempty"`
|
||||
}
|
||||
|
||||
// Token is an API token. Secret is only set when the token is created.
|
||||
@@ -86,6 +108,69 @@ type Whoami struct {
|
||||
Permissions []string `json:"permissions"`
|
||||
Admin bool `json:"admin"`
|
||||
Version string `json:"version"`
|
||||
// TOTP is the user's two-factor login (sessions only).
|
||||
TOTP *TOTPStatus `json:"totp,omitempty"`
|
||||
// TOTPSetupRequired: the user must set up two-factor login before
|
||||
// doing anything else (totp.require).
|
||||
TOTPSetupRequired bool `json:"totp_setup_required,omitempty"`
|
||||
// Build is what the server was built from (release or commit).
|
||||
Build version.Build `json:"build"`
|
||||
}
|
||||
|
||||
// LoginChallenge answers a correct password of a user with two-factor
|
||||
// login: send a code with the challenge to POST /auth/login/totp.
|
||||
type LoginChallenge struct {
|
||||
TOTPRequired bool `json:"totp_required"`
|
||||
Challenge string `json:"challenge"`
|
||||
}
|
||||
|
||||
// LoginTOTP is the second login step.
|
||||
type LoginTOTP struct {
|
||||
Challenge string `json:"challenge"`
|
||||
// Code is the current code from the authenticator app, or a recovery
|
||||
// code.
|
||||
Code string `json:"code"`
|
||||
}
|
||||
|
||||
// TOTPStatus is a user's two-factor login (GET /me/totp).
|
||||
type TOTPStatus struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
EnabledAt *time.Time `json:"enabled_at,omitempty"`
|
||||
Required bool `json:"required"`
|
||||
RecoveryCodesLeft int `json:"recovery_codes_left"`
|
||||
// Policy is totp.require: none, admins or all.
|
||||
Policy string `json:"policy"`
|
||||
}
|
||||
|
||||
// TOTPSetupRequest starts setting up two-factor login.
|
||||
type TOTPSetupRequest struct {
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
// TOTPSetup is a new secret to put into an authenticator app: QR is the
|
||||
// QR code of URI, as an image data: URI.
|
||||
type TOTPSetup struct {
|
||||
Secret string `json:"secret"`
|
||||
URI string `json:"uri"`
|
||||
QR string `json:"qr"`
|
||||
Issuer string `json:"issuer"`
|
||||
Account string `json:"account"`
|
||||
}
|
||||
|
||||
// TOTPCode confirms a setup.
|
||||
type TOTPCode struct {
|
||||
Code string `json:"code"`
|
||||
}
|
||||
|
||||
// TOTPDisable turns two-factor login off.
|
||||
type TOTPDisable struct {
|
||||
Password string `json:"password"`
|
||||
Code string `json:"code"`
|
||||
}
|
||||
|
||||
// RecoveryCodes are new recovery codes, shown once.
|
||||
type RecoveryCodes struct {
|
||||
Codes []string `json:"recovery_codes"`
|
||||
}
|
||||
|
||||
// LoginRequest logs in.
|
||||
@@ -150,6 +235,7 @@ type Changeset struct {
|
||||
// StatusResponse is GET /status.
|
||||
type StatusResponse struct {
|
||||
Version string `json:"version"`
|
||||
Build version.Build `json:"build"`
|
||||
Engine json.RawMessage `json:"engine"`
|
||||
Time time.Time `json:"time"`
|
||||
// ExpiringTokens are tokens that expire within TokenExpiryWarning or
|
||||
@@ -158,6 +244,36 @@ type StatusResponse struct {
|
||||
ExpiringTokens []Token `json:"expiring_tokens"`
|
||||
// Backup is the state of database backups (administrators only).
|
||||
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.
|
||||
type TOTPOverview struct {
|
||||
// Policy is totp.require: none, admins or all.
|
||||
Policy string `json:"policy"`
|
||||
// Missing are enabled users who must use two-factor login and
|
||||
// haven't set it up (they can do nothing else until they do).
|
||||
Missing []string `json:"missing"`
|
||||
// SharedAdmin is set when an enabled administrator called "admin"
|
||||
// (the usual first account) exists next to other enabled
|
||||
// administrators: it is probably shared, and worth disabling.
|
||||
SharedAdmin string `json:"shared_admin,omitempty"`
|
||||
}
|
||||
|
||||
// TokenExpiresHeader is sent with responses to a token that expires
|
||||
@@ -267,3 +383,135 @@ type DelegationResponse struct {
|
||||
Notes []string `json:"notes"`
|
||||
CheckedAt time.Time `json:"checked_at"`
|
||||
}
|
||||
|
||||
// DNSSECResponse is GET /zones/{zone}/dnssec.
|
||||
type DNSSECResponse struct {
|
||||
Zone string `json:"zone"`
|
||||
View string `json:"view,omitempty"`
|
||||
Type string `json:"type"`
|
||||
*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"`
|
||||
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"`
|
||||
}
|
||||
|
||||
// CheckDSResponse is POST /zones/{zone}/dnssec/check-ds: what the parent
|
||||
// zone has, and what named was told.
|
||||
type CheckDSResponse struct {
|
||||
Parent *nscheck.ParentDSReport `json:"parent"`
|
||||
Marked []string `json:"marked"`
|
||||
Notes []string `json:"notes"`
|
||||
Status *dnssec.Status `json:"status"`
|
||||
}
|
||||
|
||||
// DNSSECWipe is POST /zones/{zone}/dnssec/wipe.
|
||||
type DNSSECWipe struct {
|
||||
// Force wipes although the parent zone's DS records couldn't be
|
||||
// checked, or named still counts a removed DS as cached.
|
||||
Force bool `json:"force,omitempty"`
|
||||
DryRun bool `json:"dry_run,omitempty"`
|
||||
}
|
||||
|
||||
// DNSSECWipeResponse is the result of a wipe.
|
||||
type DNSSECWipeResponse struct {
|
||||
Removed []string `json:"removed"`
|
||||
DryRun bool `json:"dry_run,omitempty"`
|
||||
ChangesetID int64 `json:"changeset_id,omitempty"`
|
||||
Parent *nscheck.ParentDSReport `json:"parent,omitempty"`
|
||||
}
|
||||
|
||||
// RegistrarResponse is GET /zones/{zone}/registrar: the domain's
|
||||
// delegation at the registrar, next to what the zone says.
|
||||
type RegistrarResponse struct {
|
||||
Registrar string `json:"registrar"` // "porkbun"
|
||||
Domain string `json:"domain"`
|
||||
// NS are the name servers at the registry, JarlNS those in the
|
||||
// zone's NS records (both without trailing dots).
|
||||
NS []string `json:"ns"`
|
||||
JarlNS []string `json:"jarl_ns"`
|
||||
NSMatch bool `json:"ns_match"`
|
||||
// Glue covers every glue record at the registry and every name server
|
||||
// inside the zone that needs one.
|
||||
Glue []GlueStatus `json:"glue"`
|
||||
// DS are the DS records at the registry.
|
||||
DS []RegistrarDS `json:"ds"`
|
||||
Findings []nscheck.Finding `json:"findings"`
|
||||
// Error is set when the registrar couldn't be asked; the rest is
|
||||
// then empty.
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// GlueStatus compares a glue record at the registry with the zone.
|
||||
type GlueStatus struct {
|
||||
Host string `json:"host"`
|
||||
Registry []string `json:"registry"` // addresses at the registry (none: no glue record)
|
||||
Jarl []string `json:"jarl"` // the zone's A and AAAA records for the host
|
||||
// Needed: a name server in the zone's NS records that is inside the
|
||||
// domain, so resolvers need its glue. InUse: in the registry's NS.
|
||||
Needed bool `json:"needed"`
|
||||
InUse bool `json:"in_use"`
|
||||
// Action is what syncing the glue does: "create", "update" or "".
|
||||
Action string `json:"action,omitempty"`
|
||||
}
|
||||
|
||||
// RegistrarDS is a DS record at the registry.
|
||||
type RegistrarDS struct {
|
||||
porkbun.DS
|
||||
// Matches: it points at a key named publishes for the zone.
|
||||
Matches bool `json:"matches"`
|
||||
}
|
||||
|
||||
// RegistrarChange asks for a change at the registrar.
|
||||
type RegistrarChange struct {
|
||||
// NS (set-ns) are the name servers to set (default: the zone's NS
|
||||
// records).
|
||||
NS []string `json:"ns,omitempty"`
|
||||
// KeyTag (publish DS) picks the key (default: every key that needs
|
||||
// one).
|
||||
KeyTag uint16 `json:"key_tag,omitempty"`
|
||||
// Force skips the checks that the change won't break the domain.
|
||||
Force bool `json:"force,omitempty"`
|
||||
DryRun bool `json:"dry_run,omitempty"`
|
||||
}
|
||||
|
||||
// RegistrarChangeResponse is the result of a change at the registrar.
|
||||
type RegistrarChangeResponse struct {
|
||||
// Done lists what was changed (or would be, in a dry run).
|
||||
Done []string `json:"done"`
|
||||
DryRun bool `json:"dry_run,omitempty"`
|
||||
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"`
|
||||
}
|
||||
|
||||
// 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
@@ -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"}}},
|
||||
|
||||
+144
-3
@@ -59,9 +59,11 @@ func parse(name string) (kind string, t time.Time, ok bool) {
|
||||
}
|
||||
|
||||
// Write makes a consistent copy of the live SQLite database src as
|
||||
// dir/Name(kind, now), and checks the copy before giving it its final
|
||||
// name: a file with a backup's name is always a complete, intact backup.
|
||||
func Write(ctx context.Context, src, dir, kind string, now time.Time) (*File, error) {
|
||||
// dir/Name(kind, now), stores the files under filesDir in it (the DNSSEC
|
||||
// keys; "" for none, see AddFiles), and checks the copy before giving it
|
||||
// its final name: a file with a backup's name is always a complete,
|
||||
// intact backup.
|
||||
func Write(ctx context.Context, src, dir, kind string, now time.Time, filesDir string) (*File, error) {
|
||||
if _, err := os.Stat(src); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -78,6 +80,12 @@ func Write(ctx context.Context, src, dir, kind string, now time.Time) (*File, er
|
||||
os.Remove(tmp)
|
||||
return nil, err
|
||||
}
|
||||
if filesDir != "" {
|
||||
if _, err := AddFiles(ctx, tmp, filesDir); err != nil {
|
||||
os.Remove(tmp)
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if err := Verify(ctx, tmp); err != nil {
|
||||
os.Remove(tmp)
|
||||
return nil, fmt.Errorf("the new backup failed its check: %w", err)
|
||||
@@ -144,6 +152,130 @@ func openCopy(path string) (*sql.DB, error) {
|
||||
return sql.Open("sqlite", "file:"+escape(path)+"?mode=ro&immutable=1")
|
||||
}
|
||||
|
||||
// FilesTable holds the files a backup stores next to the database. It
|
||||
// exists only in backups: 'jarld restore' writes the files back and drops
|
||||
// the table.
|
||||
const FilesTable = "backup_files"
|
||||
|
||||
// StoredFile is a file stored in a backup.
|
||||
type StoredFile struct {
|
||||
// Path is relative to the directory the files came from (Root).
|
||||
Path string
|
||||
Mode os.FileMode
|
||||
Data []byte
|
||||
}
|
||||
|
||||
// AddFiles stores every regular file under root in the backup database
|
||||
// at path (the DNSSEC keys named keeps under Jarl's key directory,
|
||||
// private keys included). A missing root stores nothing. Files that can't
|
||||
// be read are an error: named's private keys are readable only by named's
|
||||
// user, so the backup must run as root or with CAP_DAC_READ_SEARCH (as
|
||||
// jarl-backup.service does).
|
||||
func AddFiles(ctx context.Context, path, root string) (int, error) {
|
||||
var files []StoredFile
|
||||
err := filepath.WalkDir(root, func(p string, d os.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) && p == root {
|
||||
return filepath.SkipAll
|
||||
}
|
||||
return err
|
||||
}
|
||||
if !d.Type().IsRegular() {
|
||||
return nil
|
||||
}
|
||||
b, err := os.ReadFile(p)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fi, err := d.Info()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rel, _ := filepath.Rel(root, p)
|
||||
files = append(files, StoredFile{Path: rel, Mode: fi.Mode().Perm(), Data: b})
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
if os.IsPermission(err) {
|
||||
return 0, fmt.Errorf("reading the DNSSEC keys: %w (named's private keys are readable only by its own user: back up as root, or through jarl-backup.service)", err)
|
||||
}
|
||||
return 0, fmt.Errorf("reading the DNSSEC keys: %w", err)
|
||||
}
|
||||
db, err := sql.Open("sqlite", "file:"+escape(path)+"?mode=rw")
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer db.Close()
|
||||
tx, err := db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
if _, err := tx.ExecContext(ctx, `CREATE TABLE `+FilesTable+` (root TEXT NOT NULL, path TEXT NOT NULL, mode INTEGER NOT NULL, data BLOB NOT NULL, PRIMARY KEY (root, path))`); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
for _, f := range files {
|
||||
if _, err := tx.ExecContext(ctx, `INSERT INTO `+FilesTable+` (root, path, mode, data) VALUES (?, ?, ?, ?)`, root, f.Path, int(f.Mode), f.Data); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
}
|
||||
return len(files), tx.Commit()
|
||||
}
|
||||
|
||||
// Files returns the files stored in a backup and the directory they came
|
||||
// from ("" and none for a backup without files).
|
||||
func Files(ctx context.Context, path string) (string, []StoredFile, error) {
|
||||
db, err := openCopy(path)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
defer db.Close()
|
||||
if !hasTable(ctx, db, FilesTable) {
|
||||
return "", nil, nil
|
||||
}
|
||||
rows, err := db.QueryContext(ctx, `SELECT root, path, mode, data FROM `+FilesTable+` ORDER BY path`)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var root string
|
||||
var out []StoredFile
|
||||
for rows.Next() {
|
||||
var f StoredFile
|
||||
var mode int
|
||||
if err := rows.Scan(&root, &f.Path, &mode, &f.Data); err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
f.Mode = os.FileMode(mode).Perm()
|
||||
out = append(out, f)
|
||||
}
|
||||
return root, out, rows.Err()
|
||||
}
|
||||
|
||||
func hasTable(ctx context.Context, db *sql.DB, name string) bool {
|
||||
var n int
|
||||
db.QueryRowContext(ctx, `SELECT count(*) FROM sqlite_master WHERE type = 'table' AND name = ?`, name).Scan(&n)
|
||||
return n > 0
|
||||
}
|
||||
|
||||
// DropFiles removes the stored files from a database restored from a
|
||||
// backup, and vacuums it so the private keys don't linger in free pages.
|
||||
func DropFiles(ctx context.Context, path string) error {
|
||||
db, err := sql.Open("sqlite", "file:"+escape(path)+"?mode=rw")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer db.Close()
|
||||
if !hasTable(ctx, db, FilesTable) {
|
||||
return nil
|
||||
}
|
||||
if _, err := db.ExecContext(ctx, `DROP TABLE `+FilesTable); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = db.ExecContext(ctx, `VACUUM`)
|
||||
return err
|
||||
}
|
||||
|
||||
// Info describes the contents of a configuration database (KindJarl).
|
||||
type Info struct {
|
||||
Schema int `json:"schema"`
|
||||
@@ -151,6 +283,10 @@ type Info struct {
|
||||
Records int `json:"records"`
|
||||
Users int `json:"users"`
|
||||
LastChange *time.Time `json:"last_change,omitempty"`
|
||||
// KeyFiles is the number of DNSSEC key files stored in a backup, and
|
||||
// SignedZones how many zones were signed.
|
||||
KeyFiles int `json:"key_files"`
|
||||
SignedZones int `json:"signed_zones"`
|
||||
}
|
||||
|
||||
// Inspect reads a configuration database's schema version and size. It
|
||||
@@ -168,6 +304,11 @@ func Inspect(ctx context.Context, path string) (*Info, error) {
|
||||
db.QueryRowContext(ctx, `SELECT count(*) FROM zones`).Scan(&in.Zones)
|
||||
db.QueryRowContext(ctx, `SELECT count(*) FROM records`).Scan(&in.Records)
|
||||
db.QueryRowContext(ctx, `SELECT count(*) FROM users`).Scan(&in.Users)
|
||||
if hasTable(ctx, db, FilesTable) {
|
||||
db.QueryRowContext(ctx, `SELECT count(*) FROM `+FilesTable).Scan(&in.KeyFiles)
|
||||
}
|
||||
// dnssec_policy exists from schema 3 on.
|
||||
db.QueryRowContext(ctx, `SELECT count(*) FROM zones WHERE dnssec_policy != ''`).Scan(&in.SignedZones)
|
||||
var last sql.NullString
|
||||
db.QueryRowContext(ctx, `SELECT max(applied_at) FROM changesets`).Scan(&last)
|
||||
if t, err := time.Parse(time.RFC3339Nano, last.String); err == nil {
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -26,7 +27,7 @@ func TestWriteVerifyInspect(t *testing.T) {
|
||||
src := newDB(t)
|
||||
dir := filepath.Join(t.TempDir(), "backups")
|
||||
now := time.Date(2026, 10, 8, 3, 15, 12, 0, time.UTC)
|
||||
f, err := Write(ctx, src, dir, KindJarl, now)
|
||||
f, err := Write(ctx, src, dir, KindJarl, now, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -44,7 +45,7 @@ func TestWriteVerifyInspect(t *testing.T) {
|
||||
t.Errorf("inspect: %+v %v", in, err)
|
||||
}
|
||||
// The same second again is refused rather than overwritten.
|
||||
if _, err := Write(ctx, src, dir, KindJarl, now); err == nil {
|
||||
if _, err := Write(ctx, src, dir, KindJarl, now, ""); err == nil {
|
||||
t.Error("overwrote a backup")
|
||||
}
|
||||
// No temporary files are left.
|
||||
@@ -54,7 +55,7 @@ func TestWriteVerifyInspect(t *testing.T) {
|
||||
}
|
||||
// A missing database is an error, and isn't created.
|
||||
missing := filepath.Join(t.TempDir(), "nope.db")
|
||||
if _, err := Write(ctx, missing, dir, KindJarl, now.Add(time.Second)); err == nil {
|
||||
if _, err := Write(ctx, missing, dir, KindJarl, now.Add(time.Second), ""); err == nil {
|
||||
t.Error("backed up a missing database")
|
||||
}
|
||||
if _, err := os.Stat(missing); err == nil {
|
||||
@@ -149,3 +150,52 @@ func TestPruneTempFiles(t *testing.T) {
|
||||
t.Error("a running backup's temporary file was deleted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestKeyFiles(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
src := newDB(t)
|
||||
keys := t.TempDir()
|
||||
os.MkdirAll(filepath.Join(keys, "view-3"), 0o755)
|
||||
os.WriteFile(filepath.Join(keys, "view-3", "Kexample.com.+013+12345.key"), []byte("public"), 0o644)
|
||||
os.WriteFile(filepath.Join(keys, "view-3", "Kexample.com.+013+12345.private"), []byte("private-key-material-xyz"), 0o600)
|
||||
dir := t.TempDir()
|
||||
f, err := Write(ctx, src, dir, KindJarl, time.Now(), keys)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
root, files, err := Files(ctx, f.Path)
|
||||
if err != nil || root != keys || len(files) != 2 {
|
||||
t.Fatalf("files: %q %+v %v", root, files, err)
|
||||
}
|
||||
if files[1].Path != "view-3/Kexample.com.+013+12345.private" || string(files[1].Data) != "private-key-material-xyz" || files[1].Mode != 0o600 {
|
||||
t.Errorf("private key: %+v", files[1])
|
||||
}
|
||||
if in, err := Inspect(ctx, f.Path); err != nil || in.KeyFiles != 2 {
|
||||
t.Errorf("inspect: %+v %v", in, err)
|
||||
}
|
||||
// A restored database loses the table again.
|
||||
restored := filepath.Join(t.TempDir(), "jarl.db")
|
||||
b, _ := os.ReadFile(f.Path)
|
||||
os.WriteFile(restored, b, 0o600)
|
||||
if err := DropFiles(ctx, restored); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, files, _ := Files(ctx, restored); len(files) != 0 {
|
||||
t.Error("files left after DropFiles")
|
||||
}
|
||||
if b, _ := os.ReadFile(restored); strings.Contains(string(b), "private-key-material-xyz") {
|
||||
t.Error("the private key is still in the file")
|
||||
}
|
||||
// No key directory: nothing stored. An unreadable key: an error.
|
||||
if f, err := Write(ctx, src, dir, KindJarl, time.Now().Add(time.Second), filepath.Join(keys, "missing")); err != nil {
|
||||
t.Error(err)
|
||||
} else if _, files, _ := Files(ctx, f.Path); len(files) != 0 {
|
||||
t.Errorf("files from a missing directory: %v", files)
|
||||
}
|
||||
if os.Geteuid() != 0 {
|
||||
os.Chmod(filepath.Join(keys, "view-3", "Kexample.com.+013+12345.private"), 0)
|
||||
if _, err := Write(ctx, src, dir, KindJarl, time.Now().Add(2*time.Second), keys); err == nil || !strings.Contains(err.Error(), "back up as root") {
|
||||
t.Errorf("unreadable key: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
+68
-11
@@ -223,6 +223,24 @@ func (t *Tools) ReloadZone(ctx context.Context, zone, view string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// CheckDS tells named that the parent zone has published (or withdrawn)
|
||||
// the DS record for key tag of a zone it signs (rndc dnssec -checkds).
|
||||
func (t *Tools) CheckDS(ctx context.Context, zone, view string, tag uint16, alg uint8, published bool) (string, error) {
|
||||
which := "withdrawn"
|
||||
if published {
|
||||
which = "published"
|
||||
}
|
||||
z := zoneArgs("", zone, view)[1:]
|
||||
args := append([]string{"dnssec", "-checkds", "-key", strconv.Itoa(int(tag)), "-alg", strconv.Itoa(int(alg)), which}, z...)
|
||||
return t.RNDCRun(ctx, args...)
|
||||
}
|
||||
|
||||
// ZoneCommand is the rndc command line for a zone command, as shown to
|
||||
// users ("rndc zonestatus example.org IN internal").
|
||||
func ZoneCommand(cmd, zone, view string) string {
|
||||
return "rndc " + strings.Join(zoneArgs(cmd, zone, view), " ")
|
||||
}
|
||||
|
||||
func zoneArgs(cmd, zone, view string) []string {
|
||||
z := strings.TrimSuffix(zone, ".")
|
||||
if z == "" {
|
||||
@@ -236,16 +254,32 @@ func zoneArgs(cmd, zone, view string) []string {
|
||||
|
||||
// ZoneStatus is the result of rndc zonestatus.
|
||||
type ZoneStatus struct {
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
Files string `json:"files"`
|
||||
Serial uint32 `json:"serial"`
|
||||
HasSerial bool `json:"-"`
|
||||
Nodes int `json:"nodes"`
|
||||
LastLoaded string `json:"last_loaded"`
|
||||
Secure bool `json:"secure"`
|
||||
Dynamic bool `json:"dynamic"`
|
||||
Raw string `json:"raw"`
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
Files string `json:"files"`
|
||||
// Serial is the serial of the zone file named loaded. For a zone
|
||||
// named signs (inline signing) that is the unsigned zone Jarl wrote;
|
||||
// the signed zone that named serves has its own serial, SignedSerial.
|
||||
Serial uint32 `json:"serial"`
|
||||
HasSerial bool `json:"-"`
|
||||
SignedSerial uint32 `json:"signed_serial,omitempty"`
|
||||
HasSignedSerial bool `json:"-"`
|
||||
Nodes int `json:"nodes"`
|
||||
LastLoaded string `json:"last_loaded"`
|
||||
Secure bool `json:"secure"`
|
||||
InlineSigning bool `json:"inline_signing"`
|
||||
NextKeyEvent string `json:"next_key_event,omitempty"`
|
||||
Dynamic bool `json:"dynamic"`
|
||||
Raw string `json:"raw"`
|
||||
}
|
||||
|
||||
// ServedSerial is the serial named answers queries with: the signed
|
||||
// zone's for a zone it signs, otherwise the loaded one.
|
||||
func (zs *ZoneStatus) ServedSerial() (uint32, bool) {
|
||||
if zs.HasSignedSerial {
|
||||
return zs.SignedSerial, true
|
||||
}
|
||||
return zs.Serial, zs.HasSerial
|
||||
}
|
||||
|
||||
// ZoneStatus returns named's view of a zone.
|
||||
@@ -277,6 +311,14 @@ func ParseZoneStatus(out string) *ZoneStatus {
|
||||
if n, err := strconv.ParseUint(v, 10, 32); err == nil {
|
||||
zs.Serial, zs.HasSerial = uint32(n), true
|
||||
}
|
||||
case "signed serial":
|
||||
if n, err := strconv.ParseUint(v, 10, 32); err == nil {
|
||||
zs.SignedSerial, zs.HasSignedSerial = uint32(n), true
|
||||
}
|
||||
case "inline signing":
|
||||
zs.InlineSigning = v == "yes"
|
||||
case "next key event":
|
||||
zs.NextKeyEvent = v
|
||||
case "nodes":
|
||||
zs.Nodes, _ = strconv.Atoi(v)
|
||||
case "last loaded":
|
||||
@@ -298,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.
|
||||
@@ -321,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
|
||||
}
|
||||
|
||||
@@ -17,3 +17,33 @@ zone example.com/IN/internal: loaded serial 1
|
||||
t.Errorf("exit 1: %+v", bad)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseZoneStatusSigned(t *testing.T) {
|
||||
// rndc zonestatus of a zone named signs with dnssec-policy (BIND 9.20).
|
||||
zs := ParseZoneStatus(`name: example.test
|
||||
type: primary
|
||||
files: /var/lib/bind/jarl/_default/example.test.zone
|
||||
serial: 2026100802
|
||||
signed serial: 2026100804
|
||||
nodes: 4
|
||||
last loaded: Thu, 08 Oct 2026 19:03:31 GMT
|
||||
secure: yes
|
||||
inline signing: yes
|
||||
key maintenance: automatic
|
||||
next key event: Thu, 08 Oct 2026 21:08:31 GMT
|
||||
next resign node: new.example.test/A
|
||||
next resign time: Sat, 17 Oct 2026 07:32:59 GMT
|
||||
dynamic: no
|
||||
reconfigurable via modzone: no
|
||||
`)
|
||||
if zs.Serial != 2026100802 || zs.SignedSerial != 2026100804 || !zs.Secure || !zs.InlineSigning || zs.NextKeyEvent != "Thu, 08 Oct 2026 21:08:31 GMT" {
|
||||
t.Errorf("%+v", zs)
|
||||
}
|
||||
if s, ok := zs.ServedSerial(); !ok || s != 2026100804 {
|
||||
t.Errorf("served serial %d %v", s, ok)
|
||||
}
|
||||
plain := ParseZoneStatus("name: example.com\nserial: 7\nsecure: no\n")
|
||||
if s, ok := plain.ServedSerial(); !ok || s != 7 || plain.HasSignedSerial {
|
||||
t.Errorf("unsigned: %+v", plain)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
package blocking
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParse(t *testing.T) {
|
||||
src := "\uFEFF[Adblock Plus]\r\n" + `! Title: test list
|
||||
# a comment
|
||||
|
||||
||ads.example^
|
||||
||Tracker.Example.^$important
|
||||
||cosmetic.example^$third-party
|
||||
@@||allowed.example^
|
||||
example.org##.banner
|
||||
|https://url.example/path
|
||||
0.0.0.0 hosts.example other.example # two names
|
||||
127.0.0.1 localhost
|
||||
127.0.0.1 localhost.localdomain
|
||||
::1 ip6-localhost
|
||||
0.0.0.0 0.0.0.0
|
||||
plain.example
|
||||
*.wild.example
|
||||
single
|
||||
1.2.3.4
|
||||
bad_label!.example
|
||||
münchen.example
|
||||
www.ads.example
|
||||
` + strings.Repeat("a", 64) + ".example\n"
|
||||
l, err := Parse(strings.NewReader(src), Limits{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := map[string]uint8{
|
||||
"ads.example": Exact | Below, "tracker.example": Exact | Below,
|
||||
"hosts.example": Exact, "other.example": Exact, "plain.example": Exact,
|
||||
"wild.example": Exact | Below, "xn--mnchen-3ya.example": Exact, "www.ads.example": Exact,
|
||||
}
|
||||
if len(l.Domains) != len(want) {
|
||||
t.Errorf("domains = %v", l.Domains)
|
||||
}
|
||||
for n, f := range want {
|
||||
if l.Domains[n] != f {
|
||||
t.Errorf("%s = %d, want %d", n, l.Domains[n], f)
|
||||
}
|
||||
}
|
||||
// Skipped: $third-party, @@, ##, |url, localhost (x3), 0.0.0.0, single, 1.2.3.4, bad_label!, 64-char label.
|
||||
if l.Skipped != 12 || l.Lines != 19 {
|
||||
t.Errorf("lines %d, skipped %d", l.Lines, l.Skipped)
|
||||
}
|
||||
|
||||
Compact(l)
|
||||
if _, ok := l.Domains["www.ads.example"]; ok {
|
||||
t.Error("www.ads.example is covered by ||ads.example^ and should be dropped")
|
||||
}
|
||||
var sb strings.Builder
|
||||
n, hash, err := FeedZone(&sb, "test", 7, l)
|
||||
if err != nil || n != 10 {
|
||||
t.Fatalf("FeedZone: %d records, %v", n, err)
|
||||
}
|
||||
for _, line := range []string{"ads.example\tCNAME\t.\n", "*.ads.example\tCNAME\t.\n", "plain.example\tCNAME\t.\n", "@\tSOA\tlocalhost. hostmaster.localhost. 7 "} {
|
||||
if !strings.Contains(sb.String(), line) {
|
||||
t.Errorf("zone lacks %q:\n%s", line, sb.String())
|
||||
}
|
||||
}
|
||||
if strings.Contains(sb.String(), "*.plain.example") || strings.Contains(sb.String(), "$ORIGIN") {
|
||||
t.Errorf("zone:\n%s", sb.String())
|
||||
}
|
||||
// The hash ignores the serial.
|
||||
var other strings.Builder
|
||||
if _, h2, _ := FeedZone(&other, "test", 8, l); h2 != hash {
|
||||
t.Error("hash depends on the serial")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseLimits(t *testing.T) {
|
||||
_, err := Parse(strings.NewReader("a.example\nb.example\na.example\nc.example\n"), Limits{MaxEntries: 2})
|
||||
if !errors.Is(err, ErrTooManyEntries) {
|
||||
t.Errorf("err = %v", err)
|
||||
}
|
||||
if _, err := Parse(strings.NewReader("a.example\nb.example\na.example\n"), Limits{MaxEntries: 2}); err != nil {
|
||||
t.Errorf("a repeated name counted against the limit: %v", err)
|
||||
}
|
||||
l, _ := Parse(strings.NewReader("long-name.example\nshort.example\n"), Limits{MaxNameLen: 15})
|
||||
if len(l.Domains) != 1 || l.Skipped != 1 {
|
||||
t.Errorf("max name length: %v", l.Domains)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuleEntries(t *testing.T) {
|
||||
rules := []Rule{
|
||||
{Name: "ads.example", Action: ActionBlock}, // and everything below
|
||||
{Name: "cdn.ads.example", Action: ActionAllow, Exact: true}, // just this name
|
||||
{Name: "wide.example", Action: ActionBlock}, // and below
|
||||
{Name: "ok.wide.example", Action: ActionAllow}, // and below
|
||||
{Name: "deep.x.y.wide.example", Action: ActionAllow, Exact: true}, // two empty names in between
|
||||
{Name: "solo.example", Action: ActionBlock, Exact: true},
|
||||
}
|
||||
e := RuleEntries(rules)
|
||||
for name, want := range map[string]string{
|
||||
"ads.example": ActionBlock,
|
||||
"x.ads.example": ActionBlock,
|
||||
"cdn.ads.example": ActionAllow,
|
||||
"y.cdn.ads.example": ActionBlock, // the wildcard above still applies
|
||||
"ok.wide.example": ActionAllow,
|
||||
"a.ok.wide.example": ActionAllow,
|
||||
"y.wide.example": ActionBlock, // exists only because of a deeper rule
|
||||
"x.y.wide.example": ActionBlock,
|
||||
"z.x.y.wide.example": ActionBlock,
|
||||
"deep.x.y.wide.example": ActionAllow,
|
||||
"below.deep.x.y.wide.example": ActionBlock,
|
||||
"solo.example": ActionBlock,
|
||||
"other.example": "",
|
||||
"sub.solo.example": "",
|
||||
"example": "",
|
||||
"unrelated.org": "",
|
||||
} {
|
||||
_, act, ok := MatchRules(e, name)
|
||||
if !ok {
|
||||
act = ""
|
||||
}
|
||||
if act != want {
|
||||
t.Errorf("%s: %q, want %q", name, act, want)
|
||||
}
|
||||
}
|
||||
z := RulesZoneFile("rules", rules)
|
||||
if !strings.Contains(z, "cdn.ads.example\tCNAME\trpz-passthru.\n*.cdn.ads.example\tCNAME\t.\n") {
|
||||
t.Errorf("rules zone:\n%s", z)
|
||||
}
|
||||
if strings.Index(z, "ads.example\t") > strings.Index(z, "*.ads.example\t") {
|
||||
t.Errorf("a name should come before its wildcard:\n%s", z)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatchFeed(t *testing.T) {
|
||||
l, _ := Parse(strings.NewReader("||tracker.example^\nexact.example\n||deep.a.example^\n"), Limits{})
|
||||
Compact(l)
|
||||
path := filepath.Join(t.TempDir(), "list.rpz")
|
||||
f, _ := os.Create(path)
|
||||
FeedZone(f, "t", 1, l)
|
||||
f.Close()
|
||||
for name, want := range map[string]string{
|
||||
"tracker.example": "tracker.example",
|
||||
"a.b.tracker.example": "*.tracker.example",
|
||||
"exact.example": "exact.example",
|
||||
"sub.exact.example": "",
|
||||
"x.deep.a.example": "*.deep.a.example",
|
||||
"a.example": "",
|
||||
"example": "",
|
||||
} {
|
||||
owner, ok, err := MatchFeed(path, name)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !ok {
|
||||
owner = ""
|
||||
}
|
||||
if owner != want {
|
||||
t.Errorf("%s: %q, want %q", name, owner, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNames(t *testing.T) {
|
||||
if ListZone("hagezi") != "hagezi.blocklist.jarl.invalid" || ListName("hagezi.blocklist.jarl.invalid.") != "hagezi" || ListName(RulesZone) != "" || ListName("x.y.blocklist.jarl.invalid") != "" {
|
||||
t.Error("zone names")
|
||||
}
|
||||
if n, err := NormalizeName("Ads.Example.COM."); err != nil || n != "ads.example.com" {
|
||||
t.Errorf("NormalizeName = %q, %v", n, err)
|
||||
}
|
||||
for _, bad := range []string{"com", "1.2.3.4", "a b.example", "*.example.com", ""} {
|
||||
if _, err := NormalizeName(bad); err == nil {
|
||||
t.Errorf("%q accepted", bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
// Package blocking turns blocklists and block/allow rules into BIND
|
||||
// response policy zones (RPZ), and answers which rule or list matches a
|
||||
// name.
|
||||
//
|
||||
// A policy zone holds one rule per owner name: "ads.example CNAME ."
|
||||
// answers NXDOMAIN for ads.example, "*.ads.example CNAME ." for every name
|
||||
// below it, and "CNAME rpz-passthru." answers normally (an exception).
|
||||
// named looks names up in a policy zone like in any zone, so wildcards
|
||||
// follow DNS rules: a wildcard doesn't apply below a name that exists in
|
||||
// the zone. The writers here add the rules that keep "the most specific
|
||||
// rule wins" true despite that (see RulesZone).
|
||||
package blocking
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/netip"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"jarl/internal/dnsname"
|
||||
)
|
||||
|
||||
// Flags say what a list entry covers.
|
||||
const (
|
||||
Exact uint8 = 1 << iota // the name itself
|
||||
Below // every name below it
|
||||
)
|
||||
|
||||
// List is a parsed blocklist.
|
||||
type List struct {
|
||||
// Domains maps names (lowercase, without the final dot) to flags.
|
||||
Domains map[string]uint8
|
||||
// Lines is the number of lines with an entry (not comments or blank
|
||||
// lines); Skipped how many of them were not usable.
|
||||
Lines, Skipped int
|
||||
}
|
||||
|
||||
// Limits bound what Parse accepts.
|
||||
type Limits struct {
|
||||
MaxEntries int // distinct domains (0: no limit)
|
||||
MaxNameLen int // longest name in characters (0: 253)
|
||||
}
|
||||
|
||||
// ErrTooManyEntries is returned when a list has more than MaxEntries
|
||||
// domains.
|
||||
var ErrTooManyEntries = errors.New("too many entries")
|
||||
|
||||
// Parse reads a blocklist. Each line is one of:
|
||||
//
|
||||
// ||ads.example^ Adblock style: the name and everything below it
|
||||
// *.ads.example the name and everything below it
|
||||
// 0.0.0.0 ads.example hosts file (any address; several names allowed)
|
||||
// ads.example just this name
|
||||
//
|
||||
// Comments (#, !), Adblock headers ([Adblock Plus]) and blank lines are
|
||||
// ignored. Lines that can't be used for DNS blocking (exceptions,
|
||||
// cosmetic and URL rules, addresses, single-label names, invalid names)
|
||||
// are counted as skipped.
|
||||
func Parse(r io.Reader, lim Limits) (*List, error) {
|
||||
maxLen := lim.MaxNameLen
|
||||
if maxLen <= 0 || maxLen > 253 {
|
||||
maxLen = 253
|
||||
}
|
||||
l := &List{Domains: map[string]uint8{}}
|
||||
full := false // a new name was refused because of MaxEntries
|
||||
add := func(name string, flags uint8) bool {
|
||||
name, ok := normalize(name, maxLen)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
if _, seen := l.Domains[name]; !seen && lim.MaxEntries > 0 && len(l.Domains) >= lim.MaxEntries {
|
||||
full = true
|
||||
return false
|
||||
}
|
||||
l.Domains[name] |= flags
|
||||
return true
|
||||
}
|
||||
sc := bufio.NewScanner(r)
|
||||
sc.Buffer(make([]byte, 64*1024), 1024*1024)
|
||||
first := true
|
||||
for sc.Scan() {
|
||||
line := sc.Text()
|
||||
if first {
|
||||
line = strings.TrimPrefix(line, "\uFEFF")
|
||||
first = false
|
||||
}
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" || line[0] == '#' || line[0] == '!' || line[0] == '[' {
|
||||
continue
|
||||
}
|
||||
l.Lines++
|
||||
ok := false
|
||||
switch {
|
||||
case strings.HasPrefix(line, "||"):
|
||||
ok = add(adblockDomain(line[2:]), Exact|Below)
|
||||
case strings.ContainsAny(line, "|/$^@") || strings.Contains(line, "##"):
|
||||
// Exceptions (@@), URL and cosmetic rules: not for DNS.
|
||||
default:
|
||||
if i := strings.Index(line, "#"); i >= 0 {
|
||||
line = strings.TrimSpace(line[:i])
|
||||
}
|
||||
fields := strings.Fields(line)
|
||||
switch {
|
||||
case len(fields) == 1 && strings.HasPrefix(fields[0], "*."):
|
||||
ok = add(fields[0][2:], Exact|Below)
|
||||
case len(fields) == 1:
|
||||
ok = add(fields[0], Exact)
|
||||
case len(fields) > 1 && isAddr(fields[0]):
|
||||
for _, f := range fields[1:] {
|
||||
if add(f, Exact) {
|
||||
ok = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if full {
|
||||
return l, fmt.Errorf("%w: the list has more than %d domains", ErrTooManyEntries, lim.MaxEntries)
|
||||
}
|
||||
if !ok {
|
||||
l.Skipped++
|
||||
}
|
||||
}
|
||||
if err := sc.Err(); err != nil {
|
||||
return l, err
|
||||
}
|
||||
return l, nil
|
||||
}
|
||||
|
||||
// adblockDomain returns the domain of an Adblock rule's body
|
||||
// ("ads.example^" or "ads.example^$important"), or "" when the rule
|
||||
// does more than block a domain (other modifiers, paths, wildcards).
|
||||
func adblockDomain(s string) string {
|
||||
d, rest, _ := strings.Cut(s, "^")
|
||||
if rest != "" && rest != "$important" {
|
||||
return ""
|
||||
}
|
||||
if strings.ContainsAny(d, "*/:|$") {
|
||||
return ""
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
func isAddr(s string) bool {
|
||||
_, err := netip.ParseAddr(s)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// localNames are hosts-file names that aren't blocklist entries.
|
||||
var localNames = map[string]bool{"localhost.localdomain": true, "ip6-localhost": true, "ip6-loopback": true}
|
||||
|
||||
// normalize lowercases a name, removes the final dot, converts
|
||||
// international names to their ASCII form, and checks it is usable: at
|
||||
// least two labels of letters, digits, hyphens and underscores, and not an
|
||||
// address.
|
||||
func normalize(name string, maxLen int) (string, bool) {
|
||||
name = strings.TrimSuffix(strings.ToLower(strings.TrimSpace(name)), ".")
|
||||
if name == "" || localNames[name] || isAddr(name) {
|
||||
return "", false
|
||||
}
|
||||
for i := 0; i < len(name); i++ {
|
||||
if name[i] >= utf8.RuneSelf {
|
||||
a, err := dnsname.ToASCII(name)
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
name = strings.TrimSuffix(strings.ToLower(a), ".")
|
||||
break
|
||||
}
|
||||
}
|
||||
if len(name) > maxLen || !strings.Contains(name, ".") {
|
||||
return "", false
|
||||
}
|
||||
for _, label := range strings.Split(name, ".") {
|
||||
if label == "" || len(label) > 63 {
|
||||
return "", false
|
||||
}
|
||||
for i := 0; i < len(label); i++ {
|
||||
c := label[i]
|
||||
if !(c >= 'a' && c <= 'z' || c >= '0' && c <= '9' || c == '-' || c == '_') {
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
}
|
||||
return name, true
|
||||
}
|
||||
|
||||
// NormalizeName checks and normalizes a domain the way list entries are
|
||||
// (for rules entered by hand). The name must have at least two labels.
|
||||
func NormalizeName(name string) (string, error) {
|
||||
n, ok := normalize(name, 253-len(RulesZone)-2)
|
||||
if !ok {
|
||||
return "", fmt.Errorf("%q is not a domain name that can be blocked (it needs at least two labels of letters, digits, hyphens or underscores, like ads.example.com)", name)
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
@@ -0,0 +1,313 @@
|
||||
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
|
||||
}
|
||||
|
||||
// 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()
|
||||
}
|
||||
@@ -102,13 +102,25 @@ var managed = map[string]map[string]string{
|
||||
"match-recursive-only": "the view's match-recursive-only field",
|
||||
"key": "TSIG keys (define keys at the top level)",
|
||||
"zone": "zones",
|
||||
"dnssec-policy": dnssecManaged,
|
||||
},
|
||||
ScopeOptions: {
|
||||
"dnssec-policy": dnssecManaged,
|
||||
},
|
||||
}
|
||||
|
||||
// dnssecManaged: Jarl turns signing on and off per zone, so that it can
|
||||
// guard the steps (a zone mustn't go unsigned while the parent zone still
|
||||
// has a DS record for it). dnssec-policy statements that define custom
|
||||
// policies (at the top level) are still options.
|
||||
const dnssecManaged = "each zone's DNSSEC setting (jarl zone dnssec enable ZONE; see docs/dnssec.md)"
|
||||
|
||||
var zoneManaged = map[string]string{
|
||||
"type": "the zone's type field",
|
||||
"file": "the zone's file (generated by Jarl)",
|
||||
"in-view": "the zone's in-view field",
|
||||
"type": "the zone's type field",
|
||||
"file": "the zone's file (generated by Jarl)",
|
||||
"in-view": "the zone's in-view field",
|
||||
"dnssec-policy": dnssecManaged,
|
||||
"key-directory": "Jarl (signed zones keep their keys in Jarl's key directory, which its backups include)",
|
||||
}
|
||||
|
||||
// synonyms are older spellings that named 9.20 still accepts.
|
||||
@@ -415,3 +427,80 @@ 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 "port", "tls":
|
||||
if len(out) > 0 && i+1 < len(fields) {
|
||||
out[len(out)-1] += " " + f + " " + fields[i+1]
|
||||
i++
|
||||
continue
|
||||
}
|
||||
}
|
||||
out = append(out, f)
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -77,3 +77,28 @@ func TestCatalog(t *testing.T) {
|
||||
t.Errorf("zone types = %s", types)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -183,7 +183,7 @@ func (c *Client) Do(ctx context.Context, method, path string, query url.Values,
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.Header.Set("User-Agent", "jarl-cli/"+version.Version)
|
||||
req.Header.Set("User-Agent", "jarl-cli/"+version.Short())
|
||||
if c.Token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+c.Token)
|
||||
}
|
||||
|
||||
+173
-18
@@ -5,6 +5,7 @@ import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"net/netip"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
@@ -24,16 +25,94 @@ 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"`
|
||||
BIND BIND `yaml:"bind"`
|
||||
Audit Audit `yaml:"audit"`
|
||||
Backup Backup `yaml:"backup"`
|
||||
Stats Stats `yaml:"stats"`
|
||||
ACME ACME `yaml:"acme"`
|
||||
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"`
|
||||
}
|
||||
|
||||
// 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
|
||||
// DS records at the registrar Porkbun (docs/dnssec.md#porkbun). The key
|
||||
// lives only here, in a file root and jarld can read, and never in the
|
||||
// database (so not in backups either).
|
||||
type Porkbun struct {
|
||||
APIKey string `yaml:"api_key"`
|
||||
SecretAPIKey string `yaml:"secret_api_key"`
|
||||
// Domains limits which domains Jarl may manage at Porkbun (default:
|
||||
// every Jarl zone the key can reach).
|
||||
Domains []string `yaml:"domains"`
|
||||
// APIURL is the API's base URL (for tests).
|
||||
APIURL string `yaml:"api_url"`
|
||||
}
|
||||
|
||||
// Enabled reports whether a Porkbun key is configured.
|
||||
func (p *Porkbun) Enabled() bool { return p.APIKey != "" }
|
||||
|
||||
// Allows reports whether Jarl may manage domain (no trailing dot) at
|
||||
// Porkbun.
|
||||
func (p *Porkbun) Allows(domain string) bool {
|
||||
if !p.Enabled() {
|
||||
return false
|
||||
}
|
||||
if len(p.Domains) == 0 {
|
||||
return true
|
||||
}
|
||||
for _, d := range p.Domains {
|
||||
if strings.EqualFold(strings.TrimSuffix(d, "."), domain) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (p *Porkbun) check() error {
|
||||
if (p.APIKey == "") != (p.SecretAPIKey == "") {
|
||||
return fmt.Errorf("set both api_key and secret_api_key (or neither)")
|
||||
}
|
||||
if p.APIURL != "" {
|
||||
u, err := url.Parse(p.APIURL)
|
||||
if err != nil || (u.Scheme != "https" && !(u.Scheme == "http" && isLoopbackHost(u.Hostname()))) {
|
||||
return fmt.Errorf("api_url must be an https:// URL")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func isLoopbackHost(h string) bool {
|
||||
if h == "localhost" {
|
||||
return true
|
||||
}
|
||||
a, err := netip.ParseAddr(h)
|
||||
return err == nil && a.IsLoopback()
|
||||
}
|
||||
|
||||
// Backup configures 'jarld backup' run without a file name, which is what
|
||||
@@ -93,12 +172,37 @@ type Session struct {
|
||||
}
|
||||
|
||||
// BIND says where BIND's files and tools are.
|
||||
// TOTP configures two-factor login to the web UI (docs/permissions.md).
|
||||
type TOTP struct {
|
||||
// Require is who must use it: none (each user decides), admins or
|
||||
// all (every user who logs in with a password).
|
||||
Require string `yaml:"require"`
|
||||
// Issuer is the name authenticator apps show for the account ("" is
|
||||
// "Jarl <hostname>").
|
||||
Issuer string `yaml:"issuer"`
|
||||
}
|
||||
|
||||
// IssuerName is Issuer, or "Jarl <short hostname>".
|
||||
func (t TOTP) IssuerName() string {
|
||||
if t.Issuer != "" {
|
||||
return t.Issuer
|
||||
}
|
||||
h, _ := os.Hostname()
|
||||
if h, _, _ = strings.Cut(h, "."); h == "" {
|
||||
return "Jarl"
|
||||
}
|
||||
return "Jarl " + h
|
||||
}
|
||||
|
||||
type BIND struct {
|
||||
MainConfig string `yaml:"main_config"`
|
||||
ConfigDir string `yaml:"config_dir"`
|
||||
ZoneDir string `yaml:"zone_dir"`
|
||||
CacheDir string `yaml:"cache_dir"`
|
||||
FileGroup string `yaml:"file_group"`
|
||||
MainConfig string `yaml:"main_config"`
|
||||
ConfigDir string `yaml:"config_dir"`
|
||||
ZoneDir string `yaml:"zone_dir"`
|
||||
CacheDir string `yaml:"cache_dir"`
|
||||
FileGroup string `yaml:"file_group"`
|
||||
// NamedUser is the user named runs as. 'jarld restore' gives it the
|
||||
// DNSSEC keys it puts back.
|
||||
NamedUser string `yaml:"named_user"`
|
||||
NamedCheckConf string `yaml:"named_checkconf"`
|
||||
NamedCheckZone string `yaml:"named_checkzone"`
|
||||
RNDC string `yaml:"rndc"`
|
||||
@@ -194,12 +298,14 @@ func Default() *Config {
|
||||
StagingDir: "/var/lib/jarl/staging",
|
||||
Log: Log{Level: "info", Format: "text"},
|
||||
Session: Session{Lifetime: Duration(12 * time.Hour), IdleTimeout: Duration(2 * time.Hour), CookieSecure: "auto"},
|
||||
TOTP: TOTP{Require: "none"},
|
||||
BIND: BIND{
|
||||
MainConfig: "/etc/bind/named.conf",
|
||||
ConfigDir: "/etc/bind/jarl",
|
||||
ZoneDir: "/var/lib/bind/jarl",
|
||||
CacheDir: "/var/cache/bind/jarl",
|
||||
FileGroup: "bind",
|
||||
NamedUser: "bind",
|
||||
NamedCheckConf: "/usr/bin/named-checkconf",
|
||||
NamedCheckZone: "/usr/bin/named-checkzone",
|
||||
RNDC: "/usr/sbin/rndc",
|
||||
@@ -215,8 +321,9 @@ 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},
|
||||
Backup: Backup{Dir: "/var/backups/jarl", Retention: Duration(30 * 24 * time.Hour), KeepAtLeast: 7, WarnAfter: Duration(2 * 24 * time.Hour)},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -267,12 +374,26 @@ func (c *Config) check() error {
|
||||
default:
|
||||
return fmt.Errorf("session.cookie_secure must be auto, always or never")
|
||||
}
|
||||
switch c.TOTP.Require {
|
||||
case "none", "admins", "all":
|
||||
default:
|
||||
return fmt.Errorf("totp.require must be none, admins or all")
|
||||
}
|
||||
if len(c.TOTP.Issuer) > 64 || strings.ContainsAny(c.TOTP.Issuer, ":") {
|
||||
return fmt.Errorf("totp.issuer must be at most 64 characters, without ':'")
|
||||
}
|
||||
if err := c.Stats.check(); err != nil {
|
||||
return fmt.Errorf("stats: %w", err)
|
||||
}
|
||||
if err := c.Backup.check(); err != nil {
|
||||
return fmt.Errorf("backup: %w", err)
|
||||
}
|
||||
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)
|
||||
}
|
||||
switch c.Log.Format {
|
||||
case "text", "json":
|
||||
default:
|
||||
@@ -404,6 +525,15 @@ session:
|
||||
idle_timeout: 2h # ...or after this long without activity
|
||||
cookie_secure: auto # auto, always or never
|
||||
|
||||
# Two-factor login to the web UI with an authenticator app (TOTP). Each
|
||||
# user can turn it on under Account. require makes it compulsory: none,
|
||||
# admins, or all (every user with a password). A user who must use it and
|
||||
# hasn't set it up can only set it up after logging in. API tokens and
|
||||
# the local socket are not affected. See docs/permissions.md.
|
||||
totp:
|
||||
require: none
|
||||
issuer: "" # the name in the app; "" is "Jarl <hostname>"
|
||||
|
||||
bind:
|
||||
# The file named loads. 'jarld adopt' makes it include Jarl's config.
|
||||
main_config: /etc/bind/named.conf
|
||||
@@ -415,6 +545,8 @@ bind:
|
||||
cache_dir: /var/cache/bind/jarl
|
||||
# Group of generated files; named must be able to read them.
|
||||
file_group: bind
|
||||
# The user named runs as ('jarld restore' gives it the DNSSEC keys back).
|
||||
named_user: bind
|
||||
named_checkconf: /usr/bin/named-checkconf
|
||||
named_checkzone: /usr/bin/named-checkzone
|
||||
rndc: /usr/sbin/rndc
|
||||
@@ -434,7 +566,9 @@ audit:
|
||||
# 'systemctl edit jarl-backup.timer'. Each run writes
|
||||
# DIR/jarl-<UTC time>.db, checks it, then deletes backups older than
|
||||
# 'retention', always keeping the newest 'keep_at_least'. Only files named
|
||||
# like that are ever deleted. Restore with 'jarld restore'.
|
||||
# like that are ever deleted. Each backup also holds the DNSSEC keys of
|
||||
# signed zones (private keys included), so keep the directory private.
|
||||
# Restore with 'jarld restore'.
|
||||
backup:
|
||||
dir: /var/backups/jarl # the timer's sandbox may write only here (see the docs to move it)
|
||||
retention: 30d # 0 keeps every backup
|
||||
@@ -478,4 +612,25 @@ acme:
|
||||
# this server. Leave false if a reverse proxy runs on this server but
|
||||
# isn't in trusted_proxies: its requests would look local.
|
||||
allow_loopback_http: false
|
||||
|
||||
# The registrar Porkbun (docs/dnssec.md#porkbun): with an API key, Jarl can
|
||||
# show and set a domain's name servers, glue records and DNSSEC DS records
|
||||
# there, after checking that the change won't break the domain. Off without
|
||||
# a key. Create the key at porkbun.com (Account -> API Access), switch on
|
||||
# "API Access" for the domain, and limit the key to that domain if you can.
|
||||
# The key can move your domain elsewhere: it lives only in this file (keep
|
||||
# it readable by root and jarld only: chown root:jarl, chmod 640), never in
|
||||
# Jarl's database or backups.
|
||||
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
|
||||
`
|
||||
@@ -0,0 +1,320 @@
|
||||
// Package dnssec reads the DNSSEC keys named keeps for the zones it signs
|
||||
// (dnssec-policy with inline signing) and works out where a zone is in the
|
||||
// steps of turning signing on or off.
|
||||
//
|
||||
// named writes three files per key into the zone's key-directory:
|
||||
// K<zone>.+<alg>+<tag>.key (the public DNSKEY, world-readable),
|
||||
// .private (the private key, readable only by named's user) and .state
|
||||
// (the key's timing and state, see the dnssec-keymgr states in the BIND
|
||||
// ARM). Jarl only reads the .key and .state files.
|
||||
package dnssec
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/miekg/dns"
|
||||
)
|
||||
|
||||
// Key states, as named writes them in .state files.
|
||||
const (
|
||||
Hidden = "hidden" // not in the zone (or the parent)
|
||||
Rumoured = "rumoured" // published, but caches may not have it yet
|
||||
Omnipresent = "omnipresent" // published long enough that everyone has it
|
||||
Unretentive = "unretentive" // removed, but caches may still have it
|
||||
)
|
||||
|
||||
// Status describes how a zone is signed.
|
||||
type Status struct {
|
||||
Policy string `json:"policy"`
|
||||
// KeyDir is the zone's key-directory.
|
||||
KeyDir string `json:"key_dir"`
|
||||
Keys []*Key `json:"keys"`
|
||||
Phase Phase `json:"phase"`
|
||||
Named *NamedSigning `json:"named,omitempty"`
|
||||
// NamedError says why named couldn't be asked.
|
||||
NamedError string `json:"named_error,omitempty"`
|
||||
// KeyError says why the key directory couldn't be read.
|
||||
KeyError string `json:"key_error,omitempty"`
|
||||
// Policies are the policies the zone could use: default and the
|
||||
// custom dnssec-policy statements.
|
||||
Policies []string `json:"policies"`
|
||||
}
|
||||
|
||||
// NamedSigning is what named reports about a zone it signs.
|
||||
type NamedSigning struct {
|
||||
Secure bool `json:"secure"`
|
||||
InlineSigning bool `json:"inline_signing"`
|
||||
Serial uint32 `json:"serial"`
|
||||
SignedSerial uint32 `json:"signed_serial,omitempty"`
|
||||
NextKeyEvent string `json:"next_key_event,omitempty"`
|
||||
}
|
||||
|
||||
// DS is a DS record for a key: what the parent zone publishes to vouch
|
||||
// for it.
|
||||
type DS struct {
|
||||
KeyTag uint16 `json:"key_tag"`
|
||||
Algorithm uint8 `json:"algorithm"`
|
||||
DigestType uint8 `json:"digest_type"`
|
||||
Digest string `json:"digest"`
|
||||
// Record is the DS record in zone file format.
|
||||
Record string `json:"record"`
|
||||
}
|
||||
|
||||
// Key is one DNSSEC key of a zone.
|
||||
type Key struct {
|
||||
Tag uint16 `json:"tag"`
|
||||
Algorithm uint8 `json:"algorithm"`
|
||||
AlgorithmName string `json:"algorithm_name"`
|
||||
// Role is CSK (signs everything: BIND's default policy), KSK (signs
|
||||
// the DNSKEY set; the parent's DS points at it) or ZSK.
|
||||
Role string `json:"role"`
|
||||
// DNSKEY is the public key record, and DS its DS record (KSK and CSK
|
||||
// only).
|
||||
DNSKEY string `json:"dnskey,omitempty"`
|
||||
DS *DS `json:"ds,omitempty"`
|
||||
// States from the .state file.
|
||||
Goal string `json:"goal,omitempty"`
|
||||
DNSKEYState string `json:"dnskey_state,omitempty"`
|
||||
DSState string `json:"ds_state,omitempty"`
|
||||
ZoneRRSIG string `json:"zone_rrsig_state,omitempty"`
|
||||
KeyRRSIG string `json:"key_rrsig_state,omitempty"`
|
||||
// Times from the .state file.
|
||||
Created *time.Time `json:"created,omitempty"`
|
||||
Published *time.Time `json:"published,omitempty"`
|
||||
Active *time.Time `json:"active,omitempty"`
|
||||
Retired *time.Time `json:"retired,omitempty"`
|
||||
Removed *time.Time `json:"removed,omitempty"`
|
||||
// PublishCDS is when named publishes CDS/CDNSKEY records: from then on
|
||||
// the DS can safely be added at the parent.
|
||||
PublishCDS *time.Time `json:"publish_cds,omitempty"`
|
||||
// DSPublished and DSRemoved are when named was told (or saw) that the
|
||||
// parent added or removed the DS.
|
||||
DSPublished *time.Time `json:"ds_published,omitempty"`
|
||||
DSRemoved *time.Time `json:"ds_removed,omitempty"`
|
||||
// Files are the key's file names in the key directory.
|
||||
Files []string `json:"files"`
|
||||
}
|
||||
|
||||
// SigningKey reports whether the parent's DS should point at the key.
|
||||
func (k *Key) SigningKey() bool { return k.Role == "CSK" || k.Role == "KSK" }
|
||||
|
||||
// Gone reports whether named has finished removing the key: every state
|
||||
// is hidden.
|
||||
func (k *Key) Gone() bool {
|
||||
for _, s := range []string{k.Goal, k.DNSKEYState, k.DSState, k.ZoneRRSIG, k.KeyRRSIG} {
|
||||
if s != "" && s != Hidden {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// FilePrefix is how named's key files for zone (a name with the trailing
|
||||
// dot) begin.
|
||||
func FilePrefix(zone string) string { return "K" + strings.ToLower(zone) + "+" }
|
||||
|
||||
// Files lists the names of zone's key files in dir (every .key, .private
|
||||
// and .state file, and any other file named with the zone's key prefix).
|
||||
// A missing directory has none.
|
||||
func Files(dir, zone string) ([]string, error) {
|
||||
ents, err := os.ReadDir(dir)
|
||||
if os.IsNotExist(err) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
prefix := FilePrefix(zone)
|
||||
var out []string
|
||||
for _, e := range ents {
|
||||
if !e.IsDir() && strings.HasPrefix(strings.ToLower(e.Name()), strings.ToLower(prefix)) {
|
||||
out = append(out, e.Name())
|
||||
}
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Keys reads zone's keys from dir, ordered with the signing keys first.
|
||||
func Keys(dir, zone string) ([]*Key, error) {
|
||||
files, err := Files(dir, zone)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
byBase := map[string]*Key{}
|
||||
var order []string
|
||||
for _, f := range files {
|
||||
ext := filepath.Ext(f)
|
||||
base := strings.TrimSuffix(f, ext)
|
||||
k := byBase[base]
|
||||
if k == nil {
|
||||
k = &Key{}
|
||||
byBase[base] = k
|
||||
order = append(order, base)
|
||||
}
|
||||
k.Files = append(k.Files, f)
|
||||
switch ext {
|
||||
case ".key":
|
||||
if err := k.readPublic(filepath.Join(dir, f)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
case ".state":
|
||||
if err := k.readState(filepath.Join(dir, f)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
var out []*Key
|
||||
for _, b := range order {
|
||||
k := byBase[b]
|
||||
if k.DNSKEY == "" && k.Goal == "" {
|
||||
continue // only a .private file: nothing to show
|
||||
}
|
||||
out = append(out, k)
|
||||
}
|
||||
sort.SliceStable(out, func(i, j int) bool {
|
||||
if out[i].SigningKey() != out[j].SigningKey() {
|
||||
return out[i].SigningKey()
|
||||
}
|
||||
return out[i].Tag < out[j].Tag
|
||||
})
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (k *Key) readPublic(path string) error {
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
zp := dns.NewZoneParser(strings.NewReader(string(b)), "", path)
|
||||
for rr, ok := zp.Next(); ok; rr, ok = zp.Next() {
|
||||
key, isKey := rr.(*dns.DNSKEY)
|
||||
if !isKey {
|
||||
continue
|
||||
}
|
||||
k.Tag = key.KeyTag()
|
||||
k.Algorithm = key.Algorithm
|
||||
k.AlgorithmName = dns.AlgorithmToString[key.Algorithm]
|
||||
k.DNSKEY = key.String()
|
||||
if k.Role == "" {
|
||||
switch {
|
||||
case key.Flags&dns.SEP != 0:
|
||||
k.Role = "KSK"
|
||||
default:
|
||||
k.Role = "ZSK"
|
||||
}
|
||||
}
|
||||
if key.Flags&dns.SEP != 0 {
|
||||
if ds := key.ToDS(dns.SHA256); ds != nil {
|
||||
k.DS = &DS{KeyTag: ds.KeyTag, Algorithm: ds.Algorithm, DigestType: ds.DigestType, Digest: strings.ToUpper(ds.Digest), Record: ds.String()}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if err := zp.Err(); err != nil {
|
||||
return fmt.Errorf("%s: %w", path, err)
|
||||
}
|
||||
return fmt.Errorf("%s: no DNSKEY record", path)
|
||||
}
|
||||
|
||||
func (k *Key) readState(path string) error {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
var ksk, zsk bool
|
||||
sc := bufio.NewScanner(f)
|
||||
for sc.Scan() {
|
||||
line := sc.Text()
|
||||
if strings.HasPrefix(line, ";") {
|
||||
continue
|
||||
}
|
||||
name, v, ok := strings.Cut(line, ":")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
v = strings.TrimSpace(v)
|
||||
t := func() *time.Time {
|
||||
// 20261008190331 (Thu Oct 8 19:03:31 2026)
|
||||
f := strings.Fields(v)
|
||||
if len(f) == 0 {
|
||||
return nil
|
||||
}
|
||||
ts, err := time.Parse("20060102150405", f[0])
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return &ts
|
||||
}
|
||||
switch name {
|
||||
case "Algorithm":
|
||||
if n, err := strconv.Atoi(v); err == nil && k.Algorithm == 0 {
|
||||
k.Algorithm = uint8(n)
|
||||
k.AlgorithmName = dns.AlgorithmToString[uint8(n)]
|
||||
}
|
||||
case "KSK":
|
||||
ksk = v == "yes"
|
||||
case "ZSK":
|
||||
zsk = v == "yes"
|
||||
case "Generated":
|
||||
k.Created = t()
|
||||
case "Published":
|
||||
k.Published = t()
|
||||
case "Active":
|
||||
k.Active = t()
|
||||
case "Retired":
|
||||
k.Retired = t()
|
||||
case "Removed":
|
||||
k.Removed = t()
|
||||
case "PublishCDS":
|
||||
k.PublishCDS = t()
|
||||
case "DSPublish":
|
||||
k.DSPublished = t()
|
||||
case "DSRemoved":
|
||||
k.DSRemoved = t()
|
||||
case "GoalState":
|
||||
k.Goal = v
|
||||
case "DNSKEYState":
|
||||
k.DNSKEYState = v
|
||||
case "DSState":
|
||||
k.DSState = v
|
||||
case "ZRRSIGState":
|
||||
k.ZoneRRSIG = v
|
||||
case "KRRSIGState":
|
||||
k.KeyRRSIG = v
|
||||
}
|
||||
}
|
||||
switch {
|
||||
case ksk && zsk:
|
||||
k.Role = "CSK"
|
||||
case ksk:
|
||||
k.Role = "KSK"
|
||||
case zsk:
|
||||
k.Role = "ZSK"
|
||||
}
|
||||
return sc.Err()
|
||||
}
|
||||
|
||||
// ZoneFileSuffixes are the files named keeps next to a zone file: the
|
||||
// journal of the zone Jarl wrote (.jnl, and .jbk while it rewrites it) and,
|
||||
// for a signed zone, the signed copy and its journal.
|
||||
var ZoneFileSuffixes = []string{".jnl", ".jbk", ".signed", ".signed.jnl", ".signed.jbk"}
|
||||
|
||||
// DerivedFiles returns the files named made next to zoneFile that exist.
|
||||
func DerivedFiles(zoneFile string) []string {
|
||||
var out []string
|
||||
for _, s := range ZoneFileSuffixes {
|
||||
if _, err := os.Lstat(zoneFile + s); err == nil {
|
||||
out = append(out, zoneFile+s)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
package dnssec
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// testdata holds key files written by named 9.20 with dnssec-policy:
|
||||
// example.test just signed (default policy), fast.test turned insecure
|
||||
// after its DS was removed, fast2.test turned insecure before any DS.
|
||||
|
||||
func TestKeys(t *testing.T) {
|
||||
keys, err := Keys("testdata", "example.test.")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(keys) != 1 {
|
||||
t.Fatalf("%d keys", len(keys))
|
||||
}
|
||||
k := keys[0]
|
||||
if k.Tag != 22642 || k.Role != "CSK" || k.Algorithm != 13 || k.AlgorithmName != "ECDSAP256SHA256" || len(k.Files) != 3 {
|
||||
t.Errorf("key: %+v", k)
|
||||
}
|
||||
if k.DS == nil || k.DS.KeyTag != 22642 || k.DS.DigestType != 2 || len(k.DS.Digest) != 64 || !strings.HasPrefix(k.DS.Record, "example.test.\t3600\tIN\tDS\t22642 13 2 ") {
|
||||
t.Errorf("DS: %+v", k.DS)
|
||||
}
|
||||
if k.DNSKEYState != Rumoured || k.Goal != Omnipresent || k.PublishCDS == nil || k.PublishCDS.Format("2006-01-02 15:04") != "2026-10-09 19:08" {
|
||||
t.Errorf("state: %+v", k)
|
||||
}
|
||||
// "fast.test." mustn't pick up fast2.test's keys, nor "test." anyone's.
|
||||
if keys, _ := Keys("testdata", "fast.test."); len(keys) != 1 || keys[0].Tag != 642 {
|
||||
t.Errorf("fast.test: %+v", keys)
|
||||
}
|
||||
if files, _ := Files("testdata", "test."); len(files) != 0 {
|
||||
t.Errorf("test.: %v", files)
|
||||
}
|
||||
if files, _ := Files("no-such-dir", "test."); files != nil {
|
||||
t.Errorf("missing dir: %v", files)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAssess(t *testing.T) {
|
||||
load := func(zone string) []*Key {
|
||||
keys, err := Keys("testdata", zone)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return keys
|
||||
}
|
||||
check := func(name string, p Phase, code string) {
|
||||
t.Helper()
|
||||
if p.Code != code {
|
||||
t.Errorf("%s: %s (%s), want %s", name, p.Code, p.Summary, code)
|
||||
}
|
||||
}
|
||||
check("unsigned", Assess("", nil), PhaseOff)
|
||||
check("left over", Assess("", load("example.test.")), PhaseLeftover)
|
||||
check("no keys yet", Assess("default", nil), PhaseStarting)
|
||||
p := Assess("default", load("example.test."))
|
||||
check("new", p, PhaseSigning)
|
||||
if p.CanPublishDS || p.DSReadyAt == nil {
|
||||
t.Errorf("new: %+v", p)
|
||||
}
|
||||
check("DS removed", Assess("insecure", load("fast.test.")), PhaseDSExpiring)
|
||||
if Assess("insecure", load("fast.test.")).CanFinish {
|
||||
t.Error("DNSSEC mustn't be switched off while resolvers may still have the DS cached")
|
||||
}
|
||||
p = Assess("insecure", load("fast2.test."))
|
||||
check("no DS ever", p, PhaseRemovingKeys)
|
||||
if !p.CanFinish {
|
||||
t.Error("no DS: switching off is safe")
|
||||
}
|
||||
check("insecure, no keys", Assess("insecure", nil), PhaseUnsigned)
|
||||
|
||||
// The rest of the life cycle, from edited state files.
|
||||
edit := func(zone string, repl ...string) []*Key {
|
||||
dir := t.TempDir()
|
||||
files, _ := Files("testdata", zone)
|
||||
for _, f := range files {
|
||||
b, _ := os.ReadFile(filepath.Join("testdata", f))
|
||||
s := strings.NewReplacer(repl...).Replace(string(b))
|
||||
os.WriteFile(filepath.Join(dir, f), []byte(s), 0o644)
|
||||
}
|
||||
keys, err := Keys(dir, zone)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return keys
|
||||
}
|
||||
ready := edit("example.test.", "DNSKEYState: rumoured", "DNSKEYState: omnipresent", "KRRSIGState: rumoured", "KRRSIGState: omnipresent", "DSState: hidden", "DSState: rumoured")
|
||||
p = Assess("default", ready)
|
||||
check("ready", p, PhaseReady)
|
||||
if !p.CanPublishDS || len(p.NeedsDS) != 1 || p.NeedsDS[0] != 22642 {
|
||||
t.Errorf("ready: %+v", p)
|
||||
}
|
||||
check("DS added", Assess("default", edit("example.test.", "DNSKEYState: rumoured", "DNSKEYState: omnipresent", "KRRSIGState: rumoured", "KRRSIGState: omnipresent", "DSChange:", "DSPublish: 20261010000000\nDSChange:")), PhaseDSPending)
|
||||
check("secure", Assess("default", edit("example.test.", "DNSKEYState: rumoured", "DNSKEYState: omnipresent", "KRRSIGState: rumoured", "KRRSIGState: omnipresent", "DSState: hidden", "DSState: omnipresent")), PhaseSecure)
|
||||
p = Assess("insecure", edit("fast.test.", "DSRemoved:", "XDSRemoved:"))
|
||||
check("turning off with the DS still there", p, PhaseRemovingDS)
|
||||
if p.CanFinish {
|
||||
t.Error("the DS is still at the parent")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDerivedFiles(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
zf := filepath.Join(dir, "example.com.zone")
|
||||
for _, f := range []string{zf, zf + ".jnl", zf + ".signed", zf + ".signed.jnl", zf + ".other"} {
|
||||
os.WriteFile(f, nil, 0o644)
|
||||
}
|
||||
got := DerivedFiles(zf)
|
||||
if len(got) != 3 {
|
||||
t.Errorf("%v", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
package dnssec
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Phases of a zone, from unsigned to signed and back. Turning signing on:
|
||||
// off -> starting -> signing -> ready (add the DS at the parent) ->
|
||||
// ds_pending -> secure. Turning it off: secure -> removing_ds (remove the
|
||||
// DS at the parent) -> ds_expiring -> removing_keys -> unsigned -> off.
|
||||
const (
|
||||
PhaseOff = "off" // not signed, no keys
|
||||
PhaseLeftover = "leftover_keys" // not signed, but old keys are still in the key directory
|
||||
PhaseStarting = "starting" // signing is on in Jarl; named hasn't made keys yet
|
||||
PhaseSigning = "signing" // keys made, zone signed; not yet safe to add the DS
|
||||
PhaseReady = "ready" // safe to add the DS at the parent
|
||||
PhaseDSPending = "ds_pending" // the DS is at the parent; named waits for its TTL
|
||||
PhaseSecure = "secure" // signed, and the parent's DS is in place
|
||||
PhaseRemovingDS = "removing_ds" // turning off: the DS must go from the parent first
|
||||
PhaseDSExpiring = "ds_expiring" // the DS is gone; named waits until caches forget it
|
||||
PhaseRemovingKeys = "removing_keys" // named removes the keys and signatures
|
||||
PhaseUnsigned = "unsigned" // turning off has finished; switch DNSSEC off
|
||||
)
|
||||
|
||||
// Phase says where a zone is and what comes next.
|
||||
type Phase struct {
|
||||
Code string `json:"code"`
|
||||
Summary string `json:"summary"`
|
||||
Next string `json:"next,omitempty"`
|
||||
// DSReadyAt is when the DS can safely be added at the parent (phase
|
||||
// signing).
|
||||
DSReadyAt *time.Time `json:"ds_ready_at,omitempty"`
|
||||
// CanPublishDS: the zone is ready for (or already has) a DS at the
|
||||
// parent.
|
||||
CanPublishDS bool `json:"can_publish_ds"`
|
||||
// CanFinish: the zone is being turned off and the parent has no DS
|
||||
// any more (as far as named knows), so DNSSEC can be switched off.
|
||||
CanFinish bool `json:"can_finish"`
|
||||
// NeedsDS lists the signing keys whose DS should be at the parent,
|
||||
// and StaleDS those whose DS should be removed from it.
|
||||
NeedsDS []uint16 `json:"needs_ds,omitempty"`
|
||||
StaleDS []uint16 `json:"stale_ds,omitempty"`
|
||||
}
|
||||
|
||||
// Assess works out the phase of a zone signed with policy ("" when Jarl
|
||||
// doesn't sign it, "insecure" while signing is being removed) that has
|
||||
// keys.
|
||||
func Assess(policy string, keys []*Key) Phase {
|
||||
var live []*Key // signing keys named keeps (or is introducing)
|
||||
for _, k := range keys {
|
||||
if k.SigningKey() && k.Goal == Omnipresent {
|
||||
live = append(live, k)
|
||||
}
|
||||
}
|
||||
switch {
|
||||
case policy == "":
|
||||
if len(keys) == 0 {
|
||||
return Phase{Code: PhaseOff, Summary: "DNSSEC is off."}
|
||||
}
|
||||
return Phase{Code: PhaseLeftover, Summary: "DNSSEC is off, but keys from when the zone was signed are still in its key directory.",
|
||||
Next: "Wipe them (jarl zone dnssec wipe) once you're sure the parent zone has no DS record for them, or turn signing on again to reuse them."}
|
||||
case policy == "insecure":
|
||||
return assessOff(keys)
|
||||
}
|
||||
if len(live) == 0 {
|
||||
return Phase{Code: PhaseStarting, Summary: "Signing is on; named hasn't created the zone's keys yet.",
|
||||
Next: "Wait a moment: named makes the keys when it loads the configuration."}
|
||||
}
|
||||
p := Phase{}
|
||||
var notReady []*Key
|
||||
for _, k := range live {
|
||||
if k.DNSKEYState != Omnipresent || k.KeyRRSIG != Omnipresent {
|
||||
notReady = append(notReady, k)
|
||||
}
|
||||
}
|
||||
for _, k := range keys {
|
||||
if k.SigningKey() && k.Goal == Hidden && k.DSState != Hidden && k.DSRemoved == nil {
|
||||
p.StaleDS = append(p.StaleDS, k.Tag)
|
||||
}
|
||||
}
|
||||
if len(notReady) > 0 {
|
||||
p.Code = PhaseSigning
|
||||
p.Summary = "named is signing the zone. Resolvers still treat it as unsigned, because the parent zone has no DS record for it."
|
||||
for _, k := range notReady {
|
||||
if k.PublishCDS != nil && (p.DSReadyAt == nil || k.PublishCDS.After(*p.DSReadyAt)) {
|
||||
t := *k.PublishCDS
|
||||
p.DSReadyAt = &t
|
||||
}
|
||||
}
|
||||
if p.DSReadyAt != nil {
|
||||
p.Next = fmt.Sprintf("Wait until %s before adding the DS at the parent: until then some resolvers may still have unsigned answers cached, and would reject them once the DS appears.", p.DSReadyAt.UTC().Format("Mon 2 Jan 15:04 MST"))
|
||||
} else {
|
||||
p.Next = "Wait until named has published the keys long enough for caches to pick them up before adding the DS."
|
||||
}
|
||||
return p
|
||||
}
|
||||
p.CanPublishDS = true
|
||||
var pending, secure int
|
||||
for _, k := range live {
|
||||
switch {
|
||||
case k.DSState == Omnipresent:
|
||||
secure++
|
||||
case k.DSPublished != nil:
|
||||
pending++
|
||||
default:
|
||||
p.NeedsDS = append(p.NeedsDS, k.Tag)
|
||||
}
|
||||
}
|
||||
switch {
|
||||
case len(p.NeedsDS) > 0:
|
||||
p.Code = PhaseReady
|
||||
p.Summary = "The zone is signed and ready: add the DS record at the parent zone (your registrar) to turn validation on."
|
||||
p.Next = "Add the DS record (" + tags(p.NeedsDS) + ") at your registrar, then run the DS check so named knows it's there."
|
||||
case pending > 0:
|
||||
p.Code = PhaseDSPending
|
||||
p.Summary = "The parent zone has the DS record. named waits for the parent's TTL before it treats it as in place."
|
||||
default:
|
||||
p.Code = PhaseSecure
|
||||
p.Summary = "The zone is signed and the parent zone's DS record points at its key: validating resolvers check its answers."
|
||||
}
|
||||
if len(p.StaleDS) > 0 && p.Next == "" {
|
||||
p.Next = "Remove the DS record of the retired key " + tags(p.StaleDS) + " at your registrar, then run the DS check."
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
func assessOff(keys []*Key) Phase {
|
||||
var dsUp, dsGoing, keysLeft bool
|
||||
for _, k := range keys {
|
||||
if !k.SigningKey() {
|
||||
if !k.Gone() {
|
||||
keysLeft = true
|
||||
}
|
||||
continue
|
||||
}
|
||||
switch {
|
||||
case k.DSState == Hidden || k.DSState == "":
|
||||
case k.DSRemoved != nil:
|
||||
dsGoing = true
|
||||
default:
|
||||
dsUp = true
|
||||
}
|
||||
if !k.Gone() {
|
||||
keysLeft = true
|
||||
}
|
||||
}
|
||||
switch {
|
||||
case dsUp:
|
||||
return Phase{Code: PhaseRemovingDS, Summary: "Turning DNSSEC off: named keeps the zone signed until the parent zone's DS record is gone.",
|
||||
Next: "Remove the DS record at your registrar, then run the DS check so named knows. Don't switch DNSSEC off before that: the zone would fail to validate."}
|
||||
case dsGoing:
|
||||
return Phase{Code: PhaseDSExpiring, Summary: "Turning DNSSEC off: the parent's DS record is gone; named keeps signing until resolvers' cached copies of it expire (the parent's DS TTL, often a day).",
|
||||
Next: "Wait: switching DNSSEC off now would make the zone fail to validate for resolvers that still have the DS cached."}
|
||||
case keysLeft:
|
||||
return Phase{Code: PhaseRemovingKeys, Summary: "Turning DNSSEC off: the parent has no DS any more, and named is removing the keys and signatures.",
|
||||
Next: "Wait until named has finished, then switch DNSSEC off.", CanFinish: true}
|
||||
}
|
||||
return Phase{Code: PhaseUnsigned, Summary: "Turning DNSSEC off has finished: the zone is no longer signed.",
|
||||
Next: "Switch DNSSEC off for the zone to finish (jarl zone dnssec disable), and wipe the old keys if you like.", CanFinish: true}
|
||||
}
|
||||
|
||||
func tags(ts []uint16) string {
|
||||
var s []string
|
||||
for _, t := range ts {
|
||||
s = append(s, fmt.Sprintf("key %d", t))
|
||||
}
|
||||
return strings.Join(s, ", ")
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
; This is a key-signing key, keyid 22642, for example.test.
|
||||
; Created: 20261008190331 (Thu Oct 8 19:03:31 2026)
|
||||
; Publish: 20261008190331 (Thu Oct 8 19:03:31 2026)
|
||||
; Activate: 20261008190331 (Thu Oct 8 19:03:31 2026)
|
||||
; SyncPublish: 20261009190831 (Fri Oct 9 19:08:31 2026)
|
||||
example.test. 3600 IN DNSKEY 257 3 13 fiSDgJJGHyAp36ZNGOeb45gn+O1pF3hCbTUPHeJ3ztIuisGoywmo7Be3 +vUPyC6e9BmTCIRIeElB4f2o9HoPXg==
|
||||
@@ -0,0 +1 @@
|
||||
Private-key-format: v1.3 (test data, not a real key)
|
||||
@@ -0,0 +1,19 @@
|
||||
; This is the state of key 22642, for example.test.
|
||||
Algorithm: 13
|
||||
Length: 256
|
||||
Lifetime: 0
|
||||
KSK: yes
|
||||
ZSK: yes
|
||||
Generated: 20261008190331 (Thu Oct 8 19:03:31 2026)
|
||||
Published: 20261008190331 (Thu Oct 8 19:03:31 2026)
|
||||
Active: 20261008190331 (Thu Oct 8 19:03:31 2026)
|
||||
PublishCDS: 20261009190831 (Fri Oct 9 19:08:31 2026)
|
||||
DNSKEYChange: 20261008190331 (Thu Oct 8 19:03:31 2026)
|
||||
ZRRSIGChange: 20261008190331 (Thu Oct 8 19:03:31 2026)
|
||||
KRRSIGChange: 20261008190331 (Thu Oct 8 19:03:31 2026)
|
||||
DSChange: 20261008190331 (Thu Oct 8 19:03:31 2026)
|
||||
DNSKEYState: rumoured
|
||||
ZRRSIGState: rumoured
|
||||
KRRSIGState: rumoured
|
||||
DSState: hidden
|
||||
GoalState: omnipresent
|
||||
@@ -0,0 +1,8 @@
|
||||
; This is a key-signing key, keyid 642, for fast.test.
|
||||
; Created: 20261008203220 (Thu Oct 8 20:32:20 2026)
|
||||
; Publish: 20261008203220 (Thu Oct 8 20:32:20 2026)
|
||||
; Activate: 20261008203220 (Thu Oct 8 20:32:20 2026)
|
||||
; Inactive: 20261008203319 (Thu Oct 8 20:33:19 2026)
|
||||
; Delete: 20261024213819 (Sat Oct 24 21:38:19 2026)
|
||||
; SyncPublish: 20261008203234 (Thu Oct 8 20:32:34 2026)
|
||||
fast.test. 3600 IN DNSKEY 257 3 13 D6O3u6ZWXwKPu/Y4acS2eRm1zKQ8LaN8zhvkr6KNWP+fEPgko6yLib94 J5KcUXpFcG6Y/jdxKM8FgeASRDq/ow==
|
||||
@@ -0,0 +1 @@
|
||||
Private-key-format: v1.3 (test data, not a real key)
|
||||
@@ -0,0 +1,23 @@
|
||||
; This is the state of key 642, for fast.test.
|
||||
Algorithm: 13
|
||||
Length: 256
|
||||
Lifetime: 0
|
||||
KSK: yes
|
||||
ZSK: yes
|
||||
Generated: 20261008203220 (Thu Oct 8 20:32:20 2026)
|
||||
Published: 20261008203220 (Thu Oct 8 20:32:20 2026)
|
||||
Active: 20261008203220 (Thu Oct 8 20:32:20 2026)
|
||||
Retired: 20261008203319 (Thu Oct 8 20:33:19 2026)
|
||||
Removed: 20261024213819 (Sat Oct 24 21:38:19 2026)
|
||||
DSPublish: 20261008203249 (Thu Oct 8 20:32:49 2026)
|
||||
DSRemoved: 20261008203329 (Thu Oct 8 20:33:29 2026)
|
||||
PublishCDS: 20261008203234 (Thu Oct 8 20:32:34 2026)
|
||||
DNSKEYChange: 20261008203234 (Thu Oct 8 20:32:34 2026)
|
||||
ZRRSIGChange: 20261008203232 (Thu Oct 8 20:32:32 2026)
|
||||
KRRSIGChange: 20261008203234 (Thu Oct 8 20:32:34 2026)
|
||||
DSChange: 20261008203319 (Thu Oct 8 20:33:19 2026)
|
||||
DNSKEYState: omnipresent
|
||||
ZRRSIGState: omnipresent
|
||||
KRRSIGState: omnipresent
|
||||
DSState: unretentive
|
||||
GoalState: hidden
|
||||
@@ -0,0 +1,8 @@
|
||||
; This is a key-signing key, keyid 28643, for fast2.test.
|
||||
; Created: 20261008203340 (Thu Oct 8 20:33:40 2026)
|
||||
; Publish: 20261008203340 (Thu Oct 8 20:33:40 2026)
|
||||
; Activate: 20261008203340 (Thu Oct 8 20:33:40 2026)
|
||||
; Inactive: 20261008203344 (Thu Oct 8 20:33:44 2026)
|
||||
; Delete: 20261024213844 (Sat Oct 24 21:38:44 2026)
|
||||
; SyncPublish: 20261008203354 (Thu Oct 8 20:33:54 2026)
|
||||
fast2.test. 3600 IN DNSKEY 257 3 13 6WkqPp3kxXxMJtC9clsF4zTnZGPXaFRikSdMLG1eFExa4d0Uo+PtMbqo KHR/Qe601tO7yZoY8itie0aZzPaMiA==
|
||||
@@ -0,0 +1 @@
|
||||
Private-key-format: v1.3 (test data, not a real key)
|
||||
@@ -0,0 +1,21 @@
|
||||
; This is the state of key 28643, for fast2.test.
|
||||
Algorithm: 13
|
||||
Length: 256
|
||||
Lifetime: 0
|
||||
KSK: yes
|
||||
ZSK: yes
|
||||
Generated: 20261008203340 (Thu Oct 8 20:33:40 2026)
|
||||
Published: 20261008203340 (Thu Oct 8 20:33:40 2026)
|
||||
Active: 20261008203340 (Thu Oct 8 20:33:40 2026)
|
||||
Retired: 20261008203344 (Thu Oct 8 20:33:44 2026)
|
||||
Removed: 20261024213844 (Sat Oct 24 21:38:44 2026)
|
||||
PublishCDS: 20261008203354 (Thu Oct 8 20:33:54 2026)
|
||||
DNSKEYChange: 20261008203344 (Thu Oct 8 20:33:44 2026)
|
||||
ZRRSIGChange: 20261008203344 (Thu Oct 8 20:33:44 2026)
|
||||
KRRSIGChange: 20261008203344 (Thu Oct 8 20:33:44 2026)
|
||||
DSChange: 20261008203340 (Thu Oct 8 20:33:40 2026)
|
||||
DNSKEYState: unretentive
|
||||
ZRRSIGState: unretentive
|
||||
KRRSIGState: unretentive
|
||||
DSState: hidden
|
||||
GoalState: hidden
|
||||
@@ -0,0 +1,299 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"jarl/internal/blocking"
|
||||
"jarl/internal/model"
|
||||
)
|
||||
|
||||
func blockRule(name, action string, exact bool, views ...string) Request {
|
||||
in := BlockRuleInput{Name: &name, Action: &action, Exact: &exact}
|
||||
if len(views) > 0 {
|
||||
in.Views = &views
|
||||
}
|
||||
return NewRequest(OpBlockRuleCreate, 0, in)
|
||||
}
|
||||
|
||||
func blocklist(name, url string, views ...string) Request {
|
||||
in := BlocklistInput{Name: &name, URL: &url}
|
||||
if len(views) > 0 {
|
||||
in.Views = &views
|
||||
}
|
||||
return NewRequest(OpBlocklistCreate, 0, in)
|
||||
}
|
||||
|
||||
// listServer serves a blocklist with an ETag, and counts the downloads
|
||||
// that sent content.
|
||||
type listServer struct {
|
||||
body atomic.Value
|
||||
full atomic.Int32
|
||||
calls atomic.Int32
|
||||
}
|
||||
|
||||
func newListServer(t *testing.T, body string) (*listServer, *httptest.Server) {
|
||||
ls := &listServer{}
|
||||
ls.body.Store(body)
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
ls.calls.Add(1)
|
||||
b := ls.body.Load().(string)
|
||||
etag := `"` + string(rune('a'+len(b)%26)) + "x" + string(rune('a'+len(b)/26%26)) + `"`
|
||||
if r.Header.Get("If-None-Match") == etag {
|
||||
w.WriteHeader(http.StatusNotModified)
|
||||
return
|
||||
}
|
||||
w.Header().Set("ETag", etag)
|
||||
ls.full.Add(1)
|
||||
w.Write([]byte(b))
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
return ls, srv
|
||||
}
|
||||
|
||||
func TestBlockingRender(t *testing.T) {
|
||||
env := newEnv(t)
|
||||
setupZone(env)
|
||||
env.submit(env.admin,
|
||||
NewRequest(OpViewCreate, 0, ViewInput{Name: model.Ptr("internal"), MatchClients: &[]string{"10.0.0.0/8"}, AdoptZones: true}),
|
||||
NewRequest(OpViewCreate, 0, ViewInput{Name: model.Ptr("external")}),
|
||||
)
|
||||
st := env.state()
|
||||
ext := st.ViewByName("external")
|
||||
env.submit(env.admin, NewRequest(OpOptionCreate, 0, OptionInput{Scope: model.Ptr(model.ScopeView), ScopeID: &ext.ID, Name: model.Ptr("recursion"), Value: model.Ptr("no")}))
|
||||
|
||||
_, srv := newListServer(t, "||ads.example^\n")
|
||||
res := env.submit(env.admin,
|
||||
blocklist("ads", srv.URL+"/list.txt"),
|
||||
blockRule("tracker.example", "block", false),
|
||||
blockRule("cdn.tracker.example", "allow", true),
|
||||
)
|
||||
if res.Ops[1].Summary != "block tracker.example (and below)" || res.Ops[2].Summary != "allow cdn.tracker.example" {
|
||||
t.Errorf("summaries: %q, %q", res.Ops[1].Summary, res.Ops[2].Summary)
|
||||
}
|
||||
conf, _ := os.ReadFile(filepath.Join(env.dir, "etc/named.conf"))
|
||||
c := string(conf)
|
||||
// Automatic views: only internal offers recursion.
|
||||
internal := c[strings.Index(c, `view "internal"`):strings.Index(c, `view "external"`)]
|
||||
for _, want := range []string{
|
||||
"response-policy {\n\t\tzone \"rules.jarl.invalid\" min-update-interval 0 log no;\n\t\tzone \"ads.blocklist.jarl.invalid\" min-update-interval 0 log no;\n\t} qname-wait-recurse no break-dnssec yes;",
|
||||
`zone "rules.jarl.invalid" {`,
|
||||
"allow-query { none; };",
|
||||
} {
|
||||
if !strings.Contains(internal, want) {
|
||||
t.Errorf("view internal lacks %q:\n%s", want, internal)
|
||||
}
|
||||
}
|
||||
if strings.Contains(c[strings.Index(c, `view "external"`):], "response-policy") {
|
||||
t.Error("view external, without recursion, got blocking")
|
||||
}
|
||||
rules, err := os.ReadFile(filepath.Join(env.dir, "zones/internal/rules.jarl.invalid.rpz"))
|
||||
if err != nil || !strings.Contains(string(rules), "cdn.tracker.example\tCNAME\trpz-passthru.\n*.cdn.tracker.example\tCNAME\t.\n") {
|
||||
t.Errorf("rules zone: %v\n%s", err, rules)
|
||||
}
|
||||
// The list's file exists as an empty policy zone until downloaded.
|
||||
b := env.state().BlocklistByName("ads")
|
||||
listFile := filepath.Join(env.dir, "cache/blocklists/list-"+strconv.FormatInt(b.ID, 10)+".rpz")
|
||||
if f, err := os.ReadFile(listFile); err != nil || !strings.Contains(string(f), "not downloaded yet") {
|
||||
t.Errorf("placeholder: %v %s", err, f)
|
||||
}
|
||||
|
||||
// A view a list or rule names can't be deleted.
|
||||
res = env.submit(env.admin, NewRequest(OpBlocklistUpdate, b.ID, BlocklistInput{Views: &[]string{"external"}}))
|
||||
if len(res.Problems) != 1 || res.Problems[0].Code != "blocking_no_recursion" {
|
||||
t.Errorf("a list in a view without recursion: %+v", res.Problems)
|
||||
}
|
||||
if e := env.submitErr(env.admin, NewRequest(OpViewDelete, ext.ID, DeleteInput{Cascade: true})); e.Kind != KindConflict || !strings.Contains(e.Message, "blocklist ads") {
|
||||
t.Errorf("deleting a used view: %+v", e)
|
||||
}
|
||||
st = env.state()
|
||||
// A second rule for the same name and view is refused.
|
||||
if e := env.submitErr(env.admin, blockRule("tracker.example", "allow", true)); e.Kind != KindValidation || !strings.Contains(e.Message, "two rules for tracker.example") {
|
||||
t.Errorf("duplicate rule: %+v", e)
|
||||
}
|
||||
// So is a response-policy option where Jarl writes one.
|
||||
in := st.ViewByName("internal")
|
||||
if e := env.submitErr(env.admin, NewRequest(OpOptionCreate, 0, OptionInput{Scope: model.Ptr(model.ScopeView), ScopeID: &in.ID, Name: model.Ptr("response-policy"), Value: model.Ptr(`{ zone "x"; }`)})); e.Kind != KindValidation {
|
||||
t.Errorf("response-policy option: %+v", e)
|
||||
}
|
||||
// Bad input.
|
||||
for _, req := range []Request{blocklist("Bad Name", srv.URL), blocklist("ok", "ftp://x/list"), blockRule("com", "block", false), blockRule("a.example", "deny", false), blockRule("a.example", "block", false, "nope")} {
|
||||
if e := env.submitErr(env.admin, req); e.Kind != KindBadRequest {
|
||||
t.Errorf("%s: %+v", req.Data, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBlocklistRefresh(t *testing.T) {
|
||||
env := newEnv(t)
|
||||
setupZone(env)
|
||||
ls, srv := newListServer(t, "# test\n||ads.example^\n0.0.0.0 tracker.example\nnot a domain line\n")
|
||||
env.submit(env.admin, blocklist("ads", srv.URL), blockRule("good.ads.example", "allow", false))
|
||||
b := env.state().BlocklistByName("ads")
|
||||
ctx := context.Background()
|
||||
|
||||
env.e.RefreshDue(ctx)
|
||||
s, _ := env.st.BlocklistStatuses(ctx)
|
||||
st := s[b.ID]
|
||||
if st == nil || st.Error != "" || st.Entries != 2 || st.Records != 3 || st.Skipped != 1 || st.UpdatedAt == nil {
|
||||
t.Fatalf("status after the first download: %+v", st)
|
||||
}
|
||||
file := env.e.cfg.Paths.BlocklistFile(b.ID)
|
||||
if f, _ := os.ReadFile(file); !strings.Contains(string(f), "*.ads.example\tCNAME\t.\n") || !strings.Contains(string(f), "tracker.example\tCNAME\t.\n") {
|
||||
t.Errorf("list zone:\n%s", f)
|
||||
}
|
||||
// Not due again: nothing is fetched.
|
||||
env.e.RefreshDue(ctx)
|
||||
if ls.calls.Load() != 1 {
|
||||
t.Errorf("downloads after a second RefreshDue: %d", ls.calls.Load())
|
||||
}
|
||||
// A cleared cache leaves an empty placeholder: downloaded again at once.
|
||||
os.Remove(file)
|
||||
env.e.RefreshDue(ctx)
|
||||
if ls.calls.Load() != 2 {
|
||||
t.Errorf("downloads after the file was lost: %d", ls.calls.Load())
|
||||
}
|
||||
if f, _ := os.ReadFile(file); !strings.Contains(string(f), "*.ads.example\tCNAME\t.\n") {
|
||||
t.Errorf("list zone after the file was lost:\n%s", f)
|
||||
}
|
||||
// Unchanged on the server: 304, no rewrite.
|
||||
firstHash := st.SHA256
|
||||
fullBefore := ls.full.Load()
|
||||
env.e.RefreshBlocklist(ctx, b.ID, false)
|
||||
if ls.full.Load() != fullBefore {
|
||||
t.Errorf("a conditional download fetched the list again")
|
||||
}
|
||||
// Changed: rewritten.
|
||||
ls.body.Store("||ads.example^\n||more.example^\n")
|
||||
st, err := env.e.RefreshBlocklist(ctx, b.ID, false)
|
||||
if err != nil || st.Entries != 2 || st.Records != 4 || st.SHA256 == firstHash {
|
||||
t.Errorf("after a change: %+v, %v", st, err)
|
||||
}
|
||||
// A broken download keeps the previous version and reports why.
|
||||
before, _ := os.ReadFile(file)
|
||||
ls.body.Store("<html><body>Not found</body></html>\n")
|
||||
st, err = env.e.RefreshBlocklist(ctx, b.ID, true)
|
||||
if err == nil || !strings.Contains(st.Error, "no domains found") {
|
||||
t.Errorf("garbage: %+v, %v", st, err)
|
||||
}
|
||||
if after, _ := os.ReadFile(file); string(after) != string(before) {
|
||||
t.Error("a failed update changed the list's file")
|
||||
}
|
||||
// A failed attempt is retried after RetryAfter, not at every check.
|
||||
calls := ls.calls.Load()
|
||||
env.e.RefreshDue(ctx)
|
||||
if ls.calls.Load() != calls {
|
||||
t.Error("a failed list was tried again before RetryAfter")
|
||||
}
|
||||
// Too big.
|
||||
env.e.Limits.MaxBytes = 10
|
||||
ls.body.Store("||ads.example^\n||more.example^\n||third.example^\n")
|
||||
if st, _ = env.e.RefreshBlocklist(ctx, b.ID, true); !strings.Contains(st.Error, "larger than") {
|
||||
t.Errorf("size limit: %+v", st)
|
||||
}
|
||||
env.e.Limits = DefaultBlocklistLimits()
|
||||
env.e.RefreshBlocklist(ctx, b.ID, true)
|
||||
|
||||
// What blocks what.
|
||||
check := func(name string) blocking.Match {
|
||||
m, err := env.e.CheckBlocking(env.state(), name)
|
||||
if err != nil || len(m) != 1 {
|
||||
t.Fatalf("check %s: %+v %v", name, m, err)
|
||||
}
|
||||
return m[0]
|
||||
}
|
||||
if m := check("x.ads.example"); m.Result != "blocked" || m.List != "ads" || m.Entry != "*.ads.example" {
|
||||
t.Errorf("x.ads.example: %+v", m)
|
||||
}
|
||||
if m := check("a.good.ads.example"); m.Result != "allowed" || m.RuleID == 0 || m.Entry != "*.good.ads.example" {
|
||||
t.Errorf("a.good.ads.example: %+v", m)
|
||||
}
|
||||
if m := check("fine.example"); m.Result != "none" {
|
||||
t.Errorf("fine.example: %+v", m)
|
||||
}
|
||||
|
||||
// Deleting the list removes its file and status.
|
||||
env.submit(env.admin, NewRequest(OpBlocklistDelete, b.ID, nil))
|
||||
env.e.RefreshDue(ctx)
|
||||
if _, err := os.Stat(file); !os.IsNotExist(err) {
|
||||
t.Errorf("the deleted list's file is still there: %v", err)
|
||||
}
|
||||
if s, _ := env.st.BlocklistStatuses(ctx); s[b.ID] != nil {
|
||||
t.Error("the deleted list's status is still there")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBlockingPause(t *testing.T) {
|
||||
env := newEnv(t)
|
||||
setupZone(env)
|
||||
env.submit(env.admin, blocklist("ads", "https://example.org/list"))
|
||||
rulesFile := filepath.Join(env.dir, "zones/_default/rules.jarl.invalid.rpz")
|
||||
if _, err := os.Stat(rulesFile); !os.IsNotExist(err) {
|
||||
t.Fatal("a rules zone without rules or a pause")
|
||||
}
|
||||
until := env.e.Now().Add(10 * time.Minute)
|
||||
res := env.submit(env.admin, NewRequest(OpBlockingPause, 0, BlockingPauseInput{Until: &until}))
|
||||
if !strings.HasPrefix(res.Ops[0].Summary, "pause blocking until ") {
|
||||
t.Errorf("summary = %q", res.Ops[0].Summary)
|
||||
}
|
||||
f, err := os.ReadFile(rulesFile)
|
||||
if err != nil || !strings.HasSuffix(string(f), "NS\tlocalhost.\n*\tCNAME\trpz-passthru.\n") {
|
||||
t.Fatalf("paused rules zone: %v\n%s", err, f)
|
||||
}
|
||||
conf, _ := os.ReadFile(filepath.Join(env.dir, "etc/named.conf"))
|
||||
if !strings.Contains(string(conf), "zone \"rules.jarl.invalid\" min-update-interval 0 log no;\n\t\tzone \"ads.") {
|
||||
t.Errorf("the paused rules zone must come first:\n%s", conf)
|
||||
}
|
||||
// Not yet time: nothing happens.
|
||||
if got := env.e.ResumeBlocking(context.Background()); !got.Equal(until) {
|
||||
t.Errorf("ResumeBlocking before the end = %v", got)
|
||||
}
|
||||
// Time is up: resumed, and the rules zone is gone again.
|
||||
env.submit(env.admin, blockRule("ads.example", "block", false))
|
||||
env.e.Now = func() time.Time { return until.Add(time.Second) }
|
||||
if got := env.e.ResumeBlocking(context.Background()); !got.IsZero() || env.state().Settings.BlockingPausedUntil != nil {
|
||||
t.Fatalf("after the pause: %v, %v", got, env.state().Settings.BlockingPausedUntil)
|
||||
}
|
||||
if f, _ := os.ReadFile(rulesFile); !strings.Contains(string(f), "*.ads.example\tCNAME\t.\n") || strings.Contains(string(f), "passthru") {
|
||||
t.Errorf("rules zone after the pause:\n%s", f)
|
||||
}
|
||||
// Reverting a pause resumes blocking.
|
||||
until = env.e.Now().Add(time.Hour)
|
||||
res = env.submit(env.admin, NewRequest(OpBlockingPause, 0, BlockingPauseInput{Until: &until}))
|
||||
rev, err := env.e.Revert(context.Background(), res.ChangesetID, env.admin)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := env.e.Apply(context.Background(), rev.ID, env.admin, ApplyOptions{}); err != nil || env.state().Settings.BlockingPausedUntil != nil {
|
||||
t.Errorf("revert of a pause: %v, %v", err, env.state().Settings.BlockingPausedUntil)
|
||||
}
|
||||
// Until resumed: jarld never ends it, a resume does.
|
||||
res = env.submit(env.admin, NewRequest(OpBlockingPause, 0, BlockingPauseInput{Indefinitely: true}))
|
||||
if res.Ops[0].Summary != "pause blocking until resumed" || !env.state().Settings.BlockingPaused() {
|
||||
t.Errorf("indefinite pause: %q", res.Ops[0].Summary)
|
||||
}
|
||||
if f, _ := os.ReadFile(rulesFile); !strings.Contains(string(f), "*\tCNAME\trpz-passthru.\n") {
|
||||
t.Errorf("rules zone while paused indefinitely:\n%s", f)
|
||||
}
|
||||
env.e.Now = func() time.Time { return until.Add(1000 * time.Hour) }
|
||||
if got := env.e.ResumeBlocking(context.Background()); !got.IsZero() || !env.state().Settings.BlockingPausedIndefinitely {
|
||||
t.Errorf("an indefinite pause ended by itself: %v", got)
|
||||
}
|
||||
env.submit(env.admin, NewRequest(OpBlockingPause, 0, BlockingPauseInput{}))
|
||||
if env.state().Settings.BlockingPaused() {
|
||||
t.Error("resume didn't end an indefinite pause")
|
||||
}
|
||||
// At most a day.
|
||||
long := env.e.Now().Add(48 * time.Hour)
|
||||
if e := env.submitErr(env.admin, NewRequest(OpBlockingPause, 0, BlockingPauseInput{Until: &long})); e.Kind != KindBadRequest {
|
||||
t.Errorf("a two-day pause: %+v", e)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,403 @@
|
||||
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.
|
||||
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" {
|
||||
continue // an earlier zone decided
|
||||
}
|
||||
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 != "" {
|
||||
cur.Result, cur.Entry, cur.List = "blocked", entry, st.Blocklists[z.ListID].Name
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ruleFor finds the rule behind a rules-zone entry: the rule for the
|
||||
// entry's name, or (for entries added so the most specific rule wins) the
|
||||
// closest rule above it that covers the names below it.
|
||||
func ruleFor(st *model.State, viewID int64, owner string) int64 {
|
||||
name := strings.TrimPrefix(owner, "*.")
|
||||
wild := name != owner
|
||||
var best *model.BlockRule
|
||||
for _, r := range st.SortedBlockRules() {
|
||||
if !slices.Contains(st.BlockingViews(r.ViewIDs), viewID) {
|
||||
continue
|
||||
}
|
||||
if r.Name == name && (!wild || !r.Exact) {
|
||||
return r.ID
|
||||
}
|
||||
if !r.Exact && strings.HasSuffix(name, "."+r.Name) && (best == nil || len(r.Name) > len(best.Name)) {
|
||||
best = r
|
||||
}
|
||||
}
|
||||
if best != nil {
|
||||
return best.ID
|
||||
}
|
||||
return 0
|
||||
}
|
||||
@@ -0,0 +1,312 @@
|
||||
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
|
||||
}
|
||||
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))
|
||||
}
|
||||
}
|
||||
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{}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// hunkHeader matches a unified diff hunk header; the line counts are left
|
||||
// out when they are 1.
|
||||
var hunkHeader = regexp.MustCompile(`^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@$`)
|
||||
|
||||
// labelHunks adds the blocks a hunk's first change is in to its header,
|
||||
// like git does with function names, so a change to named.conf says where
|
||||
// it is:
|
||||
//
|
||||
// @@ -50,6 +49,8 @@ view "internal" {
|
||||
//
|
||||
// Removed lines are looked up in the old file, added ones in the new.
|
||||
func labelHunks(diff, old, new string) string {
|
||||
if !strings.Contains(diff, "\n@@ ") {
|
||||
return diff
|
||||
}
|
||||
oldCtx, newCtx := blockContext(old), blockContext(new)
|
||||
lines := strings.SplitAfter(diff, "\n")
|
||||
for i, line := range lines {
|
||||
m := hunkHeader.FindStringSubmatch(strings.TrimSuffix(line, "\n"))
|
||||
if m == nil {
|
||||
continue
|
||||
}
|
||||
o, _ := strconv.Atoi(m[1])
|
||||
n, _ := strconv.Atoi(m[2])
|
||||
label := ""
|
||||
hunk:
|
||||
for _, l := range lines[i+1:] {
|
||||
switch {
|
||||
case strings.HasPrefix(l, "-"):
|
||||
label = at(oldCtx, o)
|
||||
break hunk
|
||||
case strings.HasPrefix(l, "+"):
|
||||
label = at(newCtx, n)
|
||||
break hunk
|
||||
case strings.HasPrefix(l, " "):
|
||||
o, n = o+1, n+1
|
||||
case strings.HasPrefix(l, `\`):
|
||||
default:
|
||||
break hunk
|
||||
}
|
||||
}
|
||||
if label != "" {
|
||||
lines[i] = strings.TrimSuffix(line, "\n") + " " + label + "\n"
|
||||
}
|
||||
}
|
||||
return strings.Join(lines, "")
|
||||
}
|
||||
|
||||
// at returns the context of line n (1-based), or "".
|
||||
func at(ctx []string, n int) string {
|
||||
if n < 1 || n > len(ctx) {
|
||||
return ""
|
||||
}
|
||||
return ctx[n-1]
|
||||
}
|
||||
|
||||
// blockContext returns, for each line of a named.conf file, the blocks
|
||||
// open at its start: `view "internal" { zone "example.com" {`, or "" at
|
||||
// the top level. It knows named.conf's strings and comments.
|
||||
func blockContext(text string) []string {
|
||||
var (
|
||||
out []string
|
||||
stack []string
|
||||
stmt strings.Builder // the current statement, up to a { or ;
|
||||
inStr bool
|
||||
inBlock bool // in a /* */ comment
|
||||
)
|
||||
label := func() string {
|
||||
if len(stack) == 0 {
|
||||
return ""
|
||||
}
|
||||
return strings.Join(stack, " { ") + " {"
|
||||
}
|
||||
for _, line := range strings.SplitAfter(text, "\n") {
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
out = append(out, label())
|
||||
for i := 0; i < len(line); i++ {
|
||||
c := line[i]
|
||||
switch {
|
||||
case inBlock:
|
||||
if c == '*' && i+1 < len(line) && line[i+1] == '/' {
|
||||
inBlock = false
|
||||
i++
|
||||
}
|
||||
case inStr:
|
||||
stmt.WriteByte(c)
|
||||
if c == '\\' && i+1 < len(line) {
|
||||
stmt.WriteByte(line[i+1])
|
||||
i++
|
||||
} else if c == '"' {
|
||||
inStr = false
|
||||
}
|
||||
case c == '"':
|
||||
inStr = true
|
||||
stmt.WriteByte(c)
|
||||
case c == '#' || c == '/' && i+1 < len(line) && line[i+1] == '/':
|
||||
i = len(line) // a comment to the end of the line
|
||||
case c == '/' && i+1 < len(line) && line[i+1] == '*':
|
||||
inBlock = true
|
||||
i++
|
||||
case c == '{':
|
||||
stack = append(stack, shortLabel(stmt.String()))
|
||||
stmt.Reset()
|
||||
case c == '}':
|
||||
if len(stack) > 0 {
|
||||
stack = stack[:len(stack)-1]
|
||||
}
|
||||
stmt.Reset()
|
||||
case c == ';':
|
||||
stmt.Reset()
|
||||
default:
|
||||
stmt.WriteByte(c)
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// shortLabel tidies the text before a { for a hunk header.
|
||||
func shortLabel(s string) string {
|
||||
s = strings.Join(strings.Fields(s), " ")
|
||||
if len(s) > 60 {
|
||||
s = s[:57] + "..."
|
||||
}
|
||||
return s
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestLabelHunks(t *testing.T) {
|
||||
old := `// header
|
||||
options {
|
||||
directory "/var/cache/bind";
|
||||
forwarders { 1.1.1.1; 9.9.9.9; };
|
||||
listen-on { any; };
|
||||
recursion no;
|
||||
version "a { b"; # a { in a string and comments: /* {
|
||||
dnssec-validation auto;
|
||||
};
|
||||
|
||||
view "internal" {
|
||||
match-clients { 10.0.0.0/8; };
|
||||
recursion yes;
|
||||
allow-transfer { localhost; };
|
||||
/* a { comment
|
||||
over lines */
|
||||
zone "example.com" {
|
||||
type primary;
|
||||
file "example.com.db";
|
||||
notify yes;
|
||||
};
|
||||
zone "other.example" { type primary; file "o.db"; };
|
||||
};
|
||||
`
|
||||
new := strings.Replace(old, "\tforwarders { 1.1.1.1; 9.9.9.9; };\n", "", 1)
|
||||
new = strings.Replace(new, "\tallow-transfer { localhost; };\n", "\tallow-transfer { localhost; };\n\tforward first;\n\tforwarders { 1.1.1.1; 9.9.9.9; };\n", 1)
|
||||
new = strings.Replace(new, "\t\tnotify yes;\n", "\t\tnotify no;\n", 1)
|
||||
d := fileDiff("/etc/bind/named.conf", []byte(old), true, []byte(new), false, false)
|
||||
var headers []string
|
||||
for _, l := range strings.Split(d, "\n") {
|
||||
if strings.HasPrefix(l, "@@") {
|
||||
headers = append(headers, l)
|
||||
}
|
||||
}
|
||||
want := []string{
|
||||
`@@ -1,7 +1,6 @@ options {`,
|
||||
`@@ -12,12 +11,14 @@ view "internal" {`,
|
||||
}
|
||||
if strings.Join(headers, "\n") != strings.Join(want, "\n") {
|
||||
t.Errorf("headers:\n%s\n\ndiff:\n%s", strings.Join(headers, "\n"), d)
|
||||
}
|
||||
|
||||
// The first change of a hunk decides, and a change inside a zone
|
||||
// names both blocks.
|
||||
new2 := strings.Replace(old, "\t\tnotify yes;\n", "\t\tnotify no;\n", 1)
|
||||
d = fileDiff("/etc/bind/named.conf", []byte(old), true, []byte(new2), false, false)
|
||||
if !strings.Contains(d, ` @@ view "internal" { zone "example.com" {`+"\n") {
|
||||
t.Errorf("zone change:\n%s", d)
|
||||
}
|
||||
// Zone files aren't named.conf syntax.
|
||||
d = fileDiff("/var/lib/bind/example.com.db", []byte(old), true, []byte(new2), false, false)
|
||||
if strings.Contains(d, "@@ view") {
|
||||
t.Errorf("zone file labelled:\n%s", d)
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,9 @@ func DiffOps(from, to *model.State) []model.Op {
|
||||
add := func(kind string, id int64, base int, data any, summary string) {
|
||||
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, 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,35 @@ 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))
|
||||
}
|
||||
}
|
||||
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}
|
||||
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))
|
||||
}
|
||||
}
|
||||
// Deletions, most dependent objects first.
|
||||
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.Records) {
|
||||
if _, ok := to.Records[k]; !ok {
|
||||
o := from.Records[k]
|
||||
@@ -156,6 +187,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
|
||||
}
|
||||
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
package engine
|
||||
|
||||
// DNSSEC signing (docs/dnssec.md). named signs zones itself (dnssec-policy
|
||||
// with inline signing) and keeps their keys in a key directory Jarl
|
||||
// chooses (render.KeyDir). Jarl's part is to keep the steps in order: a
|
||||
// zone must never become unsigned while the parent zone may still have a
|
||||
// DS record for it, or validating resolvers reject the whole domain.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"jarl/internal/auth"
|
||||
"jarl/internal/dnssec"
|
||||
"jarl/internal/model"
|
||||
"jarl/internal/render"
|
||||
"jarl/internal/validate"
|
||||
)
|
||||
|
||||
// DNSSEC returns the signing status of zone z.
|
||||
func (e *Engine) DNSSEC(ctx context.Context, st *model.State, z *model.Zone) *dnssec.Status {
|
||||
s := &dnssec.Status{Policy: z.DNSSECPolicy, KeyDir: render.KeyDir(st, z, e.cfg.Paths), Keys: []*dnssec.Key{}, Policies: []string{model.DNSSECDefault}}
|
||||
for name := range validate.Policies(st) {
|
||||
s.Policies = append(s.Policies, name)
|
||||
}
|
||||
sort.Strings(s.Policies[1:])
|
||||
keys, err := dnssec.Keys(s.KeyDir, z.Name)
|
||||
if err != nil {
|
||||
s.KeyError = err.Error()
|
||||
} else if keys != nil {
|
||||
s.Keys = keys
|
||||
}
|
||||
s.Phase = dnssec.Assess(z.DNSSECPolicy, s.Keys)
|
||||
if z.Type == model.ZonePrimary && !e.cfg.NoReload && e.tools != nil {
|
||||
zs, err := e.tools.ZoneStatus(ctx, z.Name, e.namedView(st, z))
|
||||
if err != nil {
|
||||
s.NamedError = err.Error()
|
||||
} else {
|
||||
s.Named = &dnssec.NamedSigning{Secure: zs.Secure, InlineSigning: zs.InlineSigning, Serial: zs.Serial, SignedSerial: zs.SignedSerial, NextKeyEvent: zs.NextKeyEvent}
|
||||
}
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// dnssecChanges checks the DNSSEC side of going from base to work: the
|
||||
// steps that would make a signed zone fail to validate, or give it new
|
||||
// keys its parent's DS doesn't match.
|
||||
func (e *Engine) dnssecChanges(base, work *model.State, opts ApplyOptions) []validate.Problem {
|
||||
var out []validate.Problem
|
||||
problem := func(sev validate.Severity, st *model.State, z *model.Zone, code, format string, args ...any) {
|
||||
out = append(out, validate.Problem{Severity: sev, Code: code, Message: fmt.Sprintf(format, args...), ObjectType: "zone", ObjectID: z.ID, Zone: z.Name, View: st.ViewName(z)})
|
||||
}
|
||||
for _, old := range base.SortedZones() {
|
||||
if !old.Signed() {
|
||||
continue
|
||||
}
|
||||
z := work.Zones[old.ID]
|
||||
if z == nil {
|
||||
problem(validate.Error, base, old, "dnssec_delete_signed", "the zone is signed: turn DNSSEC off before deleting it (jarl zone dnssec disable, and remove the DS record at your registrar), or the domain fails to validate while the parent still points at its keys")
|
||||
continue
|
||||
}
|
||||
if z.Type != model.ZonePrimary {
|
||||
continue // reported by validate (dnssec_zone_type)
|
||||
}
|
||||
if render.KeyDir(base, old, e.cfg.Paths) != render.KeyDir(work, z, e.cfg.Paths) {
|
||||
problem(validate.Error, work, z, "dnssec_keys_moved", "the zone is signed, and moving it to another view would make named sign it with new keys that the parent's DS record doesn't match; turn DNSSEC off first, or create the zone anew in the other view")
|
||||
continue
|
||||
}
|
||||
switch {
|
||||
case z.DNSSECPolicy == "" && opts.wipeZone == z.ID:
|
||||
// WipeDNSSEC: the caller checked that the parent has no DS.
|
||||
case z.DNSSECPolicy == "" && old.DNSSECPolicy != model.DNSSECInsecure:
|
||||
problem(validate.Error, work, z, "dnssec_off_unsafe", "switching DNSSEC off at once makes named serve the zone unsigned immediately, and if the parent zone has a DS record for it, validating resolvers then reject the whole domain. Turn it off in steps: 'jarl zone dnssec disable' (the insecure policy) keeps the zone signed while you remove the DS at your registrar, and Jarl says when it's safe to finish. If the zone never had a DS at its parent, wipe it instead ('jarl zone dnssec wipe')")
|
||||
case z.DNSSECPolicy == "":
|
||||
keys, err := dnssec.Keys(render.KeyDir(base, old, e.cfg.Paths), old.Name)
|
||||
if err != nil {
|
||||
problem(validate.Error, work, z, "dnssec_off_unknown", "can't tell whether turning DNSSEC off has finished, because the zone's keys can't be read: %v", err)
|
||||
continue
|
||||
}
|
||||
if ph := dnssec.Assess(model.DNSSECInsecure, keys); !ph.CanFinish {
|
||||
problem(validate.Error, work, z, "dnssec_off_too_early", "turning DNSSEC off hasn't finished yet: %s %s", ph.Summary, ph.Next)
|
||||
}
|
||||
case z.DNSSECPolicy != old.DNSSECPolicy && z.DNSSECPolicy != model.DNSSECInsecure && old.DNSSECPolicy != model.DNSSECInsecure:
|
||||
problem(validate.Warning, work, z, "dnssec_policy_change", "changing the zone's DNSSEC policy from %s to %s can make named replace its keys; when its key-signing key changes, the DS record at the parent must change too (Jarl shows when)", old.DNSSECPolicy, z.DNSSECPolicy)
|
||||
}
|
||||
}
|
||||
for _, z := range work.SortedZones() {
|
||||
if !z.Signed() || z.DNSSECPolicy == model.DNSSECInsecure {
|
||||
continue
|
||||
}
|
||||
if old := base.Zones[z.ID]; old != nil && old.Signed() {
|
||||
continue
|
||||
}
|
||||
if files, _ := dnssec.Files(render.KeyDir(work, z, e.cfg.Paths), z.Name); len(files) > 0 {
|
||||
problem(validate.Info, work, z, "dnssec_old_keys", "the zone's key directory still has keys from when it was signed before; named only reuses keys it hasn't retired, and makes new ones otherwise (wipe the old ones first for a clean start)")
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// removeDerived deletes what named kept for zones that are gone after a
|
||||
// change: the journals and signed copies next to their files and, for a
|
||||
// deleted zone, keys left over from when it was signed (a signed zone
|
||||
// can't be deleted). Failures are only logged.
|
||||
func (e *Engine) removeDerived(pl *plan) {
|
||||
for _, path := range pl.removed {
|
||||
for _, f := range dnssec.DerivedFiles(path) {
|
||||
if err := os.Remove(f); err != nil && !errors.Is(err, fs.ErrNotExist) {
|
||||
e.log.Warn("removing a file named kept for a deleted zone failed", "file", f, "err", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
for id, old := range pl.base.Zones {
|
||||
if old.Type != model.ZonePrimary || pl.work.Zones[id] != nil || pl.work.FindZone(old.Name, old.ViewID) != nil {
|
||||
continue
|
||||
}
|
||||
dir := render.KeyDir(pl.base, old, e.cfg.Paths)
|
||||
files, _ := dnssec.Files(dir, old.Name)
|
||||
for _, f := range files {
|
||||
if err := os.Remove(filepath.Join(dir, f)); err != nil && !errors.Is(err, fs.ErrNotExist) {
|
||||
e.log.Warn("removing a deleted zone's old DNSSEC key failed", "file", f, "err", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// WipeResult is the outcome of WipeDNSSEC.
|
||||
type WipeResult struct {
|
||||
Result *Result `json:"result,omitempty"` // the change that switched signing off, if one was needed
|
||||
Removed []string `json:"removed"`
|
||||
DryRun bool `json:"dry_run,omitempty"`
|
||||
}
|
||||
|
||||
// WipeDNSSEC switches signing off for a zone at once, skipping the
|
||||
// insecure policy, and deletes its keys and named's signed copy of it, so
|
||||
// that signing it again starts from scratch. The caller must make sure the
|
||||
// parent zone has no DS record for the zone: otherwise the domain stops
|
||||
// validating.
|
||||
func (e *Engine) WipeDNSSEC(ctx context.Context, zoneID int64, p *auth.Principal, via string, opts ApplyOptions) (*WipeResult, error) {
|
||||
st, err := e.store.LoadState(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
z := st.Zones[zoneID]
|
||||
if z == nil {
|
||||
return nil, errf(KindNotFound, "zone %d not found", zoneID)
|
||||
}
|
||||
if z.Type != model.ZonePrimary {
|
||||
return nil, errf(KindBadRequest, "only primary zones are signed by Jarl")
|
||||
}
|
||||
if !p.Can(zoneAction(st, z, true)) {
|
||||
return nil, errf(KindForbidden, "permission denied: this needs write access to zone %s", zoneDesc(st, z))
|
||||
}
|
||||
out := &WipeResult{Removed: []string{}, DryRun: opts.DryRun}
|
||||
dir := render.KeyDir(st, z, e.cfg.Paths)
|
||||
keyFiles, err := dnssec.Files(dir, z.Name)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("reading the key directory: %w", err)
|
||||
}
|
||||
zoneFile := render.ZoneFilePath(st, z, e.cfg.Paths)
|
||||
var signed []string
|
||||
for _, f := range dnssec.DerivedFiles(zoneFile) {
|
||||
if strings.Contains(filepath.Base(f), ".signed") {
|
||||
signed = append(signed, f)
|
||||
}
|
||||
}
|
||||
if z.DNSSECPolicy == "" && len(keyFiles) == 0 && len(signed) == 0 {
|
||||
return nil, errf(KindConflict, "zone %s isn't signed and has no keys or signed files left: there is nothing to wipe", zoneDesc(st, z))
|
||||
}
|
||||
if z.DNSSECPolicy != "" {
|
||||
opts.wipeZone = z.ID
|
||||
res, err := e.Submit(ctx, []Request{NewRequest(OpZoneUpdate, z.ID, ZoneInput{DNSSECPolicy: model.Ptr("")})}, p, via, "wipe DNSSEC from zone "+zoneDesc(st, z), opts)
|
||||
out.Result = res
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
}
|
||||
for _, f := range keyFiles {
|
||||
out.Removed = append(out.Removed, filepath.Join(dir, f))
|
||||
}
|
||||
out.Removed = append(out.Removed, signed...)
|
||||
if opts.DryRun {
|
||||
return out, nil
|
||||
}
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
var failed []string
|
||||
for _, f := range out.Removed {
|
||||
if err := os.Remove(f); err != nil && !errors.Is(err, fs.ErrNotExist) {
|
||||
failed = append(failed, err.Error())
|
||||
}
|
||||
}
|
||||
msg := fmt.Sprintf("%d files removed", len(out.Removed))
|
||||
if len(failed) > 0 {
|
||||
e.audit(ctx, p, "dnssec.wipe", "zone", z.ID, zoneDesc(st, z), 0, "failure", strings.Join(failed, "; "), nil)
|
||||
return out, fmt.Errorf("signing is off, but removing files failed: %s", strings.Join(failed, "; "))
|
||||
}
|
||||
e.audit(ctx, p, "dnssec.wipe", "zone", z.ID, zoneDesc(st, z), 0, "success", msg, nil)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// MarkDS tells named that the parent zone now has (published) or no
|
||||
// longer has (withdrawn) the DS record of each key, as a DS check found.
|
||||
// named moves to the next step of signing or unsigning the zone once it
|
||||
// knows, and (unless it can query the parent itself) waits until told.
|
||||
func (e *Engine) MarkDS(ctx context.Context, st *model.State, z *model.Zone, p *auth.Principal, published, withdrawn []*dnssec.Key) ([]string, error) {
|
||||
if e.cfg.NoReload || e.tools == nil {
|
||||
return nil, errf(KindConflict, "jarld doesn't control named here (bind.no_reload)")
|
||||
}
|
||||
var out []string
|
||||
run := func(k *dnssec.Key, pub bool) error {
|
||||
msg, err := e.tools.CheckDS(ctx, z.Name, e.namedView(st, z), k.Tag, k.Algorithm, pub)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out = append(out, fmt.Sprintf("key %d: %s", k.Tag, strings.TrimSpace(msg)))
|
||||
return nil
|
||||
}
|
||||
for _, k := range published {
|
||||
if err := run(k, true); err != nil {
|
||||
return out, err
|
||||
}
|
||||
}
|
||||
for _, k := range withdrawn {
|
||||
if err := run(k, false); err != nil {
|
||||
return out, err
|
||||
}
|
||||
}
|
||||
if len(out) > 0 {
|
||||
e.audit(ctx, p, "dnssec.checkds", "zone", z.ID, zoneDesc(st, z), 0, "success", strings.Join(out, "; "), nil)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"jarl/internal/dnssec"
|
||||
"jarl/internal/model"
|
||||
"jarl/internal/render"
|
||||
"jarl/internal/store"
|
||||
)
|
||||
|
||||
// waitPhase polls the zone's DNSSEC status until ok accepts it.
|
||||
func (n *namedEnv) waitPhase(z *model.Zone, what string, ok func(*dnssec.Status) bool) *dnssec.Status {
|
||||
n.t.Helper()
|
||||
deadline := time.Now().Add(15 * time.Second)
|
||||
for {
|
||||
st := n.state()
|
||||
s := n.e.DNSSEC(n.ctx, st, st.Zones[z.ID])
|
||||
if ok(s) {
|
||||
return s
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
n.t.Fatalf("waiting for %s: %+v (phase %+v)", what, s, s.Phase)
|
||||
}
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDNSSECWithRealNamed(t *testing.T) {
|
||||
n := newNamedEnv(t)
|
||||
z := n.state().ZonesNamed("example.test")[0]
|
||||
zoneFile := filepath.Join(n.dir, "zones/_default/example.test.zone")
|
||||
|
||||
// Switching signing on for an existing zone: named makes a key and
|
||||
// signs the zone (inline signing, keeping Jarl's file as it is).
|
||||
n.submit(n.admin, NewRequest(OpZoneUpdate, z.ID, ZoneInput{DNSSECPolicy: model.Ptr("default")}))
|
||||
conf, _ := os.ReadFile(n.e.cfg.Paths.MainConfig())
|
||||
if !strings.Contains(string(conf), `dnssec-policy "default";`) || !strings.Contains(string(conf), `key-directory "`+filepath.Join(n.dir, "zones/dnssec-keys/_default")+`";`) {
|
||||
t.Errorf("named.conf:\n%s", conf)
|
||||
}
|
||||
s := n.waitPhase(z, "signing", func(s *dnssec.Status) bool { return len(s.Keys) == 1 && s.Named != nil && s.Named.Secure })
|
||||
k := s.Keys[0]
|
||||
if s.Phase.Code != dnssec.PhaseSigning || k.Role != "CSK" || k.Algorithm != 13 || k.DS == nil || s.Phase.DSReadyAt == nil {
|
||||
t.Errorf("status: %+v, key %+v", s.Phase, k)
|
||||
}
|
||||
if got := n.dig("example.test", "DNSKEY"); !strings.HasPrefix(got, "257 3 13 ") {
|
||||
t.Errorf("DNSKEY = %q", got)
|
||||
}
|
||||
// Changes still load (Jarl's serial is the unsigned zone's), and the
|
||||
// serial check sees the signed zone's serial.
|
||||
r := findRec(n.state(), "www.example.test.", "A")
|
||||
n.submit(n.admin, NewRequest(OpRecordUpdate, r.ID, RecordInput{Data: model.Ptr("192.0.2.12")}))
|
||||
if got := n.dig("www.example.test", "A"); got != "192.0.2.12" {
|
||||
t.Errorf("www = %q", got)
|
||||
}
|
||||
cur := n.state()
|
||||
served, ok, err := n.e.LoadedSerial(n.ctx, cur, cur.Zones[z.ID])
|
||||
if !ok || err != nil || !serialGreater(served, cur.Zones[z.ID].SOA.Serial) {
|
||||
t.Errorf("served serial %d (%v %v), Jarl's %d", served, ok, err, cur.Zones[z.ID].SOA.Serial)
|
||||
}
|
||||
|
||||
// Unsafe steps are refused.
|
||||
if e := n.submitErr(n.admin, NewRequest(OpZoneUpdate, z.ID, ZoneInput{DNSSECPolicy: model.Ptr("")})); !strings.Contains(e.Message, "switching DNSSEC off at once") {
|
||||
t.Errorf("switching off directly: %v", e)
|
||||
}
|
||||
if e := n.submitErr(n.admin, NewRequest(OpZoneDelete, z.ID, nil)); !strings.Contains(e.Message, "turn DNSSEC off before deleting") {
|
||||
t.Errorf("deleting: %v", e)
|
||||
}
|
||||
r = findRec(n.state(), "www.example.test.", "A")
|
||||
if e := n.submitErr(n.admin, NewRequest(OpRecordUpdate, r.ID, RecordInput{TTL: model.Some(uint32(172800))})); !strings.Contains(e.Message, "max-zone-ttl") {
|
||||
t.Errorf("long TTL: %v", e)
|
||||
}
|
||||
|
||||
// A restore of an older database: Jarl's serial is now below named's,
|
||||
// which named refuses for a signed zone. Resync raises it.
|
||||
before := n.state()
|
||||
older := before.Clone()
|
||||
older.Zones[z.ID].SOA.Serial -= 5
|
||||
findRec(older, "www.example.test.", "A").Data = "192.0.2.10"
|
||||
if err := n.st.InTx(n.ctx, func(tx *store.Tx) error { return tx.SaveState(before, older) }); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
res, err := n.e.Resync(n.ctx, n.admin, ApplyOptions{Force: true})
|
||||
if err != nil {
|
||||
t.Fatalf("resync: %v", err)
|
||||
}
|
||||
if len(res.Serials) != 1 || !serialGreater(res.Serials[0].New, before.Zones[z.ID].SOA.Serial) {
|
||||
t.Errorf("serials: %+v (named had %d)", res.Serials, before.Zones[z.ID].SOA.Serial)
|
||||
}
|
||||
if got := n.dig("www.example.test", "A"); got != "192.0.2.10" {
|
||||
t.Errorf("www after restore = %q", got)
|
||||
}
|
||||
if n.state().Zones[z.ID].SOA.Serial != res.Serials[0].New {
|
||||
t.Error("raised serial not saved")
|
||||
}
|
||||
|
||||
// Turning off: insecure first. The DS was never at the parent, so
|
||||
// named removes the keys at once and finishing is allowed.
|
||||
n.submit(n.admin, NewRequest(OpZoneUpdate, z.ID, ZoneInput{DNSSECPolicy: model.Ptr("insecure")}))
|
||||
n.waitPhase(z, "removal", func(s *dnssec.Status) bool { return s.Phase.CanFinish })
|
||||
n.submit(n.admin, NewRequest(OpZoneUpdate, z.ID, ZoneInput{DNSSECPolicy: model.Ptr("")}))
|
||||
if got := n.dig("example.test", "DNSKEY"); got != "" {
|
||||
t.Errorf("DNSKEY after switching off = %q", got)
|
||||
}
|
||||
s = n.waitPhase(z, "leftover keys", func(s *dnssec.Status) bool { return true })
|
||||
if s.Phase.Code != dnssec.PhaseLeftover {
|
||||
t.Errorf("after switching off: %+v", s.Phase)
|
||||
}
|
||||
|
||||
// Signing again makes a new key (the old one stays retired); a wipe
|
||||
// switches off at once and deletes keys and signed files.
|
||||
n.submit(n.admin, NewRequest(OpZoneUpdate, z.ID, ZoneInput{DNSSECPolicy: model.Ptr("default")}))
|
||||
s = n.waitPhase(z, "signing again", func(s *dnssec.Status) bool { return len(s.Keys) == 2 && s.Named != nil && s.Named.Secure })
|
||||
if s.Phase.Code != dnssec.PhaseSigning || s.Keys[0].Tag == k.Tag && s.Keys[0].Goal == dnssec.Omnipresent {
|
||||
t.Errorf("signing again: %+v %+v", s.Phase, s.Keys[0])
|
||||
}
|
||||
if _, err := os.Stat(zoneFile + ".signed"); err != nil {
|
||||
t.Errorf("signed copy: %v", err)
|
||||
}
|
||||
w, err := n.e.WipeDNSSEC(n.ctx, z.ID, n.admin, "test", ApplyOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("wipe: %v", err)
|
||||
}
|
||||
if len(w.Removed) < 4 || w.Result == nil || !w.Result.Applied {
|
||||
t.Errorf("wipe: %+v", w)
|
||||
}
|
||||
if files, _ := dnssec.Files(render.KeyDir(n.state(), z, n.e.cfg.Paths), z.Name); len(files) != 0 {
|
||||
t.Errorf("keys left: %v", files)
|
||||
}
|
||||
if _, err := os.Stat(zoneFile + ".signed"); !os.IsNotExist(err) {
|
||||
t.Errorf("signed copy left: %v", err)
|
||||
}
|
||||
if got := n.dig("www.example.test", "A"); got != "192.0.2.10" {
|
||||
t.Errorf("www after wipe = %q", got)
|
||||
}
|
||||
if _, err := n.e.WipeDNSSEC(n.ctx, z.ID, n.admin, "test", ApplyOptions{}); err == nil {
|
||||
t.Error("a second wipe found something")
|
||||
}
|
||||
// Deleting the zone also removes named's journal.
|
||||
n.submit(n.admin, NewRequest(OpZoneDelete, z.ID, nil))
|
||||
if left, _ := filepath.Glob(zoneFile + "*"); len(left) != 0 {
|
||||
t.Errorf("left after deleting the zone: %v", left)
|
||||
}
|
||||
}
|
||||
+133
-20
@@ -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(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -122,6 +138,8 @@ type ApplyOptions struct {
|
||||
Force bool
|
||||
// NoReload writes the files without reloading named.
|
||||
NoReload bool
|
||||
// wipeZone lets WipeDNSSEC switch signing off for this zone at once.
|
||||
wipeZone int64
|
||||
}
|
||||
|
||||
func (e *Engine) replay(base *model.State, ops []model.Op, p *auth.Principal) (*model.State, []string, error) {
|
||||
@@ -381,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
|
||||
@@ -397,11 +416,11 @@ func (pl *plan) files() []string {
|
||||
return out
|
||||
}
|
||||
|
||||
func (e *Engine) makePlan(ctx context.Context, base, work *model.State, user string) (*plan, error) {
|
||||
func (e *Engine) makePlan(ctx context.Context, base, work *model.State, user string, opts ApplyOptions) (*plan, error) {
|
||||
pl := &plan{base: base, work: work, verify: map[int64]uint32{}}
|
||||
before := e.validate(base)
|
||||
after := e.validate(work)
|
||||
pl.introduced = validate.Introduced(before, after)
|
||||
pl.introduced = append(validate.Introduced(before, after), e.dnssecChanges(base, work, opts)...)
|
||||
introducedKeys := map[string]bool{}
|
||||
for _, p := range pl.introduced {
|
||||
introducedKeys[p.Key()] = true
|
||||
@@ -415,7 +434,13 @@ func (e *Engine) makePlan(ctx context.Context, base, work *model.State, user str
|
||||
return pl, nil
|
||||
}
|
||||
pl.serials = bumpSerials(base, work, e.Now(), user)
|
||||
pl.out = render.Render(work, e.cfg.Paths, e.cfg.Extras)
|
||||
guarded, problems := e.guardSerials(ctx, base, work, pl.serials, user)
|
||||
pl.serials = append(pl.serials, guarded...)
|
||||
if validate.HasErrors(problems) {
|
||||
pl.introduced = append(pl.introduced, problems...)
|
||||
return pl, nil
|
||||
}
|
||||
pl.out = render.Render(work, e.cfg.Paths, e.extras(ctx))
|
||||
deployed, err := e.store.DeployedFiles(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -438,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)
|
||||
@@ -451,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
|
||||
@@ -476,6 +501,16 @@ 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 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.Contains(baseOut.Policy, z) {
|
||||
pl.reloadPolicy = append(pl.reloadPolicy, z)
|
||||
}
|
||||
}
|
||||
|
||||
if !e.cfg.SkipCheck {
|
||||
if err := e.stagingCheck(ctx, pl); err != nil {
|
||||
@@ -497,7 +532,12 @@ 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
|
||||
}
|
||||
}
|
||||
for _, f := range sout.Files {
|
||||
if err := os.MkdirAll(filepath.Dir(f.Path), 0o750); err != nil {
|
||||
return err
|
||||
@@ -506,6 +546,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
|
||||
@@ -635,7 +685,7 @@ func (e *Engine) Preview(ctx context.Context, csID int64, p *auth.Principal) (*R
|
||||
res.Error = ee
|
||||
return res, nil
|
||||
}
|
||||
pl, err := e.makePlan(ctx, base, work, p.Name())
|
||||
pl, err := e.makePlan(ctx, base, work, p.Name(), ApplyOptions{})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -714,7 +764,7 @@ func (e *Engine) applyOps(ctx context.Context, cs *model.Changeset, reqs []Reque
|
||||
cs.Description += fmt.Sprintf(" (+%d more)", len(cs.Ops)-1)
|
||||
}
|
||||
}
|
||||
pl, err := e.makePlan(ctx, base, work, p.Name())
|
||||
pl, err := e.makePlan(ctx, base, work, p.Name(), opts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -782,6 +832,13 @@ 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
|
||||
}
|
||||
@@ -815,6 +872,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
|
||||
@@ -846,6 +906,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
|
||||
@@ -871,6 +936,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)
|
||||
@@ -956,15 +1031,16 @@ func (e *Engine) Resync(ctx context.Context, p *auth.Principal, opts ApplyOption
|
||||
return nil, err
|
||||
}
|
||||
work := base.Clone()
|
||||
pl, err := e.makePlan(ctx, base, work, p.Name())
|
||||
pl, err := e.makePlan(ctx, base, work, p.Name(), opts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
res := pl.result()
|
||||
res.DryRun = opts.DryRun
|
||||
if validate.HasErrors(pl.existing) {
|
||||
if validate.HasErrors(pl.existing) || validate.HasErrors(pl.introduced) {
|
||||
// Resync deploys the stored state as is; it must be valid.
|
||||
return res, &Error{Kind: KindValidation, Message: "the stored configuration has errors: " + describeErrors(pl.existing), Problems: validate.Errors(pl.existing)}
|
||||
ps := append(append([]validate.Problem{}, pl.existing...), pl.introduced...)
|
||||
return res, &Error{Kind: KindValidation, Message: "the stored configuration has errors: " + describeErrors(ps), Problems: validate.Errors(ps)}
|
||||
}
|
||||
if pl.check != nil && !pl.check.OK {
|
||||
return res, &Error{Kind: KindValidation, Message: "named-checkconf rejected the configuration: " + firstCheckError(pl.checkMsgs), Check: pl.checkMsgs}
|
||||
@@ -983,11 +1059,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
|
||||
@@ -998,15 +1080,26 @@ func (e *Engine) Resync(ctx context.Context, p *auth.Principal, opts ApplyOption
|
||||
}
|
||||
deployed := deployedList(pl.out, 0, e.Now())
|
||||
err = e.store.InTx(ctx, func(tx *store.Tx) error {
|
||||
// Serials raised above named's (guardSerials) are kept.
|
||||
if len(pl.serials) > 0 {
|
||||
if err := tx.SaveState(base, work); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := tx.SetDeployedFiles(deployed); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.AddAudit(e.auditEvent(p, "system.resync", "", 0, "", 0, "success", fmt.Sprintf("%d files written", len(res.Files)), nil))
|
||||
var details json.RawMessage
|
||||
if len(pl.serials) > 0 {
|
||||
details, _ = json.Marshal(map[string]any{"serials": pl.serials})
|
||||
}
|
||||
return tx.AddAudit(e.auditEvent(p, "system.resync", "", 0, "", 0, "success", fmt.Sprintf("%d files written", len(res.Files)), details))
|
||||
})
|
||||
if err != nil {
|
||||
return res, err
|
||||
}
|
||||
res.Applied = true
|
||||
e.removeDerived(pl)
|
||||
return res, nil
|
||||
}
|
||||
|
||||
@@ -1058,7 +1151,7 @@ func (e *Engine) CheckCurrent(ctx context.Context) (*Result, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
pl, err := e.makePlan(ctx, base, base.Clone(), "")
|
||||
pl, err := e.makePlan(ctx, base, base.Clone(), "", ApplyOptions{})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -1087,6 +1180,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)
|
||||
@@ -1130,7 +1231,7 @@ func (e *Engine) Status(ctx context.Context) (*Status, error) {
|
||||
}
|
||||
}
|
||||
}
|
||||
s.Problems = nonNilProblems(e.validate(st))
|
||||
s.Problems = nonNilProblems(append(e.validate(st), e.namedProblems(ctx, st, deployed)...))
|
||||
s.Counts = map[string]int{"views": len(st.Views), "zones": len(st.Zones), "records": len(st.Records), "acls": len(st.ACLs), "tsig_keys": len(st.Keys), "options": len(st.Options)}
|
||||
return s, nil
|
||||
}
|
||||
@@ -1192,8 +1293,19 @@ func (e *Engine) WorkingState(ctx context.Context, csID int64, p *auth.Principal
|
||||
}
|
||||
|
||||
// LoadedSerial returns the serial named serves for zone z, from rndc
|
||||
// zonestatus. ok is false when named isn't asked (no_reload) or doesn't
|
||||
// zonestatus: for a zone named signs, the signed zone's serial (which
|
||||
// secondaries and the rest of the world see), not that of the zone file
|
||||
// Jarl wrote. ok is false when named isn't asked (no_reload) or doesn't
|
||||
// report a serial (the zone isn't loaded yet).
|
||||
// LoadedSerialCommand is the command LoadedSerial runs, or "" when it
|
||||
// doesn't ask named.
|
||||
func (e *Engine) LoadedSerialCommand(st *model.State, z *model.Zone) string {
|
||||
if e.cfg.NoReload || e.tools == nil {
|
||||
return ""
|
||||
}
|
||||
return bind.ZoneCommand("zonestatus", z.Name, e.namedView(st, z))
|
||||
}
|
||||
|
||||
func (e *Engine) LoadedSerial(ctx context.Context, st *model.State, z *model.Zone) (serial uint32, ok bool, err error) {
|
||||
if e.cfg.NoReload || e.tools == nil {
|
||||
return 0, false, nil
|
||||
@@ -1202,5 +1314,6 @@ func (e *Engine) LoadedSerial(ctx context.Context, st *model.State, z *model.Zon
|
||||
if err != nil {
|
||||
return 0, false, err
|
||||
}
|
||||
return zs.Serial, zs.HasSerial, nil
|
||||
serial, ok = zs.ServedSerial()
|
||||
return serial, ok, nil
|
||||
}
|
||||
+209
-6
@@ -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,42 @@ 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
|
||||
}
|
||||
return nil, errf(KindBadRequest, "unknown operation %q", req.Op)
|
||||
}
|
||||
@@ -413,6 +457,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 +549,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 +566,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 +714,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 +730,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 +824,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 +1037,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)
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package engine
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
"jarl/internal/model"
|
||||
)
|
||||
@@ -29,12 +30,27 @@ 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"
|
||||
|
||||
// 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}
|
||||
|
||||
// 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"
|
||||
@@ -87,7 +103,13 @@ type ZoneInput struct {
|
||||
SOA *SOAInput `json:"soa,omitempty"`
|
||||
DefaultTTL *uint32 `json:"default_ttl,omitempty"`
|
||||
SerialPolicy *string `json:"serial_policy,omitempty"`
|
||||
Comment *string `json:"comment,omitempty"`
|
||||
// DNSSECPolicy turns signing on ("default" or a custom policy's
|
||||
// name), starts turning it off ("insecure") or, once that has
|
||||
// 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
|
||||
@@ -117,8 +139,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"`
|
||||
@@ -201,3 +225,37 @@ 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"`
|
||||
}
|
||||
|
||||
// BlockRuleInput creates or updates a block or allow rule. Views as for
|
||||
// BlocklistInput.
|
||||
type BlockRuleInput struct {
|
||||
Name *string `json:"name,omitempty"`
|
||||
Action *string `json:"action,omitempty"` // block (default) or allow
|
||||
Exact *bool `json:"exact,omitempty"` // only the name, not the names below it
|
||||
Views *[]string `json:"views,omitempty"`
|
||||
ViewIDs *[]int64 `json:"view_ids,omitempty"`
|
||||
Comment *string `json:"comment,omitempty"`
|
||||
}
|
||||
|
||||
// BlockingPauseInput pauses blocking until a time, or resumes it (null).
|
||||
type BlockingPauseInput struct {
|
||||
Until *time.Time `json:"until"`
|
||||
// Indefinitely pauses until someone resumes blocking (Until is nil).
|
||||
Indefinitely bool `json:"indefinitely,omitempty"`
|
||||
}
|
||||
|
||||
// MaxBlockingPause is the longest pause: blocking shouldn't stay off by
|
||||
// mistake (disable lists to stop using them).
|
||||
const MaxBlockingPause = 24 * time.Hour
|
||||
@@ -0,0 +1,77 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"jarl/internal/model"
|
||||
"jarl/internal/render"
|
||||
"jarl/internal/validate"
|
||||
)
|
||||
|
||||
// namedCheckTTL is how long the result of checking named's running
|
||||
// configuration is kept when nothing changed.
|
||||
const namedCheckTTL = 10 * time.Minute
|
||||
|
||||
// namedCheck caches the warnings of named-checkconf on the configuration
|
||||
// named runs, keyed by what it depends on.
|
||||
type namedCheck struct {
|
||||
mu sync.Mutex
|
||||
key string
|
||||
at time.Time
|
||||
problems []validate.Problem
|
||||
}
|
||||
|
||||
// namedProblems runs named-checkconf on the configuration named runs
|
||||
// (without loading zones, which is quick) and returns what it reports as
|
||||
// problems. An apply shows these too, but they are named's own checks,
|
||||
// which Jarl doesn't repeat ("both recursion no and allow-recursion
|
||||
// active"): the dashboard lists them with Jarl's so they don't go unseen
|
||||
// after the apply. The result is reused while the deployed files and
|
||||
// named's main file are unchanged.
|
||||
func (e *Engine) namedProblems(ctx context.Context, st *model.State, deployed map[string]*model.DeployedFile) []validate.Problem {
|
||||
if e.tools == nil || e.cfg.SkipCheck || e.cfg.MainConfig == "" || len(deployed) == 0 {
|
||||
return nil
|
||||
}
|
||||
key := ""
|
||||
if fi, err := os.Stat(e.cfg.MainConfig); err == nil {
|
||||
key = fi.ModTime().String()
|
||||
}
|
||||
if f := deployed[e.cfg.Paths.MainConfig()]; f != nil {
|
||||
key += "|" + f.SHA256
|
||||
}
|
||||
c := &e.namedCheck
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if c.key == key && e.Now().Sub(c.at) < namedCheckTTL {
|
||||
return c.problems
|
||||
}
|
||||
res, err := e.tools.CheckConfig(ctx, e.cfg.MainConfig, false)
|
||||
if err != nil {
|
||||
return nil // named-checkconf couldn't run; an apply reports that
|
||||
}
|
||||
out := render.Render(st, e.cfg.Paths, e.extras(ctx))
|
||||
var ps []validate.Problem
|
||||
for _, m := range res.Messages {
|
||||
p := validate.Problem{Severity: validate.Warning, Code: "named_warning", View: m.View, Zone: m.Zone}
|
||||
if m.Error {
|
||||
p.Severity, p.Code = validate.Error, "named_error"
|
||||
}
|
||||
where := ""
|
||||
if m.File != "" {
|
||||
where = fmt.Sprintf(" (%s:%d)", m.File, m.Line)
|
||||
}
|
||||
if span, ok := out.Lookup(m.File, m.Line); ok {
|
||||
p.ObjectType, p.ObjectID = span.ObjectType, span.ObjectID
|
||||
where = " (" + span.Label + ", " + where[2:]
|
||||
}
|
||||
p.Message = "named-checkconf: " + strings.TrimSpace(m.Text) + where
|
||||
ps = append(ps, p)
|
||||
}
|
||||
c.key, c.at, c.problems = key, e.Now(), ps
|
||||
return ps
|
||||
}
|
||||
+50
-4
@@ -98,6 +98,16 @@ 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 OpBlockingPause:
|
||||
err = applyBlockingPause(st, op, c)
|
||||
default:
|
||||
err = errf(KindBadRequest, "unknown operation %q", op.Kind)
|
||||
}
|
||||
@@ -230,6 +240,19 @@ 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) {
|
||||
case "none", "off":
|
||||
p = ""
|
||||
case model.DNSSECDefault, model.DNSSECInsecure:
|
||||
p = strings.ToLower(p)
|
||||
}
|
||||
z.DNSSECPolicy = p
|
||||
}
|
||||
if in.Comment != nil {
|
||||
z.Comment = *in.Comment
|
||||
}
|
||||
@@ -363,7 +386,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
|
||||
}
|
||||
@@ -412,6 +435,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)
|
||||
}
|
||||
@@ -488,6 +514,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
|
||||
}
|
||||
@@ -607,20 +636,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
|
||||
@@ -803,6 +844,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
|
||||
@@ -1083,6 +1127,8 @@ func NormalizeOption(st *model.State, scope string, scopeID int64, name, value s
|
||||
if (stmt.Kind == catalog.KindString) && !strings.HasPrefix(v, `"`) && !strings.ContainsAny(v, " \t{};") {
|
||||
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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,13 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"jarl/internal/model"
|
||||
"jarl/internal/render"
|
||||
"jarl/internal/validate"
|
||||
)
|
||||
|
||||
// serialGreater reports whether a > b in RFC 1982 serial number
|
||||
@@ -73,3 +76,59 @@ func bumpSerials(base, work *model.State, now time.Time, user string) []SerialCh
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// guardSerials makes sure every zone whose file is about to change gets
|
||||
// a serial above the one named has loaded. Normally Jarl's serial is
|
||||
// already higher, but not after restoring an older database or when the
|
||||
// files on disk were replaced: named refuses to load a signed zone's file
|
||||
// unless its serial went up (and keeps serving the old data), and
|
||||
// secondaries ignore a zone whose serial went down. Zones are bumped past
|
||||
// named's serial unless the user set the serial explicitly; an explicit
|
||||
// serial that a signed zone can't take is an error. named is only asked
|
||||
// when it controls the zone (not with no_reload).
|
||||
func (e *Engine) guardSerials(ctx context.Context, base, work *model.State, bumped []SerialChange, user string) ([]SerialChange, []validate.Problem) {
|
||||
if e.cfg.NoReload || e.tools == nil {
|
||||
return nil, nil
|
||||
}
|
||||
auto := map[int64]bool{}
|
||||
for _, c := range bumped {
|
||||
auto[c.ZoneID] = true
|
||||
}
|
||||
var out []SerialChange
|
||||
var problems []validate.Problem
|
||||
for _, z := range work.SortedZones() {
|
||||
if z.Type != model.ZonePrimary {
|
||||
continue
|
||||
}
|
||||
old := base.Zones[z.ID]
|
||||
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) {
|
||||
continue // named keeps what it has
|
||||
}
|
||||
zs, err := e.tools.ZoneStatus(ctx, z.Name, e.namedView(work, z))
|
||||
if err != nil || !zs.HasSerial || serialGreater(z.SOA.Serial, zs.Serial) {
|
||||
continue
|
||||
}
|
||||
explicit := z.SOA.Serial != old.SOA.Serial && !auto[z.ID]
|
||||
if explicit {
|
||||
if z.Signed() {
|
||||
problems = append(problems, validate.Problem{Severity: validate.Error, Code: "serial_not_higher", ObjectType: "zone", ObjectID: z.ID, Zone: z.Name, View: work.ViewName(z),
|
||||
Message: fmt.Sprintf("named only loads a signed zone's file when its serial goes up, and it has serial %d loaded; set a higher serial (or leave it to Jarl)", zs.Serial)})
|
||||
}
|
||||
continue
|
||||
}
|
||||
policy := z.SerialPolicy
|
||||
if policy == "" {
|
||||
policy = work.Settings.SerialPolicy
|
||||
}
|
||||
next := NextSerial(zs.Serial, policy, e.Now())
|
||||
out = append(out, SerialChange{ZoneID: z.ID, Zone: z.Name, View: work.ViewName(z), Old: z.SOA.Serial, New: next})
|
||||
e.log.Info("raising a zone's serial above the one named has loaded", "zone", z.Name, "view", work.ViewName(z), "jarl", z.SOA.Serial, "named", zs.Serial, "new", next)
|
||||
z.SOA.Serial = next
|
||||
z.Touch(user, e.Now())
|
||||
}
|
||||
return out, problems
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
|
||||
"jarl/internal/model"
|
||||
"jarl/internal/nscheck"
|
||||
"jarl/internal/render"
|
||||
"jarl/internal/store"
|
||||
)
|
||||
|
||||
// viewKeySeedMeta is where the seed of the view keys is kept.
|
||||
const viewKeySeedMeta = "view_key_seed"
|
||||
|
||||
// ViewKeySeed returns the seed the view keys' secrets are derived from
|
||||
// (render/viewkeys.go), creating it the first time.
|
||||
func ViewKeySeed(ctx context.Context, st *store.Store) ([]byte, error) {
|
||||
v, err := st.GetMeta(ctx, viewKeySeedMeta)
|
||||
if err == nil {
|
||||
return base64.StdEncoding.DecodeString(v)
|
||||
}
|
||||
if !errors.Is(err, store.ErrNotFound) {
|
||||
return nil, err
|
||||
}
|
||||
seed := make([]byte, 32)
|
||||
if _, err := rand.Read(seed); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := st.SetMeta(ctx, viewKeySeedMeta, base64.StdEncoding.EncodeToString(seed)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return seed, nil
|
||||
}
|
||||
|
||||
// extras returns what jarld's configuration adds to the rendered
|
||||
// configuration, with the view keys' seed.
|
||||
func (e *Engine) extras(ctx context.Context) render.Extras {
|
||||
x := e.cfg.Extras
|
||||
e.seedMu.Lock()
|
||||
defer e.seedMu.Unlock()
|
||||
if e.seed == nil {
|
||||
seed, err := ViewKeySeed(ctx, e.store)
|
||||
if err != nil {
|
||||
e.log.Warn("view keys: reading their seed failed", "err", err)
|
||||
return x
|
||||
}
|
||||
e.seed = seed
|
||||
}
|
||||
x.ViewKeySeed = e.seed
|
||||
return x
|
||||
}
|
||||
|
||||
// ViewKey returns the TSIG key that selects a zone's view on this server
|
||||
// (nil without views).
|
||||
func (e *Engine) ViewKey(ctx context.Context, st *model.State, z *model.Zone) *nscheck.Key {
|
||||
v := st.Views[z.ViewID]
|
||||
if v == nil {
|
||||
return nil
|
||||
}
|
||||
x := e.extras(ctx)
|
||||
if len(x.ViewKeySeed) == 0 {
|
||||
return nil
|
||||
}
|
||||
return &nscheck.Key{Name: render.ViewKeyName(v.Name), Algorithm: render.ViewKeyAlgorithm, Secret: render.ViewKeySecret(x.ViewKeySeed, v.ID)}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"jarl/internal/model"
|
||||
)
|
||||
|
||||
func TestZoneDisable(t *testing.T) {
|
||||
env := newEnv(t)
|
||||
setupZone(env)
|
||||
z := env.state().ZonesNamed("example.com")[0]
|
||||
confPath := filepath.Join(env.dir, "etc/named.conf")
|
||||
zonePath := filepath.Join(env.dir, "zones/_default/example.com.zone")
|
||||
|
||||
res := env.submit(env.admin, NewRequest(OpZoneUpdate, z.ID, ZoneInput{Disabled: model.Ptr(true)}))
|
||||
if res.Ops[0].Summary != "disable zone example.com" || len(res.Warnings) != 1 || !strings.Contains(res.Warnings[0], "recursion") {
|
||||
t.Errorf("disable: %q, warnings %v", res.Ops[0].Summary, res.Warnings)
|
||||
}
|
||||
conf, _ := os.ReadFile(confPath)
|
||||
if strings.Contains(string(conf), "zone \"example.com\" {") || !strings.Contains(string(conf), `// zone "example.com" is disabled in Jarl`) {
|
||||
t.Errorf("named.conf with the zone disabled:\n%s", conf)
|
||||
}
|
||||
// Still edited, and its file kept up to date.
|
||||
env.submit(env.admin, rec("example.com", "www", "A", "192.0.2.10"))
|
||||
if f, err := os.ReadFile(zonePath); err != nil || !strings.Contains(string(f), "www\t\tIN\tA\t192.0.2.10") {
|
||||
t.Errorf("zone file while disabled: %v\n%s", err, f)
|
||||
}
|
||||
|
||||
// Enabling brings it back.
|
||||
res = env.submit(env.admin, NewRequest(OpZoneUpdate, z.ID, ZoneInput{Disabled: model.Ptr(false)}))
|
||||
if res.Ops[0].Summary != "enable zone example.com" {
|
||||
t.Errorf("enable: %q", res.Ops[0].Summary)
|
||||
}
|
||||
if conf, _ := os.ReadFile(confPath); !strings.Contains(string(conf), "zone \"example.com\" {") {
|
||||
t.Errorf("named.conf after enabling:\n%s", conf)
|
||||
}
|
||||
// Reverting a disable enables the zone again.
|
||||
res = env.submit(env.admin, NewRequest(OpZoneUpdate, z.ID, ZoneInput{Disabled: model.Ptr(true)}))
|
||||
rev, err := env.e.Revert(env.ctx, res.ChangesetID, env.admin)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := env.e.Apply(env.ctx, rev.ID, env.admin, ApplyOptions{}); err != nil || env.state().Zones[z.ID].Disabled {
|
||||
t.Errorf("revert of a disable: %v, disabled=%v", err, env.state().Zones[z.ID].Disabled)
|
||||
}
|
||||
|
||||
// Signed zones can't be disabled.
|
||||
env.submit(env.admin, NewRequest(OpZoneUpdate, z.ID, ZoneInput{DNSSECPolicy: model.Ptr("default")}))
|
||||
if e := env.submitErr(env.admin, NewRequest(OpZoneUpdate, z.ID, ZoneInput{Disabled: model.Ptr(true)})); e.Kind != KindValidation || !strings.Contains(e.Message, "signed zone can't be disabled") {
|
||||
t.Errorf("disabling a signed zone: %+v", e)
|
||||
}
|
||||
}
|
||||
|
||||
func TestZoneDisableInView(t *testing.T) {
|
||||
env := newEnv(t)
|
||||
setupZone(env)
|
||||
env.submit(env.admin,
|
||||
NewRequest(OpViewCreate, 0, ViewInput{Name: model.Ptr("internal"), MatchClients: &[]string{"10.0.0.0/8"}, AdoptZones: true}),
|
||||
NewRequest(OpViewCreate, 0, ViewInput{Name: model.Ptr("external")}),
|
||||
)
|
||||
st := env.state()
|
||||
ext := st.ViewByName("external")
|
||||
env.submit(env.admin, NewRequest(OpZoneCreate, 0, ZoneInput{Name: model.Ptr("example.com"), ViewID: &ext.ID, Type: model.Ptr(model.ZoneInView), InView: model.Ptr("internal")}))
|
||||
in := st.ZonesNamed("example.com")[0]
|
||||
// The zone another view shares can't be disabled alone.
|
||||
if e := env.submitErr(env.admin, NewRequest(OpZoneUpdate, in.ID, ZoneInput{Disabled: model.Ptr(true)})); e.Kind != KindValidation || !strings.Contains(e.Message, "is disabled, so this zone can't share it") {
|
||||
t.Errorf("disabling a shared zone: %+v", e)
|
||||
}
|
||||
// Without recursion, the warning says REFUSED.
|
||||
env.submit(env.admin, NewRequest(OpOptionCreate, 0, OptionInput{Scope: model.Ptr(model.ScopeView), ScopeID: &ext.ID, Name: model.Ptr("recursion"), Value: model.Ptr("no")}))
|
||||
env.submit(env.admin, NewRequest(OpZoneCreate, 0, ZoneInput{Name: model.Ptr("public.example"), ViewID: &ext.ID}))
|
||||
pub := env.state().FindZone("public.example", ext.ID)
|
||||
res := env.submit(env.admin, NewRequest(OpZoneUpdate, pub.ID, ZoneInput{Disabled: model.Ptr(true)}))
|
||||
if len(res.Warnings) != 1 || !strings.Contains(res.Warnings[0], "view external answers REFUSED") {
|
||||
t.Errorf("warnings = %v", res.Warnings)
|
||||
}
|
||||
}
|
||||
@@ -61,6 +61,9 @@ type importer struct {
|
||||
dir string // the directory option, for relative zone file names
|
||||
seen map[string]bool
|
||||
ctrl map[string]bool // key names used by controls
|
||||
// policies are dnssec-policy statements of the options block (key
|
||||
// "options") and of views ("view:ID"), which Jarl sets per zone.
|
||||
policies map[string]string
|
||||
}
|
||||
|
||||
func (im *importer) warn(format string, args ...any) {
|
||||
@@ -90,7 +93,7 @@ func (im *importer) meta() model.Meta {
|
||||
|
||||
// Import reads the configuration starting at mainPath.
|
||||
func Import(ctx context.Context, mainPath string, opts Options) (*model.State, *Report, error) {
|
||||
im := &importer{ctx: ctx, opts: opts, st: model.NewState(), rep: &Report{}, seen: map[string]bool{}, ctrl: map[string]bool{}}
|
||||
im := &importer{ctx: ctx, opts: opts, st: model.NewState(), rep: &Report{}, seen: map[string]bool{}, ctrl: map[string]bool{}, policies: map[string]string{}}
|
||||
stmts, err := im.readFile(mainPath)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
@@ -116,6 +119,7 @@ func Import(ctx context.Context, mainPath string, opts Options) (*model.State, *
|
||||
return nil, nil, err
|
||||
}
|
||||
}
|
||||
im.dnssecZones()
|
||||
im.rep.Counts = map[string]int{"views": len(im.st.Views), "zones": len(im.st.Zones), "records": len(im.st.Records), "acls": len(im.st.ACLs), "tsig_keys": len(im.st.Keys), "options": len(im.st.Options)}
|
||||
sort.Slice(im.rep.Zones, func(i, j int) bool {
|
||||
a, b := im.rep.Zones[i], im.rep.Zones[j]
|
||||
@@ -246,6 +250,20 @@ func (im *importer) keepInclude(path string) {
|
||||
}
|
||||
|
||||
func (im *importer) addOption(scope string, scopeID int64, name, value, comment string) {
|
||||
switch {
|
||||
case name == "dnssec-policy" && scope == model.ScopeOptions:
|
||||
im.policies["options"] = value
|
||||
return
|
||||
case name == "dnssec-policy" && scope == model.ScopeView:
|
||||
im.policies[fmt.Sprintf("view:%d", scopeID)] = value
|
||||
return
|
||||
case name == "dnssec-policy" && scope == model.ScopeZone:
|
||||
im.policies[fmt.Sprintf("zone:%d", scopeID)] = value
|
||||
return
|
||||
case name == "key-directory" && scope == model.ScopeZone:
|
||||
// Jarl chooses signed zones' key directories (see dnssecKeys).
|
||||
return
|
||||
}
|
||||
id, err := im.id()
|
||||
if err != nil {
|
||||
return
|
||||
@@ -259,6 +277,45 @@ func (im *importer) addOption(scope string, scopeID int64, name, value, comment
|
||||
im.st.Options[id] = &model.Option{ID: id, Scope: scope, ScopeID: scopeID, Name: name, Value: value, Position: pos, Comment: comment, Meta: im.meta()}
|
||||
}
|
||||
|
||||
// policyName turns a dnssec-policy value into a zone's DNSSECPolicy.
|
||||
func policyName(v string) string {
|
||||
v = strings.Trim(strings.TrimSpace(v), `"`)
|
||||
switch l := strings.ToLower(v); l {
|
||||
case "none":
|
||||
return ""
|
||||
case model.DNSSECDefault, model.DNSSECInsecure:
|
||||
return l
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// dnssecZones gives primary zones the dnssec-policy they inherit from
|
||||
// their view or the options block (Jarl sets it per zone), and warns
|
||||
// about signed zones' keys: Jarl keeps them in its own key directory, and
|
||||
// unless the existing keys are moved there, named makes new ones that the
|
||||
// parent's DS record doesn't match.
|
||||
func (im *importer) dnssecZones() {
|
||||
for _, z := range im.st.SortedZones() {
|
||||
v, ok := im.policies[fmt.Sprintf("zone:%d", z.ID)]
|
||||
if !ok {
|
||||
v, ok = im.policies[fmt.Sprintf("view:%d", z.ViewID)]
|
||||
}
|
||||
if !ok {
|
||||
v = im.policies["options"]
|
||||
}
|
||||
if z.Type != model.ZonePrimary {
|
||||
if p := policyName(v); p != "" {
|
||||
im.warn("zone %s: named signs this %s zone (dnssec-policy %s); Jarl only signs primary zones, so the setting was dropped", dnsname.Display(z.Name), z.Type, p)
|
||||
}
|
||||
continue
|
||||
}
|
||||
z.DNSSECPolicy = policyName(v)
|
||||
if z.DNSSECPolicy != "" {
|
||||
im.warn("zone %s is signed (dnssec-policy %s). Before 'jarld adopt', copy its key files (K%s+*) into the key directory Jarl uses for it ('jarl zone dnssec status %s' shows it), owned by named's user; otherwise named makes new keys, and if the parent zone has a DS record for the old ones, the zone stops validating (docs/dnssec.md#adopting-a-signed-zone)", dnsname.Display(z.Name), z.DNSSECPolicy, z.Name, dnsname.Display(z.Name))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func restText(s *namedconf.Stmt) string {
|
||||
return namedconf.Compact(s.Items[1:])
|
||||
}
|
||||
@@ -336,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))
|
||||
@@ -376,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 {
|
||||
@@ -477,7 +538,7 @@ func (im *importer) zone(s *namedconf.Stmt, v *model.View) error {
|
||||
}
|
||||
report.Records, report.Skipped = n, skipped
|
||||
for _, o := range opts {
|
||||
if kw := o.Keyword(); kw == "allow-update" || kw == "update-policy" {
|
||||
if kw := o.Keyword(); kw == "update-policy" || kw == "allow-update" && !allowsNone(o) {
|
||||
im.warn("zone %s uses %s (dynamic updates); Jarl rewrites zone files and won't apply such zones until %s is removed", dnsname.Display(z.Name), kw, kw)
|
||||
}
|
||||
}
|
||||
@@ -735,3 +796,38 @@ func ParseZoneText(zone, text string) (*ParsedZone, error) {
|
||||
}
|
||||
return pz, nil
|
||||
}
|
||||
|
||||
// allowsNone says whether an address match list option is { none; }
|
||||
// (allow-update { none; } turns dynamic updates off).
|
||||
func allowsNone(o *namedconf.Stmt) bool {
|
||||
b := o.FirstBlock()
|
||||
if b == nil {
|
||||
return false
|
||||
}
|
||||
for _, s := range b.Stmts {
|
||||
if namedconf.Compact(s.Items) != "none" {
|
||||
return false
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
@@ -146,3 +146,74 @@ func TestParseTTL(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestImportAllowUpdateWarning(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
os.WriteFile(filepath.Join(dir, "x.db"), []byte("$TTL 300\n@ IN SOA ns1.x.test. h.x.test. 1 2 3 4 5\n@ IN NS ns1\nns1 IN A 192.0.2.1\n"), 0o644)
|
||||
for conf, want := range map[string]bool{
|
||||
`allow-update { none; };`: false,
|
||||
`allow-update { 127.0.0.1; };`: true,
|
||||
`update-policy local;`: true,
|
||||
} {
|
||||
os.WriteFile(filepath.Join(dir, "named.conf"), []byte(`zone "x.test" { type primary; file "`+dir+`/x.db"; `+conf+` };`), 0o644)
|
||||
_, rep, err := Import(context.Background(), filepath.Join(dir, "named.conf"), Options{Alloc: seqAlloc()})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := len(rep.Warnings) > 0; got != want {
|
||||
t.Errorf("%s: warnings = %v", conf, rep.Warnings)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestImportSignedZones(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
zone := "$TTL 300\n@ IN SOA ns1 h 1 2 3 4 5\n@ IN NS ns1\nns1 IN A 192.0.2.1\n"
|
||||
for _, n := range []string{"a", "b", "c"} {
|
||||
os.WriteFile(filepath.Join(dir, n+".db"), []byte(strings.ReplaceAll(zone, "ns1", "ns1."+n+".test.")), 0o644)
|
||||
}
|
||||
conf := `options { directory "` + dir + `"; dnssec-policy default; key-directory "/var/cache/bind/keys"; };
|
||||
view "external" {
|
||||
zone "a.test" { type primary; file "a.db"; key-directory "/elsewhere"; };
|
||||
zone "b.test" { type primary; file "b.db"; dnssec-policy none; };
|
||||
};
|
||||
view "internal" {
|
||||
dnssec-policy "none";
|
||||
zone "c.test" { type primary; file "c.db"; };
|
||||
};`
|
||||
os.WriteFile(filepath.Join(dir, "named.conf"), []byte(conf), 0o644)
|
||||
st, rep, err := Import(context.Background(), filepath.Join(dir, "named.conf"), Options{Alloc: seqAlloc()})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
policy := map[string]string{}
|
||||
for _, z := range st.Zones {
|
||||
policy[z.Name] = z.DNSSECPolicy
|
||||
}
|
||||
if policy["a.test."] != "default" || policy["b.test."] != "" || policy["c.test."] != "" {
|
||||
t.Errorf("policies: %v", policy)
|
||||
}
|
||||
for _, o := range st.Options {
|
||||
if o.Name == "dnssec-policy" || o.Scope == model.ScopeZone && o.Name == "key-directory" {
|
||||
t.Errorf("option kept: %+v", o)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(strings.Join(rep.Warnings, "\n"), "zone a.test is signed (dnssec-policy default). Before 'jarld adopt', copy its key files (Ka.test.+*)") {
|
||||
t.Errorf("warnings: %v", rep.Warnings)
|
||||
}
|
||||
if ps := validate.Errors(validate.State(st, validate.Options{})); len(ps) != 0 {
|
||||
t.Errorf("imported state has errors: %v", ps)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWithoutViewKeys(t *testing.T) {
|
||||
for _, tc := range []struct{ in, want []string }{
|
||||
{[]string{"!key jarl-view-external", `key "jarl-view-internal"`, "homelab"}, []string{"homelab"}},
|
||||
{[]string{"key jarl-view-external", "any"}, []string{}},
|
||||
{[]string{"key xfer", "any"}, []string{"key xfer", "any"}},
|
||||
} {
|
||||
if got := withoutViewKeys(tc.in); strings.Join(got, ";") != strings.Join(tc.want, ";") || got == nil {
|
||||
t.Errorf("%v: %v, want %v", tc.in, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// 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"`
|
||||
Comment string `json:"comment"`
|
||||
Meta
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
@@ -70,8 +70,17 @@ type User struct {
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
LastLoginAt *time.Time `json:"last_login_at,omitempty"`
|
||||
// TOTPEnabledAt is when the user turned on two-factor login (nil:
|
||||
// off). The secrets never leave the server.
|
||||
TOTPEnabledAt *time.Time `json:"totp_enabled_at,omitempty"`
|
||||
TOTPSecret string `json:"-"`
|
||||
TOTPPending string `json:"-"`
|
||||
TOTPLastStep int64 `json:"-"`
|
||||
}
|
||||
|
||||
// HasTOTP reports whether the user logs in with a TOTP code.
|
||||
func (u *User) HasTOTP() bool { return u.TOTPSecret != "" }
|
||||
|
||||
// Token is an API key. The secret is shown once at creation and only its
|
||||
// hash is stored.
|
||||
type Token struct {
|
||||
@@ -125,6 +134,9 @@ type Session struct {
|
||||
LastSeenAt time.Time `json:"last_seen_at"`
|
||||
IP string `json:"ip"`
|
||||
UserAgent string `json:"user_agent"`
|
||||
// TOTPAt is when the session last proved a TOTP code (at login or
|
||||
// since); nil for a session without two-factor login.
|
||||
TOTPAt *time.Time `json:"totp_at,omitempty"`
|
||||
}
|
||||
|
||||
// AuditEvent records an action.
|
||||
|
||||
+93
-2
@@ -37,6 +37,10 @@ func (m *Meta) Touch(user string, now time.Time) {
|
||||
}
|
||||
|
||||
// View is a BIND view.
|
||||
// ViewColors are the colours the web UI can show a view in, besides the
|
||||
// default (the link colour).
|
||||
var ViewColors = []string{"green", "teal", "purple", "pink", "orange", "yellow", "red", "grey"}
|
||||
|
||||
type View struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
@@ -118,13 +122,31 @@ type Zone struct {
|
||||
SOA SOA `json:"soa"`
|
||||
DefaultTTL uint32 `json:"default_ttl"`
|
||||
SerialPolicy string `json:"serial_policy,omitempty"`
|
||||
// DNSSECPolicy is the dnssec-policy named signs the zone with (primary
|
||||
// zones only): "" for an unsigned zone, "default" (BIND's built-in
|
||||
// policy), the name of a dnssec-policy statement, or "insecure" while
|
||||
// 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
|
||||
}
|
||||
|
||||
// DNSSEC policies with a special meaning.
|
||||
const (
|
||||
DNSSECDefault = "default" // BIND's built-in policy
|
||||
DNSSECInsecure = "insecure" // BIND's built-in policy for removing signing
|
||||
)
|
||||
|
||||
// HasRecords reports whether Jarl manages the zone's records.
|
||||
func (z *Zone) HasRecords() bool { return z.Type == ZonePrimary }
|
||||
|
||||
// Signed reports whether named signs the zone (or is still removing its
|
||||
// signatures, under the insecure policy).
|
||||
func (z *Zone) Signed() bool { return z.Type == ZonePrimary && z.DNSSECPolicy != "" }
|
||||
|
||||
// Record is a resource record in a primary zone.
|
||||
type Record struct {
|
||||
ID int64 `json:"id"`
|
||||
@@ -135,6 +157,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
|
||||
}
|
||||
|
||||
@@ -186,7 +211,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.
|
||||
@@ -210,6 +246,9 @@ 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
|
||||
}
|
||||
|
||||
// NewState returns an empty state with default settings.
|
||||
@@ -222,6 +261,9 @@ func NewState() *State {
|
||||
Zones: map[int64]*Zone{},
|
||||
Records: map[int64]*Record{},
|
||||
Options: map[int64]*Option{},
|
||||
|
||||
Blocklists: map[int64]*Blocklist{},
|
||||
BlockRules: map[int64]*BlockRule{},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -235,6 +277,9 @@ 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)),
|
||||
}
|
||||
c.Settings.Nameservers = append([]string(nil), s.Settings.Nameservers...)
|
||||
for id, v := range s.Views {
|
||||
@@ -272,6 +317,16 @@ func (s *State) Clone() *State {
|
||||
n := *o
|
||||
c.Options[id] = &n
|
||||
}
|
||||
for id, b := range s.Blocklists {
|
||||
n := *b
|
||||
n.ViewIDs = append([]int64(nil), b.ViewIDs...)
|
||||
c.Blocklists[id] = &n
|
||||
}
|
||||
for id, r := range s.BlockRules {
|
||||
n := *r
|
||||
n.ViewIDs = append([]int64(nil), r.ViewIDs...)
|
||||
c.BlockRules[id] = &n
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
@@ -329,6 +384,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 {
|
||||
@@ -427,6 +489,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{}
|
||||
@@ -436,7 +510,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 {
|
||||
@@ -444,6 +531,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
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
package model
|
||||
|
||||
// optionGroups orders the options of a block (options, a view, a zone) the
|
||||
// same way everywhere: in named.conf, the web UI and the CLI. Related
|
||||
// options are grouped; within a group they are alphabetical, and options
|
||||
// not listed here come last, alphabetically. named doesn't care about the
|
||||
// order of options in a block.
|
||||
var optionGroups = [][]string{
|
||||
// Files and identity.
|
||||
{"bindkeys-file", "directory", "dump-file", "hostname", "managed-keys-directory", "memstatistics-file", "pid-file", "recursing-file", "secroots-file", "server-id", "session-keyfile", "statistics-file", "version"},
|
||||
// Listening and source addresses.
|
||||
{"listen-on", "listen-on-v6", "notify-source", "notify-source-v6", "query-source", "query-source-v6", "transfer-source", "transfer-source-v6"},
|
||||
// Access control.
|
||||
{"allow-query", "allow-query-cache", "allow-query-cache-on", "allow-query-on", "allow-recursion", "allow-recursion-on", "allow-update", "allow-update-forwarding", "blackhole", "deny-answer-addresses", "deny-answer-aliases", "update-policy"},
|
||||
// Recursion, forwarding and the cache.
|
||||
{"forward", "forwarders", "max-cache-size", "max-cache-ttl", "max-ncache-ttl", "minimal-responses", "prefetch", "qname-minimization", "rate-limit", "recursion", "response-policy", "serve-stale", "stale-answer-enable", "stale-answer-ttl", "stale-cache-enable"},
|
||||
// DNSSEC.
|
||||
{"dnssec-accept-expired", "dnssec-must-be-secure", "dnssec-policy", "dnssec-validation", "inline-signing", "key-directory", "trust-anchor-telemetry", "validate-except"},
|
||||
// Transfers and notify.
|
||||
{"allow-notify", "allow-transfer", "also-notify", "catalog-zones", "ixfr-from-differences", "masters", "max-transfer-idle-in", "max-transfer-idle-out", "max-transfer-time-in", "max-transfer-time-out", "notify", "notify-to-soa", "primaries", "provide-ixfr", "request-ixfr", "serial-query-rate", "transfer-format", "transfers-in", "transfers-out", "transfers-per-ns"},
|
||||
}
|
||||
|
||||
var optionGroup = func() map[string]int {
|
||||
m := map[string]int{}
|
||||
for i, g := range optionGroups {
|
||||
for _, n := range g {
|
||||
m[n] = i
|
||||
}
|
||||
}
|
||||
return m
|
||||
}()
|
||||
|
||||
// optionLess orders two options of the same block: by group, then name,
|
||||
// then (for an option given more than once, like listen-on) the order they
|
||||
// were added in.
|
||||
func optionLess(a, b *Option) bool {
|
||||
ga, ok := optionGroup[a.Name]
|
||||
if !ok {
|
||||
ga = len(optionGroups)
|
||||
}
|
||||
gb, ok := optionGroup[b.Name]
|
||||
if !ok {
|
||||
gb = len(optionGroups)
|
||||
}
|
||||
if ga != gb {
|
||||
return ga < gb
|
||||
}
|
||||
if a.Name != b.Name {
|
||||
return a.Name < b.Name
|
||||
}
|
||||
if a.Position != b.Position {
|
||||
return a.Position < b.Position
|
||||
}
|
||||
return a.ID < b.ID
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestScopedOptionsOrder(t *testing.T) {
|
||||
s := NewState()
|
||||
add := func(scope string, names ...string) {
|
||||
for i, n := range names {
|
||||
id := int64(len(s.Options) + 1)
|
||||
s.Options[id] = &Option{ID: id, Scope: scope, Name: n, Position: i}
|
||||
}
|
||||
}
|
||||
// Added in an arbitrary order; listen-on twice.
|
||||
add(ScopeView, "zzz-unknown", "forwarders", "listen-on", "recursion", "dnssec-validation", "aaa-unknown", "allow-query", "forward", "directory", "listen-on", "allow-transfer")
|
||||
var got []string
|
||||
for _, o := range s.ScopedOptions(ScopeView, 0) {
|
||||
got = append(got, o.Name)
|
||||
}
|
||||
want := "directory listen-on listen-on allow-query forward forwarders recursion dnssec-validation allow-transfer aaa-unknown zzz-unknown"
|
||||
if strings.Join(got, " ") != want {
|
||||
t.Errorf("order:\n got %s\nwant %s", strings.Join(got, " "), want)
|
||||
}
|
||||
// An option given twice keeps the order it was added in.
|
||||
l := s.ScopedOptions(ScopeView, 0)
|
||||
if l[1].Position > l[2].Position {
|
||||
t.Error("repeated listen-on swapped")
|
||||
}
|
||||
// Top-level statements stay as entered.
|
||||
add(ScopeTopLevel, "logging", "acl", "key")
|
||||
got = nil
|
||||
for _, o := range s.ScopedOptions(ScopeTopLevel, 0) {
|
||||
got = append(got, o.Name)
|
||||
}
|
||||
if strings.Join(got, " ") != "logging acl key" {
|
||||
t.Errorf("top level: %v", got)
|
||||
}
|
||||
}
|
||||
@@ -36,6 +36,24 @@ func Compact(items []Item) string {
|
||||
// CompactStmt returns the statement on a single line with its semicolon.
|
||||
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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+256
-46
@@ -9,6 +9,8 @@ import (
|
||||
"sync"
|
||||
|
||||
"github.com/miekg/dns"
|
||||
|
||||
"jarl/internal/dnssec"
|
||||
)
|
||||
|
||||
// Finding is one conclusion of a delegation check. Level is "ok",
|
||||
@@ -28,8 +30,73 @@ type ParentAnswer struct {
|
||||
NS []string `json:"ns,omitempty"`
|
||||
// Glue is the addresses the parent hands out for in-zone name
|
||||
// servers.
|
||||
Glue map[string][]string `json:"glue,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Glue map[string][]string `json:"glue,omitempty"`
|
||||
// DS is the zone's DS records at this server ("" in DSError when it
|
||||
// couldn't be asked).
|
||||
DS []dnssec.DS `json:"ds,omitempty"`
|
||||
DSError string `json:"ds_error,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// Signing is how Jarl signs a zone, for comparing with the parent's DS
|
||||
// records.
|
||||
type Signing struct {
|
||||
// Policy is the zone's dnssec-policy ("" when unsigned, "insecure"
|
||||
// while signing is being removed).
|
||||
Policy string
|
||||
// DS are the DS records of the zone's key-signing keys that named
|
||||
// still publishes.
|
||||
DS []dnssec.DS
|
||||
}
|
||||
|
||||
// ParentDSReport is what the parent zone's servers say about the zone's
|
||||
// DS records.
|
||||
type ParentDSReport struct {
|
||||
Zone string `json:"zone"`
|
||||
Parent string `json:"parent,omitempty"`
|
||||
Resolver string `json:"resolver,omitempty"`
|
||||
// Delegated: at least one parent server delegates the zone. Reached
|
||||
// is how many answered.
|
||||
Delegated bool `json:"delegated"`
|
||||
Reached int `json:"reached"`
|
||||
Servers []ParentAnswer `json:"parent_servers"`
|
||||
// DS is every DS record any parent server has.
|
||||
DS []dnssec.DS `json:"ds"`
|
||||
// Consistent: every server that answered has the same DS records.
|
||||
Consistent bool `json:"consistent"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// AllHaveDS reports whether every parent server that answered has a DS
|
||||
// record for key tag (with digest, unless "").
|
||||
func (r *ParentDSReport) AllHaveDS(tag uint16, digest string) bool {
|
||||
n := 0
|
||||
for _, a := range r.Servers {
|
||||
if a.Status != "delegated" || a.DSError != "" {
|
||||
continue
|
||||
}
|
||||
n++
|
||||
found := false
|
||||
for _, d := range a.DS {
|
||||
if d.KeyTag == tag && (digest == "" || strings.EqualFold(d.Digest, digest)) {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return n > 0
|
||||
}
|
||||
|
||||
// HasDS reports whether a parent server has a DS record for key tag.
|
||||
func (r *ParentDSReport) HasDS(tag uint16, digest string) bool {
|
||||
for _, d := range r.DS {
|
||||
if d.KeyTag == tag && (digest == "" || strings.EqualFold(d.Digest, digest)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// GlueCheck compares the parent's glue for an in-zone name server with
|
||||
@@ -52,6 +119,8 @@ type DelegationReport struct {
|
||||
JarlNS []string `json:"jarl_ns"`
|
||||
Parents []ParentAnswer `json:"parent_servers"`
|
||||
Glue []GlueCheck `json:"glue"`
|
||||
// ParentDS are the zone's DS records at the parent (DNSSEC).
|
||||
ParentDS []dnssec.DS `json:"parent_ds"`
|
||||
// Servers are the delegated name servers' SOA answers, as resolvers
|
||||
// on the internet would reach them.
|
||||
Servers []Result `json:"servers"`
|
||||
@@ -60,12 +129,14 @@ type DelegationReport struct {
|
||||
|
||||
// Delegation checks how the parent zone delegates zone: it finds the
|
||||
// parent through the resolver, asks each of the parent's servers for the
|
||||
// zone's NS records and glue, compares those with jarlNS and jarlAddrs
|
||||
// (Jarl's NS records and its addresses for in-zone name servers), and
|
||||
// asks every delegated server for the zone's serial (compared with ref).
|
||||
func (c *Checker) Delegation(ctx context.Context, zone string, jarlNS []string, jarlAddrs map[string][]string, ref *uint32) *DelegationReport {
|
||||
// zone's NS records, glue and DS records, compares those with jarlNS and
|
||||
// jarlAddrs (Jarl's NS records and its addresses for in-zone name
|
||||
// servers) and with sig (how Jarl signs the zone; nil skips the DNSSEC
|
||||
// part), and asks every delegated server for the zone's serial (compared
|
||||
// with ref).
|
||||
func (c *Checker) Delegation(ctx context.Context, zone string, jarlNS []string, jarlAddrs map[string][]string, ref *uint32, sig *Signing) *DelegationReport {
|
||||
zone = dns.CanonicalName(zone)
|
||||
rep := &DelegationReport{Zone: zone, JarlNS: canonAll(jarlNS), ParentNS: []string{}, Parents: []ParentAnswer{}, Glue: []GlueCheck{}, Servers: []Result{}, Findings: []Finding{}}
|
||||
rep := &DelegationReport{Zone: zone, JarlNS: canonAll(jarlNS), ParentNS: []string{}, Parents: []ParentAnswer{}, Glue: []GlueCheck{}, ParentDS: []dnssec.DS{}, Servers: []Result{}, Findings: []Finding{}}
|
||||
sort.Strings(rep.JarlNS)
|
||||
add := func(level, format string, args ...any) {
|
||||
for i, a := range args {
|
||||
@@ -78,42 +149,17 @@ func (c *Checker) Delegation(ctx context.Context, zone string, jarlNS []string,
|
||||
}
|
||||
rep.Findings = append(rep.Findings, Finding{level, fmt.Sprintf(format, args...)})
|
||||
}
|
||||
res, err := c.resolver()
|
||||
if err != nil {
|
||||
add("error", "No resolver to find the parent zone with: %v", err)
|
||||
pr := c.ParentDS(ctx, zone)
|
||||
rep.Resolver, rep.Parent, rep.Parents = pr.Resolver, pr.Parent, pr.Servers
|
||||
if pr.Error != "" {
|
||||
level := "error"
|
||||
if zone == "." {
|
||||
level = "info"
|
||||
}
|
||||
rep.Findings = append(rep.Findings, Finding{level, pr.Error})
|
||||
return rep
|
||||
}
|
||||
rep.Resolver = res
|
||||
if zone == "." {
|
||||
add("info", "The root zone has no parent to delegate it.")
|
||||
return rep
|
||||
}
|
||||
|
||||
// The parent zone: the zone that holds the name just above ours.
|
||||
parent, err := c.enclosingZone(ctx, res, parentName(zone))
|
||||
if err != nil {
|
||||
add("error", "Couldn't find the parent zone of %s through %s: %v", zone, res, err)
|
||||
return rep
|
||||
}
|
||||
rep.Parent = parent
|
||||
pns, err := c.nsNames(ctx, res, parent)
|
||||
if err != nil || len(pns) == 0 {
|
||||
add("error", "Couldn't find the name servers of the parent zone %s through %s: %v", parent, res, errOr(err, "none listed"))
|
||||
return rep
|
||||
}
|
||||
|
||||
// Ask every parent server, in parallel.
|
||||
var wg sync.WaitGroup
|
||||
answers := make([]ParentAnswer, len(pns))
|
||||
for i, name := range pns {
|
||||
wg.Add(1)
|
||||
go func(i int, name string) {
|
||||
defer wg.Done()
|
||||
answers[i] = c.askParent(ctx, zone, name)
|
||||
}(i, name)
|
||||
}
|
||||
wg.Wait()
|
||||
rep.Parents = answers
|
||||
parent, answers := pr.Parent, pr.Servers
|
||||
|
||||
parentNS := map[string]bool{}
|
||||
glue := map[string]map[string]bool{}
|
||||
@@ -141,10 +187,6 @@ func (c *Checker) Delegation(ctx context.Context, zone string, jarlNS []string,
|
||||
notDelegated++
|
||||
}
|
||||
}
|
||||
if reached == 0 {
|
||||
add("error", "None of the parent zone's servers (%s) answered; this server may not be able to reach them.", pns)
|
||||
return rep
|
||||
}
|
||||
if delegated == 0 {
|
||||
rep.Delegated = false
|
||||
where := "the parent zone " + show(parent)
|
||||
@@ -154,6 +196,7 @@ func (c *Checker) Delegation(ctx context.Context, zone string, jarlNS []string,
|
||||
add("info", "%s isn't delegated from %s, so the internet can't see it. That's expected for a zone used only on your own network. To publish it, register the domain and give your registrar these name servers: %s.", zone, where, rep.JarlNS)
|
||||
return rep
|
||||
}
|
||||
rep.ParentDS = pr.DS
|
||||
rep.Delegated = true
|
||||
if notDelegated > 0 {
|
||||
add("warning", "%d of the parent's %d servers don't know the delegation yet (a recent change at the registrar can take a while to reach them all).", notDelegated, reached)
|
||||
@@ -207,6 +250,12 @@ func (c *Checker) Delegation(ctx context.Context, zone string, jarlNS []string,
|
||||
}
|
||||
}
|
||||
|
||||
if sig != nil {
|
||||
for _, f := range dsFindings(zone, pr, sig) {
|
||||
rep.Findings = append(rep.Findings, f)
|
||||
}
|
||||
}
|
||||
|
||||
// Every delegated server, reached the way resolvers reach it: by its
|
||||
// glue, or by looking its name up.
|
||||
var targets []Target
|
||||
@@ -262,7 +311,151 @@ func (c *Checker) Delegation(ctx context.Context, zone string, jarlNS []string,
|
||||
return rep
|
||||
}
|
||||
|
||||
// askParent asks one of the parent's servers (by name) for zone's NS.
|
||||
// ParentDS finds the parent of zone through the resolver and asks each of
|
||||
// its servers for the zone's delegation and DS records.
|
||||
func (c *Checker) ParentDS(ctx context.Context, zone string) *ParentDSReport {
|
||||
zone = dns.CanonicalName(zone)
|
||||
rep := &ParentDSReport{Zone: zone, Servers: []ParentAnswer{}, DS: []dnssec.DS{}}
|
||||
res, err := c.resolver()
|
||||
if err != nil {
|
||||
rep.Error = "No resolver to find the parent zone with: " + err.Error()
|
||||
return rep
|
||||
}
|
||||
rep.Resolver = res
|
||||
if zone == "." {
|
||||
rep.Error = "The root zone has no parent to delegate it."
|
||||
return rep
|
||||
}
|
||||
// The parent zone: the zone that holds the name just above ours.
|
||||
parent, err := c.enclosingZone(ctx, res, parentName(zone))
|
||||
if err != nil {
|
||||
rep.Error = fmt.Sprintf("Couldn't find the parent zone of %s through %s: %v", show(zone), res, err)
|
||||
return rep
|
||||
}
|
||||
rep.Parent = parent
|
||||
pns, err := c.nsNames(ctx, res, parent)
|
||||
if err != nil || len(pns) == 0 {
|
||||
rep.Error = fmt.Sprintf("Couldn't find the name servers of the parent zone %s through %s: %v", show(parent), res, errOr(err, "none listed"))
|
||||
return rep
|
||||
}
|
||||
// Ask every parent server, in parallel.
|
||||
var wg sync.WaitGroup
|
||||
answers := make([]ParentAnswer, len(pns))
|
||||
for i, name := range pns {
|
||||
wg.Add(1)
|
||||
go func(i int, name string) {
|
||||
defer wg.Done()
|
||||
answers[i] = c.askParent(ctx, zone, name)
|
||||
}(i, name)
|
||||
}
|
||||
wg.Wait()
|
||||
rep.Servers = answers
|
||||
all := map[string]dnssec.DS{}
|
||||
sets := map[string]bool{}
|
||||
for _, a := range answers {
|
||||
if a.Status != "delegated" && a.Status != "not_delegated" {
|
||||
continue
|
||||
}
|
||||
rep.Reached++
|
||||
if a.Status == "delegated" {
|
||||
rep.Delegated = true
|
||||
}
|
||||
if a.DSError != "" {
|
||||
continue
|
||||
}
|
||||
var set []string
|
||||
for _, d := range a.DS {
|
||||
k := dsKey(d)
|
||||
all[k] = d
|
||||
set = append(set, k)
|
||||
}
|
||||
sort.Strings(set)
|
||||
sets[strings.Join(set, " ")] = true
|
||||
}
|
||||
if rep.Reached == 0 {
|
||||
names := make([]string, len(pns))
|
||||
for i, n := range pns {
|
||||
names[i] = show(n)
|
||||
}
|
||||
rep.Error = fmt.Sprintf("None of the parent zone's servers (%s) answered; this server may not be able to reach them.", strings.Join(names, ", "))
|
||||
return rep
|
||||
}
|
||||
for _, k := range keys(boolSet(all)) {
|
||||
rep.DS = append(rep.DS, all[k])
|
||||
}
|
||||
rep.Consistent = len(sets) <= 1
|
||||
return rep
|
||||
}
|
||||
|
||||
func dsKey(d dnssec.DS) string {
|
||||
return fmt.Sprintf("%05d %d %d %s", d.KeyTag, d.Algorithm, d.DigestType, strings.ToUpper(d.Digest))
|
||||
}
|
||||
|
||||
func boolSet(m map[string]dnssec.DS) map[string]bool {
|
||||
out := map[string]bool{}
|
||||
for k := range m {
|
||||
out[k] = true
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// dsFindings compares the parent's DS records with how Jarl signs the
|
||||
// zone.
|
||||
func dsFindings(zone string, pr *ParentDSReport, sig *Signing) []Finding {
|
||||
var out []Finding
|
||||
add := func(level, format string, args ...any) {
|
||||
out = append(out, Finding{level, fmt.Sprintf(format, args...)})
|
||||
}
|
||||
z := show(zone)
|
||||
var tags []string
|
||||
for _, d := range pr.DS {
|
||||
tags = append(tags, fmt.Sprint(d.KeyTag))
|
||||
}
|
||||
if !pr.Consistent {
|
||||
add("warning", "The parent's servers have different DS records for %s (a change at the registrar may still be on its way to them all).", z)
|
||||
}
|
||||
matches := func(d dnssec.DS) bool {
|
||||
for _, k := range sig.DS {
|
||||
if k.KeyTag == d.KeyTag && k.Algorithm == d.Algorithm && k.DigestType == d.DigestType && strings.EqualFold(k.Digest, d.Digest) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
var good, stale []string
|
||||
for _, d := range pr.DS {
|
||||
if matches(d) {
|
||||
good = append(good, fmt.Sprint(d.KeyTag))
|
||||
} else {
|
||||
stale = append(stale, fmt.Sprint(d.KeyTag))
|
||||
}
|
||||
}
|
||||
switch {
|
||||
case len(pr.DS) == 0 && sig.Policy == "":
|
||||
// Unsigned and no DS: nothing to say about DNSSEC.
|
||||
case len(pr.DS) == 0 && sig.Policy == "insecure":
|
||||
add("ok", "The parent has no DS record for %s any more, so turning DNSSEC off can go ahead.", z)
|
||||
case len(pr.DS) == 0:
|
||||
add("info", "%s is signed, but the parent has no DS record for it, so resolvers treat it as unsigned. Add the DS record at your registrar when the zone's DNSSEC page says it's ready.", z)
|
||||
case sig.Policy == "":
|
||||
add("error", "The parent has a DS record for %s (key %s), but the zone isn't signed: validating resolvers reject all of its answers. Remove the DS record at your registrar now.", z, strings.Join(tags, ", "))
|
||||
case len(good) == 0:
|
||||
add("error", "The parent's DS record for %s (key %s) matches none of the zone's keys: validating resolvers reject all of its answers. Replace it at your registrar with the zone's DS record.", z, strings.Join(tags, ", "))
|
||||
default:
|
||||
if sig.Policy == "insecure" {
|
||||
add("warning", "The parent still has the DS record for %s (key %s). DNSSEC is being turned off: remove it at your registrar, then run the DS check.", z, strings.Join(good, ", "))
|
||||
} else {
|
||||
add("ok", "The parent's DS record matches the zone's key %s: validating resolvers check its answers.", strings.Join(good, ", "))
|
||||
}
|
||||
if len(stale) > 0 {
|
||||
add("warning", "The parent also has a DS record for key %s, which the zone no longer has. Remove it at your registrar.", strings.Join(stale, ", "))
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// askParent asks one of the parent's servers (by name) for zone's NS and
|
||||
// DS records.
|
||||
func (c *Checker) askParent(ctx context.Context, zone, server string) ParentAnswer {
|
||||
pa := ParentAnswer{Name: server}
|
||||
addrs, err := c.lookup(ctx, server)
|
||||
@@ -331,6 +524,23 @@ func (c *Checker) askParent(ctx context.Context, zone, server string) ParentAnsw
|
||||
for n := range pa.Glue {
|
||||
pa.Glue[n] = canonAddrs(pa.Glue[n])
|
||||
}
|
||||
// The parent itself answers DS queries for the zone.
|
||||
m = new(dns.Msg)
|
||||
m.SetQuestion(zone, dns.TypeDS)
|
||||
m.RecursionDesired = false
|
||||
resp, _, err = c.exchange(ctx, m, pa.Address, nil)
|
||||
switch {
|
||||
case err != nil:
|
||||
pa.DSError = err.Error()
|
||||
case resp.Rcode != dns.RcodeSuccess:
|
||||
pa.DSError = "answered " + dns.RcodeToString[resp.Rcode]
|
||||
default:
|
||||
for _, rr := range resp.Answer {
|
||||
if d, ok := rr.(*dns.DS); ok && dns.CanonicalName(d.Hdr.Name) == zone {
|
||||
pa.DS = append(pa.DS, dnssec.DS{KeyTag: d.KeyTag, Algorithm: d.Algorithm, DigestType: d.DigestType, Digest: strings.ToUpper(d.Digest), Record: d.String()})
|
||||
}
|
||||
}
|
||||
}
|
||||
return pa
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
package nscheck
|
||||
|
||||
import (
|
||||
"net/netip"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Without netlink (a sandboxed jarld), the host's addresses come from /proc.
|
||||
func TestProcAddrs(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
os.WriteFile(filepath.Join(dir, "if_inet6"), []byte(
|
||||
"00000000000000000000000000000001 01 80 10 80 lo\n"+
|
||||
"2a0280137f3d00100000000000000005 02 40 00 80 eth0\n"), 0o644)
|
||||
os.WriteFile(filepath.Join(dir, "fib_trie"), []byte(`Main:
|
||||
+-- 0.0.0.0/0 3 0 5
|
||||
|-- 0.0.0.0
|
||||
/0 universe UNICAST
|
||||
+-- 10.0.10.0/24 2 0 2
|
||||
|-- 10.0.10.0
|
||||
/24 link UNICAST
|
||||
|-- 10.0.10.5
|
||||
/32 host LOCAL
|
||||
|-- 10.0.10.255
|
||||
/32 link BROADCAST
|
||||
Local:
|
||||
+-- 0.0.0.0/0 3 0 5
|
||||
|-- 10.0.10.5
|
||||
/32 host LOCAL
|
||||
`), 0o644)
|
||||
got := procAddrs(dir)
|
||||
for _, want := range []string{"::1", "2a02:8013:7f3d:10::5", "10.0.10.5"} {
|
||||
if !slices.Contains(got, netip.MustParseAddr(want)) {
|
||||
t.Errorf("%s missing from %v", want, got)
|
||||
}
|
||||
}
|
||||
if len(got) != 3 {
|
||||
t.Errorf("got %v (10.0.10.5 once, no broadcast or network addresses)", got)
|
||||
}
|
||||
// And the real ones on this machine: at least loopback.
|
||||
if len(procAddrs("/proc/net")) == 0 {
|
||||
t.Log("no addresses in /proc/net here")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package nscheck
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"net/netip"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/miekg/dns"
|
||||
)
|
||||
|
||||
// A query to this host's own address is signed with LocalKey, so a server
|
||||
// with views answers from the view that key selects.
|
||||
func TestLocalKey(t *testing.T) {
|
||||
zone := "example.com."
|
||||
key := &Key{Name: "jarl-view-external.", Algorithm: "hmac-sha256", Secret: "c2VjcmV0LXNlY3JldC1zZWNyZXQtc2VjcmV0LXNlY3I="}
|
||||
pc, err := net.ListenPacket("udp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Skip(err)
|
||||
}
|
||||
_, port, _ := net.SplitHostPort(pc.LocalAddr().String())
|
||||
srv := &dns.Server{PacketConn: pc, TsigSecret: map[string]string{key.Name: key.Secret},
|
||||
Handler: dns.HandlerFunc(func(w dns.ResponseWriter, req *dns.Msg) {
|
||||
// Signed with the view key: the "external" view's serial.
|
||||
serial := "1"
|
||||
signed := req.IsTsig() != nil && w.TsigStatus() == nil
|
||||
if signed {
|
||||
serial = "2"
|
||||
}
|
||||
m := new(dns.Msg)
|
||||
m.SetReply(req)
|
||||
m.Authoritative = true
|
||||
m.Answer = []dns.RR{rr(zone + " 3600 IN SOA ns1." + zone + " admin." + zone + " " + serial + " 3600 900 1209600 300")}
|
||||
if signed {
|
||||
m.SetTsig(key.Name, dns.HmacSHA256, 300, time.Now().Unix())
|
||||
}
|
||||
w.WriteMsg(m)
|
||||
})}
|
||||
go srv.ActivateAndServe()
|
||||
t.Cleanup(func() { srv.Shutdown() })
|
||||
|
||||
ref := uint32(2)
|
||||
c := &Checker{Timeout: time.Second, Port: port, IsLocal: func(netip.Addr) bool { return false }}
|
||||
if res := c.Serials(context.Background(), zone, &ref, []Target{{Addresses: []string{"127.0.0.1"}}}); res[0].Status != "behind" {
|
||||
t.Errorf("unsigned: %+v (want the other view's serial)", res[0])
|
||||
}
|
||||
c.LocalKey = key
|
||||
if res := c.Serials(context.Background(), zone, &ref, []Target{{Addresses: []string{"127.0.0.1"}}}); res[0].Status != "in_sync" {
|
||||
t.Errorf("signed with the view key: %+v", res[0])
|
||||
}
|
||||
}
|
||||
+221
-15
@@ -15,13 +15,18 @@ package nscheck
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/netip"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
@@ -93,6 +98,139 @@ 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.
|
||||
Watch func(Event)
|
||||
}
|
||||
|
||||
// Event is a query a check starts or finishes (Checker.Watch).
|
||||
type Event struct {
|
||||
// Kind is "start" or "end" (of query ID).
|
||||
Kind string `json:"kind"`
|
||||
ID int64 `json:"id"`
|
||||
// Query describes what is asked, as a dig command line.
|
||||
Query string `json:"query,omitempty"`
|
||||
// End: Answer sums up the answer ("192.0.2.1, 2001:db8::1",
|
||||
// "NXDOMAIN", "referral to a.nic.lol., …"), or Error says why there
|
||||
// is none. Serials' SOA queries give the server's Result instead.
|
||||
Answer string `json:"answer,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
RTTMs int64 `json:"rtt_ms,omitempty"`
|
||||
Result *Result `json:"result,omitempty"`
|
||||
}
|
||||
|
||||
// eventIDs numbers events; 0 is left for the caller's own steps.
|
||||
var eventIDs atomic.Int64
|
||||
|
||||
func (c *Checker) startEvent(query string) int64 {
|
||||
if c.Watch == nil {
|
||||
return 0
|
||||
}
|
||||
id := eventIDs.Add(1)
|
||||
c.Watch(Event{Kind: "start", ID: id, Query: query})
|
||||
return id
|
||||
}
|
||||
|
||||
func (c *Checker) endEvent(e Event) {
|
||||
if c.Watch != nil {
|
||||
e.Kind = "end"
|
||||
c.Watch(e)
|
||||
}
|
||||
}
|
||||
|
||||
// quietKey in a context stops exchangeHostPort from reporting the query:
|
||||
// the caller reports it with more to say (Serials' SOA queries).
|
||||
type quietKey struct{}
|
||||
|
||||
// digQuery is the dig command line for m sent to hostport.
|
||||
func digQuery(m *dns.Msg, hostport string, key *Key) string {
|
||||
host, port, err := net.SplitHostPort(hostport)
|
||||
if err != nil {
|
||||
host, port = hostport, ""
|
||||
}
|
||||
if len(m.Question) == 0 {
|
||||
return "dig @" + host
|
||||
}
|
||||
q := m.Question[0]
|
||||
s := digLine(host, port, q.Name, dns.TypeToString[q.Qtype], !m.RecursionDesired, key)
|
||||
if o := m.IsEdns0(); o != nil && o.Do() {
|
||||
s += " +dnssec"
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// sumAnswer sums up a DNS answer in a few words.
|
||||
func sumAnswer(resp *dns.Msg) string {
|
||||
if resp.Rcode != dns.RcodeSuccess {
|
||||
return dns.RcodeToString[resp.Rcode]
|
||||
}
|
||||
var vals []string
|
||||
for _, rr := range resp.Answer {
|
||||
switch v := rr.(type) {
|
||||
case *dns.A:
|
||||
vals = append(vals, v.A.String())
|
||||
case *dns.AAAA:
|
||||
vals = append(vals, v.AAAA.String())
|
||||
case *dns.NS:
|
||||
vals = append(vals, v.Ns)
|
||||
case *dns.SOA:
|
||||
vals = append(vals, "serial "+strconv.FormatUint(uint64(v.Serial), 10))
|
||||
case *dns.DS:
|
||||
vals = append(vals, "DS "+strconv.Itoa(int(v.KeyTag)))
|
||||
case *dns.RRSIG:
|
||||
default:
|
||||
vals = append(vals, dns.TypeToString[rr.Header().Rrtype])
|
||||
}
|
||||
}
|
||||
if len(vals) == 0 {
|
||||
var ns []string
|
||||
for _, rr := range resp.Ns {
|
||||
switch v := rr.(type) {
|
||||
case *dns.NS:
|
||||
ns = append(ns, v.Ns)
|
||||
case *dns.SOA:
|
||||
return "no such record"
|
||||
}
|
||||
}
|
||||
if len(ns) > 0 {
|
||||
return "referral to " + shorten(ns)
|
||||
}
|
||||
return "empty answer"
|
||||
}
|
||||
s := shorten(vals)
|
||||
if !resp.Authoritative && !resp.RecursionAvailable {
|
||||
s += " (not authoritative)"
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func shorten(vals []string) string {
|
||||
if len(vals) > 3 {
|
||||
return strings.Join(vals[:3], ", ") + " and " + strconv.Itoa(len(vals)-3) + " more"
|
||||
}
|
||||
return strings.Join(vals, ", ")
|
||||
}
|
||||
|
||||
// digLine is a dig command line for a query (no TSIG secret).
|
||||
func digLine(server, port, name, types string, norec bool, key *Key) string {
|
||||
s := "dig @" + server
|
||||
if port != "" && port != "53" {
|
||||
s += " -p " + port
|
||||
}
|
||||
s += " " + name + " " + types
|
||||
if norec {
|
||||
s += " +norec"
|
||||
}
|
||||
if key != nil {
|
||||
s += " -y " + key.Algorithm + ":" + strings.TrimSuffix(key.Name, ".") + ":…"
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func (c *Checker) timeout() time.Duration {
|
||||
@@ -122,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) {
|
||||
@@ -162,16 +347,25 @@ func (c *Checker) exchange(ctx context.Context, m *dns.Msg, addr string, key *Ke
|
||||
return c.exchangeHostPort(ctx, m, net.JoinHostPort(addr, c.port()), key)
|
||||
}
|
||||
|
||||
func (c *Checker) exchangeHostPort(ctx context.Context, m *dns.Msg, hostport string, key *Key) (*dns.Msg, time.Duration, error) {
|
||||
func (c *Checker) exchangeHostPort(ctx context.Context, m *dns.Msg, hostport string, key *Key) (resp *dns.Msg, rtt time.Duration, err error) {
|
||||
if c.Watch != nil && ctx.Value(quietKey{}) == nil {
|
||||
id := c.startEvent(digQuery(m, hostport, key))
|
||||
defer func() {
|
||||
e := Event{ID: id, RTTMs: rtt.Milliseconds()}
|
||||
if err != nil {
|
||||
e.Error = err.Error()
|
||||
} else {
|
||||
e.Answer = sumAnswer(resp)
|
||||
}
|
||||
c.endEvent(e)
|
||||
}()
|
||||
}
|
||||
cl := &dns.Client{Timeout: c.timeout()}
|
||||
if key != nil {
|
||||
name := dns.Fqdn(key.Name)
|
||||
cl.TsigSecret = map[string]string{name: key.Secret}
|
||||
m.SetTsig(name, dns.Fqdn(key.Algorithm), 300, time.Now().Unix())
|
||||
}
|
||||
var resp *dns.Msg
|
||||
var rtt time.Duration
|
||||
var err error
|
||||
for try := 0; try < 2; try++ {
|
||||
resp, rtt, err = cl.ExchangeContext(ctx, m, hostport)
|
||||
if err == nil || ctx.Err() != nil {
|
||||
@@ -228,7 +422,7 @@ func (c *Checker) soa(ctx context.Context, zone string, key *Key, ref *uint32, r
|
||||
if port == "" {
|
||||
port = c.port()
|
||||
}
|
||||
resp, rtt, err := c.exchangeHostPort(ctx, m, net.JoinHostPort(r.Address, port), key)
|
||||
resp, rtt, err := c.exchangeHostPort(context.WithValue(ctx, quietKey{}, true), m, net.JoinHostPort(r.Address, port), key)
|
||||
if err != nil {
|
||||
var ne net.Error
|
||||
switch {
|
||||
@@ -320,11 +514,23 @@ func (c *Checker) Serials(ctx context.Context, zone string, ref *uint32, targets
|
||||
continue
|
||||
}
|
||||
wg.Add(1)
|
||||
port := r.Port
|
||||
if port == "" {
|
||||
port = c.port()
|
||||
}
|
||||
// Reported here rather than by exchange: the end carries the
|
||||
// server's status.
|
||||
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()
|
||||
|
||||
@@ -6,10 +6,13 @@ import (
|
||||
"net/netip"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/miekg/dns"
|
||||
|
||||
"jarl/internal/dnssec"
|
||||
)
|
||||
|
||||
// fake is a DNS server on one loopback address that answers with fn.
|
||||
@@ -187,7 +190,7 @@ func TestDelegationMismatch(t *testing.T) {
|
||||
ref := uint32(7)
|
||||
rep := checker(port, res).Delegation(context.Background(), "adbraun.lol.",
|
||||
[]string{"ns1.adbraun.lol.", "ns2.adbraun.lol."},
|
||||
map[string][]string{"ns1.adbraun.lol.": {"127.0.0.99"}, "ns2.adbraun.lol.": {"127.0.0.14"}}, &ref)
|
||||
map[string][]string{"ns1.adbraun.lol.": {"127.0.0.99"}, "ns2.adbraun.lol.": {"127.0.0.14"}}, &ref, nil)
|
||||
if rep.Parent != "lol." || !rep.Delegated {
|
||||
t.Fatalf("report: %+v", rep)
|
||||
}
|
||||
@@ -218,7 +221,7 @@ func TestDelegationGood(t *testing.T) {
|
||||
return m
|
||||
})
|
||||
ref := uint32(7)
|
||||
rep := checker(port, res).Delegation(context.Background(), "adbraun.lol.", []string{"ns1.adbraun.lol."}, map[string][]string{"ns1.adbraun.lol.": {"127.0.0.12"}}, &ref)
|
||||
rep := checker(port, res).Delegation(context.Background(), "adbraun.lol.", []string{"ns1.adbraun.lol."}, map[string][]string{"ns1.adbraun.lol.": {"127.0.0.12"}}, &ref, nil)
|
||||
l := levels(rep)
|
||||
if len(l["error"])+len(l["warning"])+len(l["info"]) != 0 || len(l["ok"]) != 2 {
|
||||
t.Errorf("findings: %v", l)
|
||||
@@ -234,7 +237,7 @@ func TestNotDelegated(t *testing.T) {
|
||||
m.Ns = []dns.RR{rr("lol. 300 IN SOA ns.nic.lol. hostmaster.lol. 1 3600 900 1209600 300")}
|
||||
return m
|
||||
})
|
||||
rep := checker(port, res).Delegation(context.Background(), "adbraun.lol.", []string{"ns1.adbraun.lol."}, nil, nil)
|
||||
rep := checker(port, res).Delegation(context.Background(), "adbraun.lol.", []string{"ns1.adbraun.lol."}, nil, nil, nil)
|
||||
l := levels(rep)
|
||||
if rep.Delegated || len(l["info"]) != 1 || len(l["warning"])+len(l["error"]) != 0 {
|
||||
t.Errorf("findings: %v", l)
|
||||
@@ -243,3 +246,126 @@ func TestNotDelegated(t *testing.T) {
|
||||
t.Errorf("note: %s", l["info"][0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestDelegationDS(t *testing.T) {
|
||||
const dsData = "22642 13 2 0B7A2F5E1C8E2F6A0B7A2F5E1C8E2F6A0B7A2F5E1C8E2F6A0B7A2F5E1C8E2F6A"
|
||||
parent := func(withDS bool) fake {
|
||||
return func(q dns.Question) *dns.Msg {
|
||||
m := new(dns.Msg)
|
||||
switch {
|
||||
case q.Qtype == dns.TypeNS && q.Name == "adbraun.lol.":
|
||||
m.Ns = []dns.RR{rr("adbraun.lol. 3600 IN NS ns1.adbraun.lol.")}
|
||||
m.Extra = []dns.RR{rr("ns1.adbraun.lol. 3600 IN A 127.0.0.12")}
|
||||
case q.Qtype == dns.TypeDS && q.Name == "adbraun.lol.":
|
||||
m.Authoritative = true
|
||||
if withDS {
|
||||
m.Answer = []dns.RR{rr("adbraun.lol. 3600 IN DS " + dsData)}
|
||||
}
|
||||
}
|
||||
return m
|
||||
}
|
||||
}
|
||||
ours := dnssec.DS{KeyTag: 22642, Algorithm: 13, DigestType: 2, Digest: strings.Fields(dsData)[3]}
|
||||
other := ours
|
||||
other.KeyTag, other.Digest = 1111, strings.Repeat("AB", 32)
|
||||
ref := uint32(7)
|
||||
for _, c := range []struct {
|
||||
name string
|
||||
withDS bool
|
||||
sig Signing
|
||||
level string
|
||||
want string
|
||||
}{
|
||||
{"secure", true, Signing{Policy: "default", DS: []dnssec.DS{ours}}, "ok", "matches the zone's key 22642"},
|
||||
{"wrong key", true, Signing{Policy: "default", DS: []dnssec.DS{other}}, "error", "matches none of the zone's keys"},
|
||||
{"unsigned", true, Signing{}, "error", "but the zone isn't signed"},
|
||||
{"turning off", true, Signing{Policy: "insecure", DS: []dnssec.DS{ours}}, "warning", "remove it at your registrar"},
|
||||
{"no DS yet", false, Signing{Policy: "default", DS: []dnssec.DS{ours}}, "info", "the parent has no DS record for it"},
|
||||
{"DS gone", false, Signing{Policy: "insecure"}, "ok", "turning DNSSEC off can go ahead"},
|
||||
} {
|
||||
port, res := internet(t, parent(c.withDS))
|
||||
sig := c.sig
|
||||
rep := checker(port, res).Delegation(context.Background(), "adbraun.lol.", []string{"ns1.adbraun.lol."}, map[string][]string{"ns1.adbraun.lol.": {"127.0.0.12"}}, &ref, &sig)
|
||||
found := false
|
||||
for _, f := range rep.Findings {
|
||||
if f.Level == c.level && strings.Contains(f.Message, c.want) {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("%s: no %s finding %q in %+v", c.name, c.level, c.want, rep.Findings)
|
||||
}
|
||||
if c.withDS && (len(rep.ParentDS) != 1 || rep.ParentDS[0].KeyTag != 22642) {
|
||||
t.Errorf("%s: parent DS %+v", c.name, rep.ParentDS)
|
||||
}
|
||||
}
|
||||
// Unsigned and no DS: nothing about DNSSEC.
|
||||
port, res := internet(t, parent(false))
|
||||
rep := checker(port, res).Delegation(context.Background(), "adbraun.lol.", []string{"ns1.adbraun.lol."}, map[string][]string{"ns1.adbraun.lol.": {"127.0.0.12"}}, &ref, &Signing{})
|
||||
for _, f := range rep.Findings {
|
||||
if strings.Contains(f.Message, "DS") {
|
||||
t.Errorf("unsigned zone: %+v", f)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestWatch: with Watch set, every query is reported as it starts and
|
||||
// ends, as a dig command line with a summary of the answer.
|
||||
func TestWatch(t *testing.T) {
|
||||
port, res := internet(t, func(q dns.Question) *dns.Msg {
|
||||
m := new(dns.Msg)
|
||||
if q.Qtype == dns.TypeNS && q.Name == "adbraun.lol." {
|
||||
m.Ns = []dns.RR{rr("adbraun.lol. 3600 IN NS ns1.adbraun.lol.")}
|
||||
m.Extra = []dns.RR{rr("ns1.adbraun.lol. 3600 IN A 127.0.0.12")}
|
||||
}
|
||||
return m
|
||||
})
|
||||
var mu sync.Mutex
|
||||
var events []Event
|
||||
c := checker(port, res)
|
||||
c.Watch = func(e Event) {
|
||||
mu.Lock()
|
||||
events = append(events, e)
|
||||
mu.Unlock()
|
||||
}
|
||||
ref := uint32(7)
|
||||
c.Delegation(context.Background(), "adbraun.lol.", []string{"ns1.adbraun.lol."}, map[string][]string{"ns1.adbraun.lol.": {"127.0.0.12"}}, &ref, nil)
|
||||
open := map[int64]string{}
|
||||
var queries, answers []string
|
||||
for _, e := range events {
|
||||
switch e.Kind {
|
||||
case "start":
|
||||
if !strings.HasPrefix(e.Query, "dig @") {
|
||||
t.Errorf("query %q", e.Query)
|
||||
}
|
||||
open[e.ID] = e.Query
|
||||
queries = append(queries, e.Query)
|
||||
case "end":
|
||||
q, ok := open[e.ID]
|
||||
if !ok {
|
||||
t.Errorf("end without start: %+v", e)
|
||||
}
|
||||
delete(open, e.ID)
|
||||
a := e.Answer + e.Error
|
||||
if e.Result != nil {
|
||||
a = e.Result.Status
|
||||
}
|
||||
answers = append(answers, q+" => "+a)
|
||||
}
|
||||
}
|
||||
if len(open) != 0 {
|
||||
t.Errorf("never ended: %v", open)
|
||||
}
|
||||
all := strings.Join(answers, "\n")
|
||||
for _, want := range []string{
|
||||
" adbraun.lol. NS +norec => referral to ns1.adbraun.lol.", // asking a parent server
|
||||
"@127.0.0.12 -p " + port + " adbraun.lol. SOA +norec => in_sync", // asking the delegated server
|
||||
} {
|
||||
if !strings.Contains(all, want) {
|
||||
t.Errorf("no %q in\n%s", want, all)
|
||||
}
|
||||
}
|
||||
if len(queries) < 4 {
|
||||
t.Errorf("only %d queries:\n%s", len(queries), all)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
package porkbun
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// Fake is an in-memory stand-in for the Porkbun API, for tests: it knows
|
||||
// one account's domains and checks the key like Porkbun does.
|
||||
type Fake struct {
|
||||
APIKey, Secret string
|
||||
mu sync.Mutex
|
||||
Domains map[string]*FakeDomain
|
||||
// Calls lists the paths called, in order.
|
||||
Calls []string
|
||||
}
|
||||
|
||||
// FakeDomain is a domain in a Fake.
|
||||
type FakeDomain struct {
|
||||
NS []string
|
||||
Glue map[string][]string // host -> addresses
|
||||
DS map[uint16]DS
|
||||
APIAccess bool
|
||||
}
|
||||
|
||||
// NewFake returns a Fake with the given domains (API access on, Porkbun's
|
||||
// own name servers, no glue or DS).
|
||||
func NewFake(key, secret string, domains ...string) *Fake {
|
||||
f := &Fake{APIKey: key, Secret: secret, Domains: map[string]*FakeDomain{}}
|
||||
for _, d := range domains {
|
||||
f.Domains[d] = &FakeDomain{NS: []string{"curitiba.ns.porkbun.com", "fortaleza.ns.porkbun.com"}, Glue: map[string][]string{}, DS: map[uint16]DS{}, APIAccess: true}
|
||||
}
|
||||
return f
|
||||
}
|
||||
|
||||
func (f *Fake) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
reply := func(status int, v map[string]any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
fail := func(status int, code, msg string) {
|
||||
reply(status, map[string]any{"status": "ERROR", "code": code, "message": msg})
|
||||
}
|
||||
var body map[string]any
|
||||
json.NewDecoder(r.Body).Decode(&body)
|
||||
path := strings.TrimPrefix(r.URL.Path, "/api/json/v3")
|
||||
f.Calls = append(f.Calls, path)
|
||||
if body["apikey"] != f.APIKey || body["secretapikey"] != f.Secret {
|
||||
fail(http.StatusForbidden, "INVALID_API_KEYS_001", "Invalid API key.")
|
||||
return
|
||||
}
|
||||
if path == "/ping" {
|
||||
reply(200, map[string]any{"status": "SUCCESS", "yourIp": "192.0.2.1", "credentialsValid": true, "account": "tester", "scopes": []string{"full"}})
|
||||
return
|
||||
}
|
||||
parts := strings.Split(strings.Trim(path, "/"), "/")
|
||||
if len(parts) < 3 {
|
||||
fail(404, "NOT_FOUND", "Unknown endpoint.")
|
||||
return
|
||||
}
|
||||
op, domain := parts[0]+"/"+parts[1], parts[2]
|
||||
d := f.Domains[domain]
|
||||
if d == nil {
|
||||
fail(400, "DOMAIN_NOT_FOUND", "Domain not found in this account.")
|
||||
return
|
||||
}
|
||||
if !d.APIAccess {
|
||||
fail(403, "API_ACCESS_DISABLED", "The domain is not opted in to API access.")
|
||||
return
|
||||
}
|
||||
host := ""
|
||||
if len(parts) > 3 {
|
||||
host = parts[3] + "." + domain
|
||||
}
|
||||
ips := func() []string {
|
||||
var out []string
|
||||
if l, ok := body["ips"].([]any); ok {
|
||||
for _, v := range l {
|
||||
out = append(out, v.(string))
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
switch op {
|
||||
case "domain/getNs":
|
||||
reply(200, map[string]any{"status": "SUCCESS", "ns": d.NS})
|
||||
case "domain/updateNs":
|
||||
var ns []string
|
||||
for _, v := range body["ns"].([]any) {
|
||||
n := v.(string)
|
||||
if strings.HasSuffix(n, "."+domain) && d.Glue[n] == nil {
|
||||
fail(400, "INVALID_NAMESERVER", "Host "+n+" doesn't exist at the registry.")
|
||||
return
|
||||
}
|
||||
ns = append(ns, n)
|
||||
}
|
||||
d.NS = ns
|
||||
reply(200, map[string]any{"status": "SUCCESS"})
|
||||
case "domain/getGlue":
|
||||
hosts := []any{}
|
||||
for h, a := range d.Glue {
|
||||
v4, v6 := []string{}, []string{}
|
||||
for _, ip := range a {
|
||||
if strings.Contains(ip, ":") {
|
||||
v6 = append(v6, ip)
|
||||
} else {
|
||||
v4 = append(v4, ip)
|
||||
}
|
||||
}
|
||||
hosts = append(hosts, []any{h, map[string]any{"v4": v4, "v6": v6}})
|
||||
}
|
||||
reply(200, map[string]any{"status": "SUCCESS", "hosts": hosts})
|
||||
case "domain/createGlue":
|
||||
if d.Glue[host] != nil {
|
||||
fail(400, "GLUE_EXISTS", "Host already exists.")
|
||||
return
|
||||
}
|
||||
d.Glue[host] = ips()
|
||||
reply(200, map[string]any{"status": "SUCCESS"})
|
||||
case "domain/updateGlue":
|
||||
if d.Glue[host] == nil {
|
||||
fail(400, "GLUE_NOT_FOUND", "Host not found.")
|
||||
return
|
||||
}
|
||||
d.Glue[host] = ips()
|
||||
reply(200, map[string]any{"status": "SUCCESS"})
|
||||
case "domain/deleteGlue":
|
||||
for _, n := range d.NS {
|
||||
if n == host {
|
||||
fail(400, "GLUE_IN_USE", "Host is in use as a name server.")
|
||||
return
|
||||
}
|
||||
}
|
||||
delete(d.Glue, host)
|
||||
reply(200, map[string]any{"status": "SUCCESS"})
|
||||
case "dns/getDnssecRecords":
|
||||
if len(d.DS) == 0 {
|
||||
reply(200, map[string]any{"status": "SUCCESS", "records": []any{}})
|
||||
return
|
||||
}
|
||||
recs := map[string]any{}
|
||||
for tag, ds := range d.DS {
|
||||
recs[strconv.Itoa(int(tag))] = map[string]any{"keyTag": strconv.Itoa(int(tag)), "alg": strconv.Itoa(int(ds.Algorithm)), "digestType": strconv.Itoa(int(ds.DigestType)), "digest": strings.ToLower(ds.Digest)}
|
||||
}
|
||||
reply(200, map[string]any{"status": "SUCCESS", "records": recs})
|
||||
case "dns/createDnssecRecord":
|
||||
num := func(k string) int { n, _ := strconv.Atoi(body[k].(string)); return n }
|
||||
tag := uint16(num("keyTag"))
|
||||
d.DS[tag] = DS{KeyTag: tag, Algorithm: uint8(num("alg")), DigestType: uint8(num("digestType")), Digest: strings.ToUpper(body["digest"].(string))}
|
||||
reply(200, map[string]any{"status": "SUCCESS"})
|
||||
case "dns/deleteDnssecRecord":
|
||||
tag, _ := strconv.Atoi(parts[3])
|
||||
delete(d.DS, uint16(tag))
|
||||
reply(200, map[string]any{"status": "SUCCESS", "message": "Deleted."})
|
||||
default:
|
||||
fail(404, "NOT_FOUND", "Unknown endpoint.")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,303 @@
|
||||
// Package porkbun is a small client for the parts of Porkbun's API v3
|
||||
// (https://porkbun.com/api/json/v3/documentation) that Jarl uses to manage
|
||||
// a domain's delegation: its name servers, glue records (host objects at
|
||||
// the registry) and DNSSEC DS records.
|
||||
//
|
||||
// Every call is a POST with the API key and secret in the JSON body. API
|
||||
// access must be switched on for each domain in Porkbun's web interface,
|
||||
// and a key can be limited to some domains, permissions and source
|
||||
// addresses there.
|
||||
package porkbun
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// DefaultURL is the API's base URL.
|
||||
const DefaultURL = "https://api.porkbun.com/api/json/v3"
|
||||
|
||||
// Client calls the Porkbun API.
|
||||
type Client struct {
|
||||
BaseURL string
|
||||
APIKey string
|
||||
Secret string
|
||||
HTTP *http.Client
|
||||
}
|
||||
|
||||
// Error is an error the API returned.
|
||||
type Error struct {
|
||||
Status int // HTTP status
|
||||
Code string // e.g. API_ACCESS_DISABLED
|
||||
Message string
|
||||
}
|
||||
|
||||
func (e *Error) Error() string {
|
||||
msg := "Porkbun: " + e.Message
|
||||
if e.Code != "" {
|
||||
msg += " (" + e.Code + ")"
|
||||
}
|
||||
return msg
|
||||
}
|
||||
|
||||
// Glue is a glue record (a host object at the registry).
|
||||
type Glue struct {
|
||||
Host string `json:"host"` // full host name, without a trailing dot
|
||||
V4 []string `json:"v4"`
|
||||
V6 []string `json:"v6"`
|
||||
}
|
||||
|
||||
// Addresses returns all of the glue's addresses.
|
||||
func (g Glue) Addresses() []string { return append(append([]string{}, g.V4...), g.V6...) }
|
||||
|
||||
// DS is a DS record at the registry.
|
||||
type DS struct {
|
||||
KeyTag uint16 `json:"key_tag"`
|
||||
Algorithm uint8 `json:"algorithm"`
|
||||
DigestType uint8 `json:"digest_type"`
|
||||
Digest string `json:"digest"`
|
||||
}
|
||||
|
||||
type response struct {
|
||||
Status string `json:"status"`
|
||||
Message string `json:"message"`
|
||||
Code string `json:"code"`
|
||||
Warnings []string `json:"warnings"`
|
||||
NS []string `json:"ns"`
|
||||
Hosts json.RawMessage `json:"hosts"`
|
||||
Records json.RawMessage `json:"records"`
|
||||
YourIP string `json:"yourIp"`
|
||||
Valid bool `json:"credentialsValid"`
|
||||
Account string `json:"account"`
|
||||
Scopes []string `json:"scopes"`
|
||||
}
|
||||
|
||||
func (c *Client) call(ctx context.Context, path string, body map[string]any) (*response, error) {
|
||||
if body == nil {
|
||||
body = map[string]any{}
|
||||
}
|
||||
body["apikey"], body["secretapikey"] = c.APIKey, c.Secret
|
||||
b, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
base := c.BaseURL
|
||||
if base == "" {
|
||||
base = DefaultURL
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, strings.TrimSuffix(base, "/")+path, bytes.NewReader(b))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("User-Agent", "jarl")
|
||||
hc := c.HTTP
|
||||
if hc == nil {
|
||||
hc = &http.Client{Timeout: 30 * time.Second}
|
||||
}
|
||||
resp, err := hc.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Porkbun: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
raw, err := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Porkbun: %w", err)
|
||||
}
|
||||
var r response
|
||||
if err := json.Unmarshal(raw, &r); err != nil {
|
||||
return nil, &Error{Status: resp.StatusCode, Message: fmt.Sprintf("unexpected answer (HTTP %d)", resp.StatusCode)}
|
||||
}
|
||||
if r.Status != "SUCCESS" {
|
||||
msg := r.Message
|
||||
if msg == "" {
|
||||
msg = fmt.Sprintf("request failed (HTTP %d)", resp.StatusCode)
|
||||
}
|
||||
return &r, &Error{Status: resp.StatusCode, Code: r.Code, Message: msg}
|
||||
}
|
||||
return &r, nil
|
||||
}
|
||||
|
||||
func domainPath(op, domain string, rest ...string) string {
|
||||
p := op + "/" + url.PathEscape(strings.ToLower(strings.TrimSuffix(domain, ".")))
|
||||
for _, r := range rest {
|
||||
p += "/" + url.PathEscape(r)
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// Ping checks the credentials and returns the account name and the key's
|
||||
// permission scopes.
|
||||
func (c *Client) Ping(ctx context.Context) (account string, scopes []string, err error) {
|
||||
r, err := c.call(ctx, "/ping", nil)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
if !r.Valid {
|
||||
return "", nil, &Error{Code: "INVALID_API_KEYS_001", Message: "the API key wasn't accepted"}
|
||||
}
|
||||
return r.Account, r.Scopes, nil
|
||||
}
|
||||
|
||||
// NS returns the domain's name servers at the registry (lowercase,
|
||||
// without trailing dots, sorted).
|
||||
func (c *Client) NS(ctx context.Context, domain string) ([]string, error) {
|
||||
r, err := c.call(ctx, domainPath("/domain/getNs", domain), nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := []string{}
|
||||
for _, n := range r.NS {
|
||||
out = append(out, normName(n))
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// SetNS replaces the domain's name servers at the registry.
|
||||
func (c *Client) SetNS(ctx context.Context, domain string, ns []string) error {
|
||||
list := make([]string, len(ns))
|
||||
for i, n := range ns {
|
||||
list[i] = normName(n)
|
||||
}
|
||||
_, err := c.call(ctx, domainPath("/domain/updateNs", domain), map[string]any{"ns": list})
|
||||
return err
|
||||
}
|
||||
|
||||
// Glue returns the domain's glue records.
|
||||
func (c *Client) Glue(ctx context.Context, domain string) ([]Glue, error) {
|
||||
r, err := c.call(ctx, domainPath("/domain/getGlue", domain), nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := []Glue{}
|
||||
if len(r.Hosts) == 0 || string(r.Hosts) == "null" {
|
||||
return out, nil
|
||||
}
|
||||
var tuples [][]json.RawMessage
|
||||
if err := json.Unmarshal(r.Hosts, &tuples); err != nil {
|
||||
return nil, fmt.Errorf("Porkbun: unexpected glue records: %w", err)
|
||||
}
|
||||
for _, t := range tuples {
|
||||
if len(t) != 2 {
|
||||
continue
|
||||
}
|
||||
var g Glue
|
||||
var ips struct{ V4, V6 []string }
|
||||
if json.Unmarshal(t[0], &g.Host) != nil || json.Unmarshal(t[1], &ips) != nil {
|
||||
continue
|
||||
}
|
||||
g.Host, g.V4, g.V6 = normName(g.Host), nonNil(ips.V4), nonNil(ips.V6)
|
||||
out = append(out, g)
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].Host < out[j].Host })
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// SetGlue creates the glue record for host (a name inside domain) or, when
|
||||
// exists, replaces its addresses.
|
||||
func (c *Client) SetGlue(ctx context.Context, domain, host string, ips []string, exists bool) error {
|
||||
sub, err := subdomain(domain, host)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
op := "/domain/createGlue"
|
||||
if exists {
|
||||
op = "/domain/updateGlue"
|
||||
}
|
||||
_, err = c.call(ctx, domainPath(op, domain, sub), map[string]any{"ips": ips})
|
||||
return err
|
||||
}
|
||||
|
||||
// DeleteGlue deletes the glue record for host. The registry refuses while
|
||||
// the domain (or another one) uses it as a name server.
|
||||
func (c *Client) DeleteGlue(ctx context.Context, domain, host string) error {
|
||||
sub, err := subdomain(domain, host)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = c.call(ctx, domainPath("/domain/deleteGlue", domain, sub), nil)
|
||||
return err
|
||||
}
|
||||
|
||||
// DS returns the domain's DS records at the registry.
|
||||
func (c *Client) DS(ctx context.Context, domain string) ([]DS, error) {
|
||||
r, err := c.call(ctx, domainPath("/dns/getDnssecRecords", domain), nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := []DS{}
|
||||
type rec struct {
|
||||
KeyTag json.Number `json:"keyTag"`
|
||||
Alg json.Number `json:"alg"`
|
||||
DigestType json.Number `json:"digestType"`
|
||||
Digest string `json:"digest"`
|
||||
}
|
||||
var recs []rec
|
||||
// An object keyed by key tag; an empty one may come as [].
|
||||
var byTag map[string]rec
|
||||
if err := json.Unmarshal(r.Records, &byTag); err == nil {
|
||||
for _, v := range byTag {
|
||||
recs = append(recs, v)
|
||||
}
|
||||
} else if err := json.Unmarshal(r.Records, &recs); err != nil && len(r.Records) > 0 && string(r.Records) != "null" {
|
||||
return nil, fmt.Errorf("Porkbun: unexpected DNSSEC records: %w", err)
|
||||
}
|
||||
for _, v := range recs {
|
||||
tag, _ := strconv.ParseUint(v.KeyTag.String(), 10, 16)
|
||||
alg, _ := strconv.ParseUint(v.Alg.String(), 10, 8)
|
||||
dt, _ := strconv.ParseUint(v.DigestType.String(), 10, 8)
|
||||
out = append(out, DS{KeyTag: uint16(tag), Algorithm: uint8(alg), DigestType: uint8(dt), Digest: strings.ToUpper(v.Digest)})
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].KeyTag < out[j].KeyTag })
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// AddDS adds a DS record at the registry. Warnings (a value the registry is
|
||||
// retiring) come back with a nil error.
|
||||
func (c *Client) AddDS(ctx context.Context, domain string, ds DS) ([]string, error) {
|
||||
r, err := c.call(ctx, domainPath("/dns/createDnssecRecord", domain), map[string]any{
|
||||
"keyTag": strconv.Itoa(int(ds.KeyTag)), "alg": strconv.Itoa(int(ds.Algorithm)),
|
||||
"digestType": strconv.Itoa(int(ds.DigestType)), "digest": ds.Digest,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return r.Warnings, nil
|
||||
}
|
||||
|
||||
// DeleteDS deletes the DS record with key tag at the registry. Porkbun
|
||||
// warns that most registries delete every record with the same key data.
|
||||
func (c *Client) DeleteDS(ctx context.Context, domain string, keyTag uint16) error {
|
||||
_, err := c.call(ctx, domainPath("/dns/deleteDnssecRecord", domain, strconv.Itoa(int(keyTag))), nil)
|
||||
return err
|
||||
}
|
||||
|
||||
// subdomain returns host's label(s) below domain ("ns1" for
|
||||
// ns1.example.com in example.com).
|
||||
func subdomain(domain, host string) (string, error) {
|
||||
d, h := normName(domain), normName(host)
|
||||
sub, ok := strings.CutSuffix(h, "."+d)
|
||||
if !ok || sub == "" {
|
||||
return "", fmt.Errorf("%s isn't inside %s, so it can't have a glue record there", h, d)
|
||||
}
|
||||
return sub, nil
|
||||
}
|
||||
|
||||
func normName(n string) string { return strings.ToLower(strings.TrimSuffix(strings.TrimSpace(n), ".")) }
|
||||
|
||||
func nonNil(s []string) []string {
|
||||
if s == nil {
|
||||
return []string{}
|
||||
}
|
||||
return s
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package porkbun
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestClient(t *testing.T) {
|
||||
f := NewFake("pk1_x", "sk1_y", "adbraun.lol")
|
||||
ts := httptest.NewServer(f)
|
||||
defer ts.Close()
|
||||
c := &Client{BaseURL: ts.URL + "/api/json/v3", APIKey: "pk1_x", Secret: "sk1_y"}
|
||||
ctx := context.Background()
|
||||
|
||||
if acct, _, err := c.Ping(ctx); err != nil || acct != "tester" {
|
||||
t.Fatalf("ping: %q %v", acct, err)
|
||||
}
|
||||
ns, err := c.NS(ctx, "adbraun.lol.")
|
||||
if err != nil || strings.Join(ns, " ") != "curitiba.ns.porkbun.com fortaleza.ns.porkbun.com" {
|
||||
t.Errorf("NS: %v %v", ns, err)
|
||||
}
|
||||
// Two glue records, one with IPv4 and IPv6.
|
||||
if err := c.SetGlue(ctx, "adbraun.lol", "ns1.adbraun.lol.", []string{"203.0.113.1", "2001:db8::1"}, false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := c.SetGlue(ctx, "adbraun.lol", "ns2.adbraun.lol", []string{"203.0.113.2"}, false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := c.SetGlue(ctx, "adbraun.lol", "ns2.adbraun.lol", []string{"203.0.113.3"}, true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := c.SetGlue(ctx, "adbraun.lol", "ns.elsewhere.net", nil, false); err == nil {
|
||||
t.Error("glue outside the domain accepted")
|
||||
}
|
||||
glue, err := c.Glue(ctx, "adbraun.lol")
|
||||
if err != nil || len(glue) != 2 || glue[0].Host != "ns1.adbraun.lol" || strings.Join(glue[0].Addresses(), " ") != "203.0.113.1 2001:db8::1" || glue[1].V4[0] != "203.0.113.3" {
|
||||
t.Errorf("glue: %+v %v", glue, err)
|
||||
}
|
||||
if err := c.SetNS(ctx, "adbraun.lol", []string{"ns1.adbraun.lol.", "ns2.adbraun.lol"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := c.DeleteGlue(ctx, "adbraun.lol", "ns2.adbraun.lol"); err == nil {
|
||||
t.Error("deleted glue in use")
|
||||
}
|
||||
// DS records: empty comes as [], others keyed by tag.
|
||||
if ds, err := c.DS(ctx, "adbraun.lol"); err != nil || len(ds) != 0 {
|
||||
t.Errorf("no DS: %v %v", ds, err)
|
||||
}
|
||||
if _, err := c.AddDS(ctx, "adbraun.lol", DS{KeyTag: 22642, Algorithm: 13, DigestType: 2, Digest: "ABCDEF"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if ds, err := c.DS(ctx, "adbraun.lol"); err != nil || len(ds) != 1 || ds[0].KeyTag != 22642 || ds[0].Algorithm != 13 || ds[0].Digest != "ABCDEF" {
|
||||
t.Errorf("DS: %+v %v", ds, err)
|
||||
}
|
||||
if err := c.DeleteDS(ctx, "adbraun.lol", 22642); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Errors carry Porkbun's code.
|
||||
f.Domains["adbraun.lol"].APIAccess = false
|
||||
_, err = c.NS(ctx, "adbraun.lol")
|
||||
var pe *Error
|
||||
if !errors.As(err, &pe) || pe.Code != "API_ACCESS_DISABLED" {
|
||||
t.Errorf("error: %v", err)
|
||||
}
|
||||
bad := &Client{BaseURL: c.BaseURL, APIKey: "pk1_x", Secret: "wrong"}
|
||||
if _, _, err := bad.Ping(ctx); err == nil || !strings.Contains(err.Error(), "INVALID_API_KEYS") {
|
||||
t.Errorf("bad key: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
package render
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"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
|
||||
}
|
||||
|
||||
// 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 the 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
|
||||
}
|
||||
}
|
||||
for _, b := range lists {
|
||||
if !b.Disabled && slices.Contains(s.BlockingViews(b.ViewIDs), vid) {
|
||||
zones = append(zones, PolicyZone{Name: blocking.ListZone(b.Name), ViewID: vid, View: view, File: p.BlocklistFile(b.ID), ListID: b.ID})
|
||||
}
|
||||
}
|
||||
// While paused, every view with blocking gets a rules zone: it
|
||||
// lets everything through.
|
||||
if hasRules || s.Settings.BlockingPaused() && len(zones) > 0 {
|
||||
out = append(out, PolicyZone{Name: blocking.RulesZone, ViewID: vid, View: view, File: p.rulesFile(view)})
|
||||
}
|
||||
out = append(out, zones...)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// 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"}
|
||||
if !logRewrites {
|
||||
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"
|
||||
if z.ListID != 0 {
|
||||
label = "blocking: blocklist " + s.Blocklists[z.ListID].Name
|
||||
}
|
||||
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
|
||||
}
|
||||
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
|
||||
}
|
||||
@@ -62,12 +62,18 @@ type Span struct {
|
||||
// Output is the result of rendering.
|
||||
type Output struct {
|
||||
Files []*File
|
||||
// Dirs are directories named needs to exist (for secondary zone files).
|
||||
// Dirs are directories named must be able to write to (secondary zone
|
||||
// files, DNSSEC key directories).
|
||||
Dirs []string
|
||||
// Spans maps file paths to source spans.
|
||||
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.
|
||||
@@ -103,6 +109,22 @@ func ViewDir(viewName string) string {
|
||||
return dnsname.FileSafe(dnsname.Fqdn(strings.ToLower(viewName)))
|
||||
}
|
||||
|
||||
// KeysDir is the directory under which named keeps the DNSSEC keys of the
|
||||
// zones Jarl signs, one subdirectory per view.
|
||||
func (p Paths) KeysDir() string { return filepath.Join(p.ZoneDir, "dnssec-keys") }
|
||||
|
||||
// KeyDir returns the key-directory of zone z: one per view, because named
|
||||
// refuses one directory for zones of the same name in two views. It is
|
||||
// named after the view's ID, which unlike its name never changes: a new
|
||||
// directory would make named create new keys, which the parent's DS
|
||||
// record wouldn't match.
|
||||
func KeyDir(s *model.State, z *model.Zone, p Paths) string {
|
||||
if z.ViewID == 0 {
|
||||
return filepath.Join(p.KeysDir(), "_default")
|
||||
}
|
||||
return filepath.Join(p.KeysDir(), fmt.Sprintf("view-%d", z.ViewID))
|
||||
}
|
||||
|
||||
// ZoneFilePath returns the file named loads for zone z, or "" for zone
|
||||
// types without files.
|
||||
func ZoneFilePath(s *model.State, z *model.Zone, p Paths) string {
|
||||
@@ -127,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})
|
||||
@@ -247,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")
|
||||
@@ -257,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))
|
||||
@@ -272,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,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)
|
||||
@@ -321,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)
|
||||
@@ -335,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)
|
||||
@@ -367,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))
|
||||
@@ -387,6 +424,14 @@ func renderZone(w *confWriter, s *model.State, z *model.Zone, p Paths, out *Outp
|
||||
}
|
||||
}
|
||||
}
|
||||
if z.Signed() {
|
||||
// named signs the zone itself (inline signing): it keeps the
|
||||
// signed copy next to Jarl's file (.signed) and its keys in dir.
|
||||
dir := KeyDir(s, z, p)
|
||||
w.write(fmt.Sprintf("%s\tdnssec-policy %s;\n", tabs, namedconf.QuoteString(z.DNSSECPolicy)))
|
||||
w.write(fmt.Sprintf("%s\tkey-directory %s;\n", tabs, namedconf.QuoteString(dir)))
|
||||
out.Dirs = appendUnique(out.Dirs, dir)
|
||||
}
|
||||
for _, o := range s.ScopedOptions(model.ScopeZone, z.ID) {
|
||||
w.begin("option", o.ID, label+" option "+o.Name)
|
||||
w.write(optionText(o, indent+1))
|
||||
@@ -436,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()
|
||||
@@ -453,7 +502,7 @@ 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))
|
||||
|
||||
Loaded 100 of 180 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user