Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
67a829be34 | ||
|
|
42e79af678 | ||
|
|
3fb159cf4d | ||
|
|
71ebed2b73 | ||
|
|
95125e7f50 | ||
|
|
c10ebe0d38 | ||
|
|
3d1911dd38 | ||
|
|
310ed8061e | ||
|
|
b367e0c830 | ||
|
|
254867c61d | ||
|
|
3ddd32f97f | ||
|
|
0976faba3f | ||
|
|
1d535c8063 | ||
|
|
7f3564c0a5 | ||
|
|
71d9a06e43 | ||
|
|
2fa9828bcf | ||
|
|
a93373687e | ||
|
|
6cd3f6ef4e | ||
|
|
d18fe18ccd | ||
|
|
fa8feb6bd9 | ||
|
|
12013b12d5 | ||
|
|
9db76a113c | ||
|
|
b4bb99b482 | ||
|
|
57012fece8 | ||
|
|
4cc2ab2287 | ||
|
|
a36c54ce60 | ||
|
|
82c282cd32 | ||
|
|
65086e314a | ||
|
|
e38390df7d | ||
|
|
0e663ab36c | ||
|
|
5cd22f52d4 | ||
|
|
aca0abc780 | ||
|
|
9175d75d61 | ||
|
|
48f0082d8b | ||
|
|
d4399cf630 | ||
|
|
d2a25d654d | ||
|
|
87368c4aaf | ||
|
|
7d93c373d1 | ||
|
|
e94d3634b6 | ||
|
|
a8c7760fc8 | ||
|
|
5443e95897 | ||
|
|
ea26171875 | ||
|
|
250e734046 | ||
|
|
1b102a78f6 | ||
|
|
01ec37b9b2 | ||
|
|
fef916b7ab | ||
|
|
bfab83da6e | ||
|
|
304c89dba4 | ||
|
|
b1719ce362 | ||
|
|
ec16de875d | ||
|
|
b24c730b97 | ||
|
|
aa4b004a14 | ||
|
|
a3b054cc86 | ||
|
|
c27a6c10bd | ||
|
|
d33f573782 | ||
|
|
13ffd8cad4 | ||
|
|
67ae0091b3 | ||
|
|
c7e5bbb628 | ||
|
|
740f3cc328 | ||
|
|
5fcf4082c9 | ||
|
|
1cec6cc393 | ||
|
|
921bdd6c95 | ||
|
|
12820b17bb | ||
|
|
ead749dd3d | ||
|
|
66dcf53684 | ||
|
|
ebd3a3dfbe | ||
|
|
fd0cbb47a9 | ||
|
|
057433c198 | ||
|
|
6313fa51f8 | ||
|
|
88f63ffb0a | ||
|
|
88965096bb | ||
|
|
bad360107e | ||
|
|
bdd7acfd3b | ||
|
|
cf550bd512 | ||
|
|
cee11e7c86 | ||
|
|
e2e5007f94 | ||
|
|
49b1c872c9 | ||
|
|
c37de31f4c | ||
|
|
7e6e274a89 | ||
|
|
11ed877c3c | ||
|
|
76aaaf3ef5 | ||
|
|
5b8f681e2a | ||
|
|
8f0a0cc233 | ||
|
|
ecffc2d5c1 | ||
|
|
9d9ae37367 | ||
|
|
384f6b3060 | ||
|
|
c696d02513 | ||
|
|
7be0762f48 | ||
|
|
b097e10bef | ||
|
|
73d97ecc0a | ||
|
|
33060f86ce | ||
|
|
89663e366b | ||
|
|
59baccd636 | ||
|
|
02c393a6bb | ||
|
|
d4756444ac | ||
|
|
67f14b030f | ||
|
|
ad8eb3fc3d | ||
|
|
27d1f9e538 | ||
|
|
9fc23310bd | ||
|
|
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
|
||||
|
||||
@@ -3,3 +3,5 @@
|
||||
*.db
|
||||
*.db-wal
|
||||
*.db-shm
|
||||
/jarl
|
||||
/jarld
|
||||
@@ -0,0 +1,197 @@
|
||||
# Working on Jarl
|
||||
|
||||
Notes for coding agents (and people) changing this repository. Read this
|
||||
first. For more depth, see [docs/development.md](docs/development.md) for
|
||||
layout, building, tests and releasing, and [docs/concepts.md](docs/concepts.md)
|
||||
for how Jarl works from the outside.
|
||||
|
||||
Jarl manages a BIND 9.20 server. It has three parts:
|
||||
|
||||
* `jarld`, a daemon with an HTTP API, an embedded web UI and SQLite storage;
|
||||
* `jarl`, a CLI for that API;
|
||||
* a Debian package.
|
||||
|
||||
It's all Go: module path `jarl`, version in `go.mod`, `CGO_ENABLED=0`. The
|
||||
SQLite driver is `modernc.org/sqlite`, so there is no cgo. The target is
|
||||
Debian/Ubuntu with BIND 9.20, using Debian's paths (`/etc/bind`, the `bind`
|
||||
group, AppArmor).
|
||||
|
||||
## Ground rules
|
||||
|
||||
* **The database is the source of truth.** Jarl renders `named.conf`,
|
||||
`keys.conf` and the zone files from it. Never hand-edit rendered output or
|
||||
add code that reads BIND's files back as state; the importer is the only
|
||||
exception.
|
||||
* **Every change goes through the engine** (`internal/engine`). An API
|
||||
request becomes ops in `expand.go`/`ops.go`. The engine then runs
|
||||
replay → validate → render → `named-checkconf`/`named-checkzone` →
|
||||
atomic install → `rndc reload` → verify serials, and rolls back on
|
||||
failure. Going through the engine gives a change permissions, dry runs,
|
||||
changesets, history and undo for free, so don't write to the store
|
||||
directly from handlers.
|
||||
* **Every user-visible change updates `CHANGELOG.md`** under
|
||||
`## [Unreleased]`, and the matching page in `docs/` in the same commit.
|
||||
Docs that may need it:
|
||||
|
||||
* `cli.md`, `api.md` and `configuration.md`;
|
||||
* `validation.md`, for new validation codes;
|
||||
* `metrics.md`;
|
||||
* the feature's own page.
|
||||
|
||||
Write docs in plain, direct English, like the existing pages.
|
||||
* **Error messages** say what is wrong and how to fix it, naming the object.
|
||||
For example: `view external is reachable from the internet …; set
|
||||
minimal-any yes`.
|
||||
* **New validation rules** get a stable `code`, a test, and a row in
|
||||
`docs/validation.md`.
|
||||
* **Schema changes** append a migration to the `migrations` slice in
|
||||
`internal/store/store.go`, and never edit an old one. The statistics
|
||||
database has its own list, `schema` in `internal/stats/store.go`. Add a note
|
||||
under "Upgrading" in the CHANGELOG when a downgrade would break.
|
||||
* `make test` runs `go vet` and a `gofmt -l` check, and both must pass.
|
||||
`goimports` isn't installed, so fix imports by hand.
|
||||
|
||||
## Building and testing
|
||||
|
||||
```sh
|
||||
make # bin/jarld, bin/jarl, bash completion
|
||||
make test # vet + gofmt check + go test ./...
|
||||
JARL_BIND_ROOT=DIR make test # also runs the integration tests against real BIND
|
||||
JARL_BIND_ROOT=DIR make e2e # scripts/e2e.sh: real named + jarld, drives every CLI command
|
||||
```
|
||||
|
||||
* **Real BIND without root.** Get the packages with `apt-get download` and
|
||||
unpack them with `dpkg -x` into a directory; that directory is
|
||||
`JARL_BIND_ROOT`. The package list is in docs/development.md, under "Tests
|
||||
against real BIND". Without `JARL_BIND_ROOT` the integration tests skip,
|
||||
so a green `make test` alone proves less than it looks.
|
||||
* **Run e2e after any engine, render, CLI or server change.** It prints a
|
||||
pass count, such as `606/606`; a failure prints the command and its
|
||||
output.
|
||||
* It uses fixed ports: DNS 25353, rndc 25953, HTTP 28053, lists 28054 and
|
||||
metrics 28055. Stop anything left running on them first.
|
||||
* `KEEP=1` leaves named and jarld running; the last line of the log has
|
||||
their PIDs.
|
||||
* `WORK=DIR` must name an empty directory.
|
||||
* **Certificate tests** run against Pebble in Docker, unless you set
|
||||
`PEBBLE=0`. If `docker info` fails, the Pebble block is silently skipped;
|
||||
on this machine it needs `DOCKER_HOST=unix:///var/run/docker.sock`.
|
||||
* **Unix socket paths** are limited to 107 bytes. Put test sockets under
|
||||
`/run/user/$UID/`, not under a deep temporary directory.
|
||||
* **The systemd sandbox isn't tested.** e2e runs outside systemd, so bugs
|
||||
that only appear in the sandbox (`RestrictAddressFamilies`,
|
||||
capabilities, read-only paths) need a package test. Run `make deb`, then
|
||||
install it in a `debian:trixie` container. That container has BIND 9.20;
|
||||
run it with `--privileged /sbin/init` when you need the real unit.
|
||||
|
||||
## Layout and where things live
|
||||
|
||||
docs/development.md has the full layout. Some places that are easy to miss:
|
||||
|
||||
* **Engine** (`internal/engine`):
|
||||
* `expand.go` turns requests into ops.
|
||||
* `linked.go`: linked zones, kept in step across views by `linkSync`.
|
||||
* `reverse.go`: PTR fill, auto-PTR and reverse zone suggestions.
|
||||
* `compare.go`: compare views.
|
||||
* `exposure.go`: fixes for internet exposure.
|
||||
* **Validation**: `internal/validate` holds all checks and lints.
|
||||
`internal/catalog` is the BIND 9.20 grammar and ARM text for every
|
||||
statement. Use it rather than hard-coding option knowledge.
|
||||
* **Rendering**: `internal/render` writes the configuration.
|
||||
`generated.go` holds the statements Jarl adds itself, shown to users as
|
||||
"Added by Jarl".
|
||||
* **Server**: `internal/server` has one `h_*.go` file per API area; routes
|
||||
are in `routes.go`. The JSON types are in `internal/api`, shared with the
|
||||
Go client in `internal/client`.
|
||||
* **CLI**: `cmd/jarl` is built on cobra. `complete.go` completes the
|
||||
placeholders in usage lines (`ZONE`, `VIEW`, …), so name them
|
||||
consistently.
|
||||
* **Statistics**: `internal/stats` is query statistics, kept in a
|
||||
**separate** SQLite file, `stats.db`, with its own migrations.
|
||||
* **Feature packages**: each has its own doc page.
|
||||
* `encdns`: encrypted DNS;
|
||||
* `certs` and `acme`: certificates and ACME;
|
||||
* `blocking`: response policy zones;
|
||||
* `dnssec`;
|
||||
* `nscheck`: queries to other name servers;
|
||||
* `mailcheck`: mail reverse DNS;
|
||||
* `totp`: two-factor login;
|
||||
* `backup`;
|
||||
* `porkbun`: registrar API, with an in-memory `Fake`. Tests never call
|
||||
the real API.
|
||||
|
||||
## Web UI rules
|
||||
|
||||
`internal/web/static/` holds the UI: plain ES modules, no framework, no
|
||||
build step. The files are embedded with `go:embed`.
|
||||
|
||||
* **Rebuild jarld after editing them**, or you'll be testing the old files.
|
||||
* **Build DOM only with `h()`** from `assets/ui.js`. Never use `innerHTML`
|
||||
or `insertAdjacentHTML`. That's how record data can't inject markup.
|
||||
* **The CSP is `self` only.** That rules out:
|
||||
* inline scripts;
|
||||
* `style="…"` attributes and inline style strings (`el.setAttribute("style", …)`);
|
||||
* external fonts and CDNs.
|
||||
|
||||
Use a CSS class in `style.css`, or set individual properties with
|
||||
`el.style.prop = …` (CSSOM is allowed).
|
||||
* **Colors are CSS custom properties** with light and dark values. Reuse the
|
||||
existing tokens.
|
||||
* **Pages live in `assets/pages/`**, one file per route. The router is in
|
||||
`app.js` and the API calls are in `api.js`.
|
||||
* **What the user wants:** functionality and ease of use matter more than
|
||||
visual polish. In pick lists, leave every checkbox unticked by default.
|
||||
|
||||
There's no JS test suite. To catch syntax and import errors without a
|
||||
browser, bundle `assets/app.js` with esbuild, or load the page in headless
|
||||
Chrome.
|
||||
|
||||
## BIND facts that bit us
|
||||
|
||||
* named 9.20 crashes on an empty `keys {}` in a `dnssec-policy`. Validation
|
||||
refuses it.
|
||||
* `named-checkconf` doesn't catch a TTL above `max-zone-ttl`, but named then
|
||||
refuses to load the zone.
|
||||
* `responselog` only takes effect when named starts; use `rndc responselog
|
||||
on|off` instead.
|
||||
* `named -g` sends all logging to stderr, so tests use `-f`.
|
||||
* RPZ client-IP rules see IPv4 clients as v4-mapped (`::ffff:0:0/96`), so
|
||||
IPv6 prefix covers must leave that range out.
|
||||
* named serves DoH in RFC 8484 wire format only, not the JSON API. Test it
|
||||
with BIND's `dig +https` / `dig +tls`, not curl or kdig.
|
||||
* named can't forward over DoH, only over DoT (`forwarders port 853 tls NAME`).
|
||||
* `rndc reconfig` makes named reload renewed TLS certificate files.
|
||||
|
||||
## Git and releases
|
||||
|
||||
These are the maintainer's rules; follow them unless told otherwise:
|
||||
|
||||
* **Commit only when asked. Never push**; the maintainer pushes, to Gitea
|
||||
at gitea.adbraun.lol.
|
||||
* **Work on `dev-X.Y.Z` branches** (for example `dev-0.6.1`) off `main`.
|
||||
Don't name a branch like a tag (`v0.5.2`): `git push` then fails with
|
||||
"src refspec matches more than one".
|
||||
* **A release** is a commit titled `Release X`.
|
||||
* It sets `VERSION`.
|
||||
* It updates the status line in the README.
|
||||
* For a final release, it moves `[Unreleased]` to
|
||||
`## [X] - YYYY-MM-DD`. A release candidate (`X-rc.N`) may leave the
|
||||
notes under `[Unreleased]`.
|
||||
* Tag it annotated: `git tag -a vX -m "Jarl X"`.
|
||||
* Before tagging, `make test` and the e2e run must pass.
|
||||
* Check the notes with `VERSION=X scripts/gitea-release.sh notes`.
|
||||
* **Pushing a `v*` tag** runs `.gitea/workflows/release.yml`. It builds the
|
||||
tarballs, the CLI for each OS and the `.deb`s, and publishes them.
|
||||
* **Before a minor release**, test an upgrade from the previous release's
|
||||
`.deb`s in a container. Check data, history, answers from named, and that
|
||||
an old backup restores.
|
||||
|
||||
## Shell hygiene
|
||||
|
||||
* **Never kill processes by pattern** with `pkill -f` or
|
||||
`pgrep -f … | xargs kill` in the same shell command. The pattern matches
|
||||
the shell running it, and kills that shell. Find the PID with `ps` and
|
||||
kill it by number.
|
||||
* **Start long-running test servers in the background** (your tool's
|
||||
background mode, not `&` in a shell that exits), so they outlive the
|
||||
command.
|
||||
+952
@@ -6,6 +6,958 @@ All notable changes to Jarl are documented here. The format follows
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- **Failed logins in the journal.** A failed login (a wrong password, or
|
||||
a wrong two-factor code) and a refused API token are logged as well as
|
||||
audited, with the address first (`msg="login failed" ip=… user=…`,
|
||||
`msg="token refused" ip=…`), for Guard's `jarl` preset or a fail2ban
|
||||
filter to ban such addresses (docs/operations.md).
|
||||
- **`jarl cert get --combined PATH`** also writes the key and the full
|
||||
chain in one file (mode 600, written all at once), for HAProxy. It
|
||||
belongs outside `--dir`, so a directory of combined files can be
|
||||
HAProxy's `crt` directory. The hook gets it as `JARL_CERT_COMBINED`.
|
||||
Adding it to a certificate this host already has writes it from the
|
||||
files on disk and runs the hook; `--combined ''` removes it.
|
||||
- **`jarl cert get --local`** doesn't ask jarld: it writes the combined
|
||||
file and runs the hook from this host's files, to apply a changed
|
||||
setting or test a hook.
|
||||
- **`jarl cert saved NAME`** shows all of one certificate's saved
|
||||
settings (names, key type, dir, combined file, hook, server) and this
|
||||
host's copy. The token is never shown.
|
||||
- `jarl cert list` has a PULLED column: when the certificate was last
|
||||
downloaded, with `(old)` while a host hasn't fetched the renewed one.
|
||||
The owner of a certificate obtained with `jarl cert get` now sees their
|
||||
own downloads (`pulled_by`) in `jarl cert list` and `jarl cert show`, not
|
||||
only administrators.
|
||||
|
||||
### Changed
|
||||
|
||||
- `jarl cert list` shows `get` as USES for a certificate obtained with
|
||||
`jarl cert get`, not `-`.
|
||||
- `jarl cert get` without a name refuses `--dir`, `--hook`, `--key-type`,
|
||||
`--combined`, `--replace` and `--no-save`, which change one
|
||||
certificate; they used to be ignored.
|
||||
|
||||
## [0.6.3] - 2026-10-21
|
||||
|
||||
### Added
|
||||
|
||||
- A docs/acme.md section: lego on a host inside a split DNS fails its own
|
||||
propagation check when the challenge is written into the external view
|
||||
only (`JARL_VIEW`, a one-view token, `.../httpreq/view/...`): why, and
|
||||
the ways round it (no view limit, `--dns.propagation.disable-ans` or
|
||||
`--dns.propagation.wait`, Traefik's `propagation.disableANSChecks`).
|
||||
- docs/acme.md and `jarl-lego` show lego 5's commands (`lego run ...`,
|
||||
which also renews; `lego migrate` when upgrading), with lego 4's
|
||||
beside them. Jarl's side is the same for both.
|
||||
|
||||
## [0.6.2] - 2026-10-21
|
||||
|
||||
### Added
|
||||
|
||||
- A `certificates` role (template): `certificates:write`, for tokens and
|
||||
users that get certificates for other hosts with `jarl cert get`. In
|
||||
the web UI's user and token forms it is in "Insert a template…", to
|
||||
narrow with `names=`; in the CLI, `--role certificates`.
|
||||
|
||||
## [0.6.1] - 2026-10-20
|
||||
|
||||
### Added
|
||||
|
||||
- Certificates for other hosts: `jarl cert get NAME -d DOMAIN --dir DIR
|
||||
--hook CMD` on a mail server or proxy has jarld obtain the certificate
|
||||
(and renew it), waits, downloads it when it is newer than the host's
|
||||
copy, and runs the hook with the file paths in `JARL_CERT_*`
|
||||
variables. Its settings are saved, so `jarl cert get` alone refreshes
|
||||
them all; `jarl cert schedule` writes a daily systemd timer for that;
|
||||
`jarl cert saved` lists them with their expiry; `jarl cert forget`
|
||||
stops one. A certificate belongs to the user that asked for it (other
|
||||
users can't take the name), is obtained again only when a name is
|
||||
added or removed, and the Certificates page shows its owner and who
|
||||
downloaded it, flagging hosts that haven't fetched a renewal. A new
|
||||
`certificates` grant (`certificates:write names=*.adbraun.lol`) says
|
||||
which names a user may have; it is never part of `*`. Users may own
|
||||
`certificates.max_per_user` certificates (10), or their own
|
||||
`--cert-limit`. API: `POST /certificates/{name}/request`.
|
||||
- Certificates can be exported for use outside Jarl: **Export…** on the
|
||||
Certificates page (download, or copy to the clipboard),
|
||||
`jarl cert export NAME [--dir DIR | --part PART]`
|
||||
and `GET /certificates/{name}/pem?part=fullchain|cert|key|bundle`.
|
||||
Administrators only; every export of the private key is audited
|
||||
(`certificate.export_key`).
|
||||
- A reverse zone's "addresses have no PTR record" banner lists them, with
|
||||
the name each would get, instead of only counting them
|
||||
(`ptr_missing_list` in the zone API).
|
||||
- A zone's page links to the same zone in the other views ("also in
|
||||
external"), in each view's colour, keeping the tab you are on. It
|
||||
works for zones that aren't linked too, and the zone API's new
|
||||
`in_views` lists them.
|
||||
- `jarl.service` has the alias `jarld.service`, so `systemctl restart
|
||||
jarld` works too. The alias is linked when the unit is enabled; the
|
||||
package relinks a unit enabled by an older version on upgrade.
|
||||
|
||||
### Changed
|
||||
|
||||
- The Statistics page's range buttons follow `stats.retention`: ranges
|
||||
longer than any data kept are left out, and **1y** and **2y** appear
|
||||
when `retention.day` (or `retention.hour`) covers them (with the
|
||||
default 730d, both do). The busiest-times heatmap says how far back it
|
||||
really goes (at most `retention.hour`), and links to the Explore page
|
||||
keep within `retention.queries` instead of assuming 7 days.
|
||||
`GET /stats/status` and `GET /stats/explore` add `retention_seconds`.
|
||||
|
||||
- A reverse zone's missing PTR records count only its own view's A and
|
||||
AAAA records. An address only another view's records use (an internal
|
||||
host, seen from the vps view's copy of `10.0.10.in-addr.arpa`) is no
|
||||
longer reported as missing; it shows apart, as `Other views` in `jarl
|
||||
zone show`, `ptr_other_views` in the API and a note on the zone's page.
|
||||
Filling (`--fill`, `jarl zone fill-ptr`, the page's button) names those
|
||||
from the other view only when asked, with `--other-views`
|
||||
(`fill_other_views`, `?other_views=1`, a checkbox in the New reverse
|
||||
zone dialog), so one view's host names don't leak into another. When
|
||||
there is nothing else to fill, Jarl says how many there are and how to
|
||||
add them. The page and `jarl zone show` list those addresses with the
|
||||
name each would get, and the view it comes from
|
||||
(`ptr_other_views_list`); `jarl zone show` lists the missing ones too.
|
||||
|
||||
- Records, zones and validation findings sort numeric labels by value, so
|
||||
`2.10.0.10.in-addr.arpa` comes before `101.10.0.10.in-addr.arpa` in the
|
||||
web UI, the CLI and the rendered zone files. Names still group by label
|
||||
from the right (subdomains stay under their parent); other labels keep
|
||||
the old byte order (`host10` before `host2`). Zone files are rewritten
|
||||
in the new order the next time they change; named doesn't care about
|
||||
record order.
|
||||
|
||||
- `cname_chain` no longer warns about one CNAME pointing at another (a
|
||||
service name at a host alias is common, and named answers the chain at
|
||||
once). A chain of three or more CNAMEs gets an info note that shows
|
||||
it.
|
||||
|
||||
- A certificate's private key is only exported over HTTPS, the local
|
||||
socket, or to the server itself (plain HTTP from another host is
|
||||
refused with `https_required`).
|
||||
|
||||
- The Certificates page's "Used for" column says DoT and DoH for named,
|
||||
with the addresses in the tooltip, instead of listing every address. A
|
||||
certificate with no use says "not used by Jarl" rather than "nothing
|
||||
yet": it may well be used elsewhere.
|
||||
|
||||
- Deleting a linked zone's local records (an override) removes that name
|
||||
and type from that view only. It used to bring the shared records back
|
||||
into the view, so a delete looked undone. The other views keep their
|
||||
records, local to each now; **Share** one view's version (`jarl record
|
||||
update ID --shared`) to have it everywhere again.
|
||||
|
||||
- A linked zone's Compare views tab starts with a view it is linked to,
|
||||
not the first other view.
|
||||
- The New token form only offers templates within what the token's user
|
||||
may do. A token never does more than its user, so offering more only
|
||||
misled.
|
||||
- A zone update that changes nothing is no change: saving a zone's
|
||||
settings without changing them says "Nothing changed" instead of
|
||||
staging an empty change. Staging operations that change nothing
|
||||
(`POST /changesets/{id}/ops`, or `?stage=`) is a 400 "nothing to
|
||||
change".
|
||||
|
||||
### Fixed
|
||||
|
||||
- `jarl cert get NAME` no longer takes over a name this host has saved
|
||||
for another Jarl server, which overwrote that certificate's settings
|
||||
(and, with the default `--dir`, its files), so it stopped being
|
||||
renewed. It is refused, as is a `--dir` holding another certificate's
|
||||
files; `--replace` takes the name or directory over.
|
||||
- The "query log is off" error named a setting that doesn't exist,
|
||||
`stats.query_retention`; it's `stats.retention.queries`.
|
||||
- `jarl zone show` for a reverse zone printed only "and 1 more" under
|
||||
"Other views:" when the server was older than the CLI (0.6.1-rc.2
|
||||
sends the count but not the addresses). It now prints just the count
|
||||
line then.
|
||||
- `jarl changeset show` printed its Created and Applied times in UTC,
|
||||
while `jarl history` and `jarl status` print local time. It uses local
|
||||
time now too.
|
||||
|
||||
- The statistics' "Busiest times" grid drew hours without data taller
|
||||
than the others (they picked up the page's padding for empty
|
||||
messages).
|
||||
|
||||
- A token for one view of a linked zone (such as an ACME token with
|
||||
`views=external`) couldn't add records: a new record is shared, and
|
||||
that reached the other view. A new name and type is now local to the
|
||||
view when the user may not change it in the other views.
|
||||
- A zone name in several views had to be given with `--view` even when
|
||||
the user could see it in only one (`jarl record list adbraun.lol` with
|
||||
a one-view token), and the error named views the user can't see. Now
|
||||
the one they can see is used.
|
||||
- `jarl view list` was empty for a user without `views:read`, though
|
||||
they can see zones in views. It now lists those views by name, colour
|
||||
and visible zone count (marked `limited` in the API), without their
|
||||
settings.
|
||||
|
||||
- The DNSSEC tab told you to give the DS record to your registrar for
|
||||
every zone whose parent isn't in Jarl in the same view. Now it (and
|
||||
`jarl zone dnssec status`, and the API's new `ds_at`, `ds_place` and
|
||||
`ds_note`) says where it really goes: the parent zone in Jarl (in any
|
||||
view now, the zone's own first), the registrar for a registered
|
||||
domain, the parent zone wherever it is hosted for a subdomain, your
|
||||
ISP or RIR for a reverse zone, and nowhere for a private name such as
|
||||
`home.lan` (signing it has no effect).
|
||||
|
||||
- The Registrar tab (and `jarl zone registrar`, and the DNSSEC page's
|
||||
registrar button) was offered for every zone when `porkbun.domains` is
|
||||
empty, and always failed for subdomain zones (`lab.adbraun.lol`),
|
||||
reverse zones and private names (`home.lan`): Porkbun only knows
|
||||
registered domains. Only zones that are a registered domain (by the
|
||||
public suffix list) get it now; the others say what delegates them
|
||||
instead.
|
||||
|
||||
- Auto-PTR in linked zones changed PTR records in every linked view, not
|
||||
just where the address record is. A record local to one view got PTR
|
||||
records in the other views too; deleting or changing the address in
|
||||
one view deleted or moved the PTR records of views that still had it.
|
||||
PTR records now follow each view's own address records, are local to
|
||||
their view in a linked reverse zone, and each zone's Reverse DNS
|
||||
setting counts for its own view. **Fill** also adds local PTR records
|
||||
now, not shared ones. The same applies to **Update PTR** (`--ptr`).
|
||||
|
||||
- Saving the settings of a zone whose primary name server is outside it
|
||||
(`ns1.adbraun.lol.` for `wg.adbraun.lol`) changed the name server to
|
||||
`ns1.adbraun.lol.wg.adbraun.lol.`. The form showed the name without its
|
||||
final dot, which the server reads as relative to the zone. Names outside
|
||||
the zone now keep the dot, and the name server is only sent when it was
|
||||
changed.
|
||||
|
||||
### Upgrading
|
||||
|
||||
- Tested: a 0.6.0 install with views, linked and reverse zones and
|
||||
statistics, opened by 0.6.1-rc.1: every zone, record and view kept,
|
||||
named answered as before, and the new grant and limits worked.
|
||||
- The database gains a migration (certificate owners, per-user
|
||||
certificate limits, certificate downloads). Going back to an older
|
||||
version afterwards means restoring a backup made before the upgrade.
|
||||
|
||||
## [0.6.0] - 2026-10-13
|
||||
|
||||
### Added
|
||||
|
||||
- Reverse zones made easy (docs/reverse.md):
|
||||
- **Missing reverse zones**: the Zones page and `jarl zone
|
||||
suggest-reverse` list the reverse zones the private views lack for
|
||||
their A and AAAA records' addresses: the /24 of a private IPv4 network,
|
||||
a /32 for a public address (the rest of its /24 belongs to others),
|
||||
the /48 of IPv6 addresses. Create one, or all (`--create`), filled.
|
||||
- **Fill**: `jarl zone create 10.0.10.0/24 --fill` (ticked in the New
|
||||
zone dialog) adds a PTR record for each address the view's records use
|
||||
there; `jarl zone fill-ptr` and the reverse zone's page add the ones
|
||||
missing later. An address with several names gets its mail or name
|
||||
server's name, else a host's.
|
||||
- **The right view**: a reverse zone created without a view goes to the
|
||||
private view whose records use its addresses.
|
||||
- **PTR records kept in step** per zone (`jarl zone update ZONE
|
||||
--auto-ptr`, zone settings → Reverse DNS): adding, changing,
|
||||
disabling or deleting an A or AAAA record does the same to its PTR
|
||||
record, as the Update PTR box does for one change.
|
||||
- Suggestions come in two groups: **yours** (private networks, a ULA or
|
||||
an IPv6 /48 this server has an address in), which Create all
|
||||
creates, and **public addresses**, each a /32 or /128 to create only
|
||||
if it is yours (not a web host's) or **Dismiss**. A view can turn
|
||||
suggestions off (`jarl view update vps --reverse-suggestions=false`),
|
||||
e.g. one only a VPS uses.
|
||||
- A zone created inside another in the same view (a /24 inside a /8,
|
||||
`lab.adbraun.lol` inside `adbraun.lol`) takes the parent's records
|
||||
under it, which named would no longer serve from the parent; a
|
||||
delegation and its glue stay.
|
||||
- Zone commands take a network for a reverse zone: `jarl zone show
|
||||
10.0.10.0/24`.
|
||||
- **New reverse zone** (Zones page) lists the networks your records use
|
||||
without a reverse zone, in every view, also where nothing is suggested
|
||||
(suggestions off, dismissed, public views); pick one, change its size
|
||||
(/48, /56, /64, /128 for IPv6, around the records' address) and create
|
||||
it filled. With a view chosen, it also lists the addresses only other
|
||||
views' records use, and fill names them from there (a VPS's AAAA in
|
||||
external, its PTR in internal). `jarl zone suggest-reverse --all
|
||||
[--view V]`. Both lists have a search, so hundreds of addresses stay
|
||||
manageable: the public addresses fold away, show 25 at a time, and
|
||||
**Dismiss these N** dismisses everything a search found in one change
|
||||
(`--dismiss` takes several networks).
|
||||
- Encrypted DNS: **several host names** (one certificate for all; the
|
||||
health check says when the served certificate misses one), and **each
|
||||
listen address gets its own views** for the host names' records
|
||||
(`--address 10.0.10.5=internal`, a view checkbox per address in the
|
||||
form), local to the view, with a warning when a public view would get
|
||||
a private address. A view no longer ticked loses the address. The form
|
||||
starts from where the records are now. The Certificates page and `jarl
|
||||
cert show` say what named uses a certificate for (DNS over TLS or HTTPS,
|
||||
on which addresses and ports).
|
||||
- docs/encrypted-dns.md: forwarding to Quad9 or Cloudflare over TLS, how to
|
||||
check it works, and why not over HTTPS.
|
||||
- Comparing one name across views: `jarl zone diff adbraun.lol nas [A]`,
|
||||
and a **Compare** button on each record.
|
||||
|
||||
- **Exploring the statistics**: jarld keeps every answer for a while
|
||||
(`stats.retention.queries`, default 7 days), and every client, name,
|
||||
view, record type, result and blocklist on the Statistics page leads to
|
||||
an explore page for it: answers over time, the top clients, names,
|
||||
views, types, results and blocklists among them (each a link that
|
||||
narrows it further), and the latest answers. "NXDOMAIN in external" →
|
||||
the clients asking → everything one of them asks. A filter bar types
|
||||
filters in, several values each (any of them), `!value` to leave one
|
||||
out and `*.example.com` for a domain and below; **hide** on a list entry
|
||||
leaves it out. **Explore the answers** opens the page from the
|
||||
Statistics page. `GET /stats/explore`.
|
||||
- **Storage** on the dashboard: the size of Jarl's databases (and how many
|
||||
answers the query log holds), and per disk Jarl uses a bar of how full it
|
||||
is and how much of it the databases take. The Statistics page shows the
|
||||
statistics database's size too, and its disk tiles get the same bar.
|
||||
|
||||
### Changed
|
||||
|
||||
- `jarl option set tls NAME …` (and `key`, `remote-servers`, `http`,
|
||||
`dnssec-policy`, `server` …) replaces only the statement with that name:
|
||||
setting `tls cloudflare` used to replace `tls quad9`. `jarl option unset
|
||||
tls cloudflare` removes one by its name.
|
||||
- **Compare views** and `jarl zone diff` are rebuilt around the view
|
||||
you're on (docs/views.md#comparing-views): a summary per view, then
|
||||
what is answered differently (a view's own records in linked zones,
|
||||
drift otherwise) with **Use this everywhere**, the names only some
|
||||
views have, grouped, with **Add to VIEW**, and how many are the same.
|
||||
One line per record: the CLI cuts long TXT records to the terminal
|
||||
(`--wide` shows them whole). `--with` picks the views to compare with.
|
||||
`GET /zones/{zone}/compare` returns these sections instead of `rows`.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Encrypted DNS: the client hints tested with kdig (Knot's, often not
|
||||
installed) and with curl's JSON API, which named's DoH doesn't speak;
|
||||
they use BIND's `dig +tls` / `dig +https` now.
|
||||
- Server options → Added by Jarl: long statements didn't wrap, and the
|
||||
browser console reported a Content Security Policy violation (an inline
|
||||
style the page's policy blocks).
|
||||
|
||||
### Upgrading from 0.5
|
||||
|
||||
- Tested: 0.5.2 packages with views, a linked zone, a reverse zone,
|
||||
blocking, a `tls` statement and statistics, upgraded in place in Debian
|
||||
13 (BIND 9.20.29): everything kept and answered as before, the new
|
||||
features worked on the upgraded data, and a 0.5.2 backup restored.
|
||||
- The database gets two new versions (12 and 13), applied when jarld
|
||||
starts; a 0.5 jarld can't open it afterwards, so take a backup first (`sudo
|
||||
jarld backup`). Nothing changes until you turn `auto_ptr` on for a
|
||||
zone.
|
||||
- The statistics database gets a new version too (the query log); 0.5
|
||||
can't open it afterwards either. It starts empty and fills from then on.
|
||||
|
||||
## [0.5.2] - 2026-10-12
|
||||
|
||||
### Added
|
||||
|
||||
- Mail reverse DNS check: the addresses of the public zones' MX hosts and
|
||||
SPF records are looked up as the internet sees them, every 6 hours: a
|
||||
PTR record whose name resolves back to the address, which Gmail and
|
||||
Outlook require. It says who runs the reverse zone (your provider) and
|
||||
what to set there, and notes when Jarl has its own reverse zone for the
|
||||
address, which only your clients see. The Exposure page (Check now),
|
||||
`jarl exposure mail` and Prometheus `jarl_mail_reverse_dns_ok`.
|
||||
- Added by Jarl: the Server options page, `jarl option generated` and
|
||||
`GET /options/generated` list what Jarl writes into named.conf by
|
||||
itself (keys.conf, the statistics channel and logging, blocking's
|
||||
response-policy, the view keys), why, and what controls each.
|
||||
- `stats.rate_limit_log` (default true): `false` stops Jarl adding named's
|
||||
`query-errors` category to its log channel when rate limiting is set.
|
||||
|
||||
### Changed
|
||||
|
||||
- Option values with a list of three or more elements (allow-query,
|
||||
forwarders, listen-on, ...) are shown one element per line.
|
||||
|
||||
## [0.5.1] - 2026-10-12
|
||||
|
||||
### Added
|
||||
|
||||
- Encrypted DNS health: jarld checks the DoT and DoH listeners every 10
|
||||
minutes as a client would (the certificate is for the host name,
|
||||
current and the one Jarl last wrote; a question gets an answer) and
|
||||
says what is wrong: nothing listening, a firewall, the wrong or an
|
||||
older certificate. On the Encrypted DNS page (Check now), the
|
||||
dashboard, `jarl encrypted-dns health` and Prometheus
|
||||
`jarl_encrypted_dns_up`.
|
||||
- Comparing a zone's views: the Compare views tab and `jarl zone diff`
|
||||
show the names and types that differ between the views that have a
|
||||
zone, linked or not, with "Use this version" to share one view's
|
||||
version. `GET /zones/{zone}/compare`.
|
||||
|
||||
### Changed
|
||||
|
||||
- `--local` (and `"local": true`) works on zones that aren't linked too:
|
||||
their records are local anyway. `--shared` there is still refused.
|
||||
- When `certificates.dir` changes, jarld points the `tls` statements
|
||||
encrypted DNS wrote at the new place when it starts; named kept the old
|
||||
copies before, which renewals no longer updated.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Sharing a view's version of a name that was shared already (Use this
|
||||
version on the shared side) didn't replace another view's override; it
|
||||
said there was nothing to do.
|
||||
|
||||
## [0.5.0] - 2026-10-12
|
||||
|
||||
### Upgrading from 0.4
|
||||
|
||||
- The database gets three new versions (internet exposure, certificates,
|
||||
linked zones), applied when jarld starts; a 0.4 jarld can't open it
|
||||
afterwards, so take a backup first (`sudo jarld backup`). Backups made
|
||||
by 0.4 restore into this version. Tested: 0.4.0 packages with views,
|
||||
zones, records, blocking and statistics, upgraded in place and
|
||||
restored from a 0.4.0 backup.
|
||||
|
||||
- Statistics per view start with this version: earlier figures are for
|
||||
all views together.
|
||||
|
||||
- New exposure warnings (docs/exposure.md) appear for views reachable from
|
||||
the internet, e.g. "no rate-limit" or "version visible" on an external
|
||||
view: fix them from the Exposure page, or acknowledge them. A change
|
||||
that newly opens a public view up (an open resolver, say) now needs
|
||||
`--accept-risk`, or the checkbox on the Changes page.
|
||||
|
||||
- Once you use address ranges, the database can't go back to 0.4: its
|
||||
parser doesn't know them. Remove the ranges first if you need to
|
||||
downgrade.
|
||||
|
||||
### Changed
|
||||
|
||||
- The web UI's top bar had grown too long, and the user name, Users and
|
||||
Log out ended up on a second line on the left. The server's
|
||||
configuration and security pages (Server options, ACLs, TSIG keys,
|
||||
Exposure, Certificates, Encrypted DNS, Audit log) are now in a
|
||||
**Server** menu, and Account, Users and Log out in a menu under your
|
||||
name. On narrow screens the search box and your name stay together on
|
||||
the right.
|
||||
|
||||
### Fixed
|
||||
|
||||
- "update PTR" when editing a record (`jarl record update ID --ptr`) did
|
||||
nothing unless the name or address changed too, and the web UI didn't
|
||||
even send it alone: a reverse zone created after the record never got
|
||||
its PTR. Alone, it now adds the PTR record if it is missing (or enables
|
||||
it). For records of linked zones, the reverse zones of every linked view
|
||||
count, not only the record's own view.
|
||||
|
||||
- Option values in the web UI looked different from the editor: a long
|
||||
line wrapped back to the left edge, losing its indentation, and the
|
||||
column changed width when Edit opened. Values now keep their layout
|
||||
(a long line scrolls), with the same width and padding as the editor.
|
||||
The "{ } around the list are optional" hint only shows for options that
|
||||
end in a list (not rate-limit, which only has one inside).
|
||||
- Query statistics skipped every query signed with a TSIG key (named
|
||||
writes "/key NAME" after the client's port), such as queries with a
|
||||
view's key: they are counted now.
|
||||
- A changeset with a staged record delete could not be applied after that
|
||||
record was deleted elsewhere (e.g. with the CLI), and it was hard to see
|
||||
which operation to remove: the error only said "record 832 does not
|
||||
exist". A staged delete of a record that is already gone now counts as
|
||||
done (with a warning), and the other operations that no longer apply
|
||||
(an edit or disable of a deleted record, a change to an object changed
|
||||
since) are named with their number and summary. The Changes page marks
|
||||
that operation and offers to remove it right in the error.
|
||||
|
||||
### Added
|
||||
|
||||
- Statistics per view ([docs/stats.md](docs/stats.md#views)): the
|
||||
Statistics page has a toggle per view, with All and None, remembered in
|
||||
the browser, to see some views only or several together (linked views,
|
||||
say). Answers, result codes, record types, zones, blocking, rate
|
||||
limiting and the top lists follow it; named's own counters stay for the
|
||||
whole server, and the page says so. `jarl stats --view`, API
|
||||
`?views=`. Per-view figures start with this version.
|
||||
- DNS over TLS and HTTPS ([docs/encrypted-dns.md](docs/encrypted-dns.md)):
|
||||
`jarl encrypted-dns setup --hostname NAME --address ADDR` (and the
|
||||
Encrypted DNS page) obtains a certificate for the host name if needed,
|
||||
checks that nothing else listens on port 853 and the DoH port (443 by
|
||||
default) at those addresses, offering a free port, and stages named's
|
||||
`tls` statement and listeners as one change, keeping port 53 where it
|
||||
was, plus the host name's records in your private views (local to the
|
||||
view in a linked zone). The page and `jarl encrypted-dns` say what to
|
||||
give Android, Linux, Firefox, Chrome and Windows. A `tls` statement whose
|
||||
files are missing is refused (`tls_file_missing`), and a certificate
|
||||
named uses can't be deleted.
|
||||
- Linked zones ([docs/views.md](docs/views.md#linked-zones)): zones of the
|
||||
same name in different views can be linked, and Jarl keeps their shared
|
||||
records in step: a change in one view reaches the others in the same
|
||||
changeset (diff, permissions, history and undo cover every view). Each
|
||||
view can still have records of its own (`--local`, "Only here"), which
|
||||
override the shared records of that name and type there, and keeps its
|
||||
own zone options. Linking existing zones changes no answer: what is the
|
||||
same everywhere becomes shared, the rest stays local, and a view's
|
||||
version can be shared later (`--shared`, "Share"). `jarl zone link` and
|
||||
`unlink`, `zone create --link-with VIEW`, `record add|set|update
|
||||
--local|--shared`, a LINK column in `record list`, "Keep them linked"
|
||||
when copying a zone in the web UI, `POST /zones/{zone}/link` and
|
||||
`/unlink`. ACME challenges are written once per link, and `--view all`
|
||||
changes a link once.
|
||||
- TLS certificates ([docs/certificates.md](docs/certificates.md)): Jarl
|
||||
obtains certificates from Let's Encrypt or any ACME CA (account set up
|
||||
in jarl.yaml under `acme.account`) and renews them itself, from the CA's
|
||||
renewal information (ARI) or two thirds into their lifetime, with
|
||||
back-off after failures. It answers the DNS-01 challenges in a zone it
|
||||
serves, or through Porkbun's DNS API while Porkbun serves the domain;
|
||||
`auto` decides from the domain's public name servers at every renewal,
|
||||
so moving the domain to your own name servers needs no change. A CNAME
|
||||
on `_acme-challenge` into a zone Jarl serves is followed, for domains
|
||||
hosted elsewhere. A certificate is used for jarld's HTTPS ("web") and/or
|
||||
written for named (`<bind.config_dir>/tls/NAME/`, "dns"; named rereads
|
||||
it on renewal), and certificates you renew yourself can be used too
|
||||
(reread when their files change). Certificates page and dashboard card
|
||||
in the web UI, `jarl cert` (check, create, issue, update, delete,
|
||||
account), `/api/v1/certificates`, Prometheus
|
||||
`jarl_certificate_expiry_timestamp_seconds`, `jarl_certificate_failing`.
|
||||
- jarld serves HTTPS and plain HTTP on the same ports once it has a
|
||||
certificate (`tls.cert_file`, or one with the web use, which takes
|
||||
effect without a restart): plain HTTP from the server itself and from
|
||||
trusted proxies is served as before, browsers elsewhere are redirected
|
||||
to HTTPS and API requests get `https_required`. Before, with
|
||||
`tls.cert_file`, plain HTTP got an error even from localhost.
|
||||
- Internet exposure ([docs/exposure.md](docs/exposure.md)): Jarl works out
|
||||
which views are reachable from the internet (match-clients ending in a
|
||||
catch-all; for a server without views, open allow-query and a
|
||||
non-private listen address), with a per-view override, and checks them:
|
||||
response rate limiting, open resolvers, ANY answers (minimal-any), cache
|
||||
snooping, open NOTIFY and zone transfers, BIND version and host name,
|
||||
management interfaces. Fixes are staged like any change, including
|
||||
response rate limiting from ISC's example in log-only mode, with your
|
||||
secondaries exempt, and a later "start enforcing". With statistics on,
|
||||
Jarl reads named's rate limiting log lines and shows what was (or would
|
||||
have been) limited: counts, client networks and names (Exposure page,
|
||||
`jarl stats breakdown rrl`, top lists `ratelimited`, Prometheus
|
||||
`jarl_ratelimit_logged_total`). A change that newly opens a public view
|
||||
up must be confirmed (checkbox on the Changes page, `--accept-risk`, API
|
||||
`accept_risk`; 409 `confirm_risk` otherwise). Findings can be
|
||||
acknowledged with a reason: they stay visible as info, with who, when
|
||||
and why. Exposure page and dashboard card in the web UI, `jarl
|
||||
exposure` (fix, ack, unack), `GET /exposure`.
|
||||
- `jarl changeset show`, `diff`, `apply`, `discard` and `remove` take the
|
||||
changeset from `-C` or `JARL_CHANGESET` when no ID is given
|
||||
(`jarl changeset apply`). A changeset from `JARL_CHANGESET` is now
|
||||
checked before it is used, for staging too: it must exist, be open and
|
||||
be yours, so a stale export (an applied changeset, or an ID that later
|
||||
turned into someone else's web UI draft) can't silently take your
|
||||
changes; the error says what to do. Commands say on stderr which
|
||||
changeset from `JARL_CHANGESET` they use, and `apply`/`discard` remind
|
||||
you to `unset JARL_CHANGESET`. `-C` and ID arguments are used as given.
|
||||
- Address ranges such as `10.0.10.101-10.0.10.199` or
|
||||
`2001:db8::100-2001:db8::1ff` (also `!` negated, and with spaces around
|
||||
the dash) wherever an address match list is used: ACLs, views'
|
||||
match-clients and match-destinations, options such as allow-query, and
|
||||
blocking's client groups ([docs/concepts.md](docs/concepts.md)). Jarl
|
||||
keeps the range as written and writes the fewest prefixes that cover
|
||||
exactly that range to named.conf, with the range as a comment. The web
|
||||
UI shows what a range becomes while you type it (`GET
|
||||
/catalog/ranges`). Mistakes are refused when entered: an end that isn't
|
||||
a full address, the start after the end, IPv4 mixed with IPv6, a range
|
||||
in an option that takes no address match list (forwarders). A range
|
||||
covered by an earlier element is reported as never matching, a range of
|
||||
every address counts as "any" for the open resolver and open transfer
|
||||
checks, and an IPv6 range in the IPv4-mapped space gets a warning. ACL
|
||||
names can no longer look like a range.
|
||||
- Prometheus metrics at `/metrics` ([docs/metrics.md](docs/metrics.md)),
|
||||
set up in the new `metrics:` section of jarl.yaml (off by default).
|
||||
`metrics.allow` lists the addresses that may scrape (loopback by
|
||||
default), `metrics.token` lets anyone who sends it as a bearer token
|
||||
scrape, and `metrics.listen` adds addresses that serve only `/metrics`
|
||||
(for a Prometheus server elsewhere). Root and jarld's own user can also
|
||||
scrape on the local socket.
|
||||
- Health, always: named up, recursive and TCP clients against their
|
||||
limits, adopted, open changesets, last apply, validation problems by
|
||||
severity, drifted files, object counts, the last backup (and failure,
|
||||
and whether it is stale), blocking paused, each blocklist's entries,
|
||||
last check and change and whether it fails, the DNSSEC phase of every
|
||||
signed zone, and when API tokens expire.
|
||||
- With query statistics on: named's counters (queries, result codes,
|
||||
record types, UDP/TCP, authoritative/recursive, rate limiting, upstream
|
||||
round-trip times, DNSSEC validation, cache hits) and Jarl's (logged
|
||||
responses per view and zone, blocked queries per blocklist), as
|
||||
counters since jarld started, plus named's memory, CPU, threads, cache
|
||||
and sockets, and the collector's health.
|
||||
- Example queries and alert rules, checked with promtool.
|
||||
|
||||
## [0.4.0] - 2026-10-11
|
||||
|
||||
### Upgrading from 0.3
|
||||
|
||||
- Back up first (`sudo jarld backup`).
|
||||
- When jarld first starts, it rewrites named's configuration: options
|
||||
move into their new fixed order, and every view gets its view key in
|
||||
`match-clients` and `keys.conf`. It is checked with named-checkconf
|
||||
before named reloads, like any apply. Files you changed by hand are left
|
||||
alone; the log then says to run `jarl resync`.
|
||||
- The systemd unit changed (it allows `AF_NETLINK`). The package replaces
|
||||
it; an override that adds `AF_NETLINK` (`systemctl edit jarl`) does no
|
||||
harm and can be removed.
|
||||
- The database moves to schema 8 (disabled zones, client groups) as
|
||||
jarld starts.
|
||||
- If statistics showed a spike after views were created or changed, see
|
||||
"Statistics" under Fixed: the bad minutes can be removed from
|
||||
`/var/lib/jarl/stats.db` (minute rows have `res = 'm'`; the hours and
|
||||
days that include them are `'h'` and `'d'`).
|
||||
|
||||
### Added
|
||||
|
||||
- Different blocking for different devices (docs/blocking.md), like
|
||||
Pi-hole's groups, in one view and without loading any list twice.
|
||||
Client groups put devices (addresses, networks or ACLs of them) at a
|
||||
blocking step: `off` (only your rules), `normal` (the default), or steps
|
||||
you add above it, each including the lists of the steps below. Lists
|
||||
start at a step. Jarl puts a small "gate" policy zone in front of each
|
||||
step's lists that lets the devices below that step through. Web UI: a
|
||||
Devices panel on the Blocking page and a step on each list. CLI: `jarl
|
||||
block step`, `jarl block group`, `jarl blocklist update --step`. API:
|
||||
`/blocking/steps`, `/blocking/groups`. Database schema 8.
|
||||
- View keys: every view gets a TSIG key, `jarl-view-NAME`, at the front of
|
||||
its match-clients, so a query signed with it lands in that view from any
|
||||
address. Jarl signs its delegation check's queries to this server's own
|
||||
addresses with the key of the zone's view: before, they got whichever
|
||||
view matched the server's own address (usually internal), so the check
|
||||
could report the wrong serial for the external view. `jarl view key
|
||||
VIEW` shows a key, for `dig -k` (docs/views.md). The first apply after
|
||||
upgrading adds the keys to named.conf and keys.conf.
|
||||
- Server statistics: named's memory (resident, and its own count) and
|
||||
CPU use, its cache size, sockets, and recursive and TCP clients against
|
||||
their limits; the host's memory and load; free space where zone files,
|
||||
logs and Jarl's data are. Memory and CPU are kept over time like the
|
||||
other statistics. Statistics page → Server, `jarl stats server`, API
|
||||
`/stats/server`. Read from named's statistics channel, `rndc status` and
|
||||
/proc, with no extra privileges.
|
||||
- CLI: the record commands that change records take several views,
|
||||
`--view internal,external`, or `--view all`, and make the change in each,
|
||||
as one changeset (all views or none).
|
||||
- Blocking can be paused until resumed, like Pi-hole's "indefinitely":
|
||||
`jarl block pause indefinitely`, **Until I resume** on the Blocking page,
|
||||
`{"indefinitely": true}` in the API.
|
||||
- Disabled zones: a zone can be disabled instead of deleted. named stops
|
||||
loading it (its zone statement is left out of named.conf), while Jarl
|
||||
keeps it with its records, options and history, still editable;
|
||||
enabling it serves it as it is then. Disabling says what clients get
|
||||
instead (recursion, or REFUSED). Signed zones can't be disabled. Web
|
||||
UI: Disable/Enable on the zone page, a badge in the zone list. CLI:
|
||||
`jarl zone disable|enable`. API: `PATCH /zones/{zone}` with
|
||||
`{"disabled": true}`. Database schema 7.
|
||||
- Blocking (docs/blocking.md): named answers "doesn't exist" for ads,
|
||||
trackers and malware, using BIND's response policy zones.
|
||||
- Blocklists in Adblock, hosts or plain-domain format, downloaded by jarld
|
||||
and checked daily (`blocking.refresh`). A list is downloaded only when
|
||||
the server says it changed, checked with named-checkzone, and reloaded
|
||||
only when its content differs; a failed update keeps the previous
|
||||
version and shows on the dashboard.
|
||||
- Your own block and allow rules, for a domain and the names below it or
|
||||
the exact name. Rules take precedence over every list, and the most
|
||||
specific rule wins.
|
||||
- Lists and rules apply to chosen views, or to every view that offers
|
||||
recursion. They are configuration: staged, reviewed and reverted like
|
||||
any change.
|
||||
- Checking a name says whether it is blocked and by which rule or list
|
||||
entry, with an Allow button in the web UI.
|
||||
- Web UI: a Blocking page, with well-known lists to pick and named's
|
||||
memory per list. CLI: `jarl blocklist` and `jarl block`. API:
|
||||
`/blocking/lists`, `/blocking/rules`, `/blocking/check`.
|
||||
- Pausing: `jarl block pause [DURATION]` or the Blocking page lets every
|
||||
name through for a while (at most a day); blocking resumes by itself.
|
||||
The lists stay loaded, so it takes effect at once.
|
||||
- Statistics: blocked queries over time (a line on the queries chart)
|
||||
and their share, blocked per list, the most blocked names and the
|
||||
clients with the most blocked queries (`jarl stats top blocked`,
|
||||
`top blocked-clients`). Blocked answers no longer count as names that
|
||||
don't exist.
|
||||
- The view choice in the web UI's forms is a compact dropdown, however
|
||||
many views there are.
|
||||
- A `blocking` permission; database schema 6.
|
||||
|
||||
### Changed
|
||||
|
||||
- Web UI: each view has its own page, like a zone: Settings (match lists,
|
||||
comment, colour), Options and Zones tabs (with **New zone in VIEW**),
|
||||
and Delete. The Views page lists them in order, with the arrows that
|
||||
reorder them; view links everywhere open the view's page.
|
||||
- Web UI: A and AAAA records have a **+ CNAME** button that fills in the
|
||||
add form with a CNAME pointing to the record's name, leaving only the
|
||||
alias's name to type. It shows when the row is hovered or focused
|
||||
(always on touch screens).
|
||||
- Blocking page: how many queries each blocklist blocked in the last 24
|
||||
hours, and your rules together, with statistics on (linking to the
|
||||
Statistics page).
|
||||
- Options are kept in one fixed order, in named.conf, the web UI and the
|
||||
CLI: grouped by topic (files, listening, access control, recursion and
|
||||
forwarding, DNSSEC, transfers and notify, then the rest), alphabetical
|
||||
within a group. The same option is now in the same place in every view
|
||||
and zone. The first apply after upgrading reorders named.conf (a diff
|
||||
that only moves lines). Top-level statements keep their order.
|
||||
- Options that end in a list (`allow-transfer`, `forwarders`,
|
||||
`listen-on`, ...) can be given without their braces, in the web UI, CLI
|
||||
and API: `10.0.10.53; key xfer` becomes `{ 10.0.10.53; key xfer; }`.
|
||||
Before, only some server options (those edited one element per line)
|
||||
added them.
|
||||
- The dashboard's configuration checks include named-checkconf's own
|
||||
warnings about the running configuration (such as `both "recursion no;"
|
||||
and "allow-recursion" active`), which before only showed while applying.
|
||||
The CLI now prints them after an apply too.
|
||||
- Statistics page: cards and tiles fill their rows instead of leaving one
|
||||
alone beside an empty space, and the queries and failed answers charts
|
||||
have the same margins, so their time axes line up.
|
||||
- Dashboard: options have their own tile; "0 TSIG keys · 22 options" read
|
||||
as if the options belonged to the keys.
|
||||
- Web UI times use the 24-hour clock.
|
||||
- Diffs of named.conf say which block each change is in, after the `@@`
|
||||
line numbers (`@@ -50,6 +49,8 @@ view "internal" {`), like git does
|
||||
with function names. Moving an option from the server's options into a
|
||||
view no longer looks like two unrelated edits.
|
||||
- Web UI options (server, top-level, view and zone): values with blocks,
|
||||
such as logging, are shown and edited laid out as in named.conf, over
|
||||
several lines with indentation. The editor grows with its content, Tab
|
||||
indents and Enter keeps the indentation (Esc, then Tab, moves on). With
|
||||
a draft open, the tables show the draft's values, so a staged edit stays
|
||||
visible after Save.
|
||||
- The pending-changes banner has an Apply now button: it applies your
|
||||
draft without going to the Changes page, and stays on the page you're
|
||||
on. If the changes are refused, a dialog says why and links to Changes.
|
||||
- Blocking page: blocklists and rules have an Edit button (name, URL,
|
||||
views, comment; a rule's action and scope). The list button that
|
||||
downloads now is called Refresh, like `jarl blocklist refresh`.
|
||||
- Records table: disabled records are hatched and have a "disabled" badge
|
||||
next to their name, instead of only a muted colour and a badge in the
|
||||
comment column.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Reverting a changeset added needless "restore" operations for
|
||||
blocklists, rules and views that hadn't changed: copying the state
|
||||
turned their empty view lists into missing ones.
|
||||
- Under the shipped systemd unit, jarld couldn't list the host's
|
||||
addresses (it needs a netlink socket, which `RestrictAddressFamilies`
|
||||
didn't allow), so the name server checks didn't recognize this server's
|
||||
own addresses. The Name servers tab then queried them like another
|
||||
server's, and the delegation check didn't sign those queries with the
|
||||
view's key: with views, both got the view matching the server's own
|
||||
address (usually internal) and reported a wrong serial. The unit now
|
||||
allows `AF_NETLINK`, and without it jarld reads the addresses from
|
||||
/proc.
|
||||
- Statistics: after a reconfig that added, removed or recreated views
|
||||
(creating the first view, for instance), a minute could count everything
|
||||
since named started: millions of queries in an hour. A per-view counter
|
||||
(cache, upstream times, validation) going down was taken for a restart
|
||||
of named. Now only a new boot time means a restart, and a counter that
|
||||
goes down counts 0 for that minute.
|
||||
- Blocking page, checking a name: when your own block rule is for that very
|
||||
name, it offers to remove the rule instead of an allow rule, which was
|
||||
refused as a second rule for the same name. It also names the rule that
|
||||
matched rather than the policy zone entry.
|
||||
- docs/validation.md lacked the `disabled_conflict` warning added in 0.3.0.
|
||||
|
||||
## [0.3.0] - 2026-10-09
|
||||
|
||||
### Added
|
||||
|
||||
- Disabled records: a record can be disabled instead of deleted. It stays
|
||||
in Jarl with its ID, TTL and comment but is left out of the zone (the
|
||||
zone file lists it as a comment), and enabling it puts it back exactly
|
||||
as it was. Web UI: a Disable/Enable button on each record, disabled
|
||||
records greyed out with a badge, a filter for them, and a count in the
|
||||
zone list. CLI: `jarl record disable|enable` (by ID or by name, type and
|
||||
data, `--ptr` for the PTR record, `--missing-ok`), `record list
|
||||
--disabled`, and `disable:`/`enable:` entries for `jarl apply -f`. API:
|
||||
`POST /records/{id}/disable|enable`, `POST
|
||||
/zones/{zone}/records/disable|enable`, a `disabled` field on records,
|
||||
`?disabled=` on record lists and `record.disable|enable` operations.
|
||||
Validation checks the zone as it is served: the last NS record can't be
|
||||
disabled, enabling a record is checked like adding it, and a disabled
|
||||
record that a CNAME now blocks gets a warning. Database schema 5.
|
||||
- DNSSEC for a zone whose parent zone is in Jarl: the DNSSEC tab gives the
|
||||
`jarl record add PARENT NAME DS ...` command to copy and an "Add to
|
||||
PARENT" button (staged like any change), links to the parent zone, and
|
||||
says when the parent has the DS record. `jarl zone dnssec status` prints
|
||||
the command too, and turning signing off gives the matching `jarl record
|
||||
delete`.
|
||||
|
||||
- CLI output for scripts: `-o id` prints just the IDs of what a command
|
||||
shows or lists (`jarl zone show adbraun.lol -o id`), `-o
|
||||
field=view,soa.serial` just those fields, tab separated. `-q` on `show`
|
||||
and `list` prints the IDs too. With a change (`jarl record add ... -o
|
||||
id`) they apply to the changed object.
|
||||
|
||||
- Statistics: client lists (busiest and refused clients, in the web UI,
|
||||
`jarl stats top clients|refused` and `jarl stats summary`) show the host
|
||||
name Jarl's records have for each address: the PTR record's target, or
|
||||
else the name of an A/AAAA record. Looked up in Jarl's own data, so no
|
||||
DNS queries; an address without a record shows as before.
|
||||
- Zones list: a padlock after each signed zone's name, linking to its
|
||||
DNSSEC tab (open and amber while signing is being turned off).
|
||||
|
||||
### Changed
|
||||
|
||||
- The web UI and API can listen on ports below 1024, such as 443:
|
||||
`jarl.service` now gives jarld the `CAP_NET_BIND_SERVICE` capability
|
||||
(and only that). With a unit that doesn't, jarld's error says what's
|
||||
missing. See docs/configuration.md, "HTTPS on port 443".
|
||||
- Records table: names are no longer greyed out when they repeat the row
|
||||
above (it made those records look inactive). Records of the same name
|
||||
are separated by a faint line instead, and a full line marks where the
|
||||
next name starts.
|
||||
|
||||
### Fixed
|
||||
|
||||
- `jarl record list`: one long value (a DKIM key) padded every row to its
|
||||
length, so on a terminal each row wrapped onto a second, blank-looking
|
||||
line. Long data is now shortened to fit the terminal; `--wide` shows it
|
||||
in full, as does output to a pipe or file.
|
||||
|
||||
## [0.2.0] - 2026-10-09
|
||||
|
||||
### Added
|
||||
|
||||
- DNSSEC signing per zone (docs/dnssec.md): `jarl zone dnssec
|
||||
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.
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
@AGENTS.md
|
||||
|
||||
## Claude Code specifics
|
||||
|
||||
- End commit messages with a `Co-Authored-By:` trailer naming the model,
|
||||
e.g. `Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>`.
|
||||
- Keep temporary scripts, extracted BIND trees, screenshots and test
|
||||
servers' work directories in the session scratchpad, not in the repo.
|
||||
@@ -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
|
||||
|
||||
@@ -97,6 +114,7 @@ install-files:
|
||||
|
||||
uninstall:
|
||||
rm -f $(DESTDIR)$(BINDIR)/jarld $(DESTDIR)$(BINDIR)/jarl $(DESTDIR)$(UNITDIR)/jarl.service \
|
||||
$(DESTDIR)$(UNITDIR)/jarld.service \
|
||||
$(DESTDIR)$(UNITDIR)/jarl-backup.service $(DESTDIR)$(UNITDIR)/jarl-backup.timer \
|
||||
$(DESTDIR)$(BASHCOMPDIR)/jarl $(DESTDIR)$(BASHCOMPDIR)/jarld \
|
||||
$(DESTDIR)$(SYSUSERSDIR)/jarl.conf $(DESTDIR)$(TMPFILESDIR)/jarl.conf
|
||||
|
||||
@@ -25,7 +25,8 @@ error: www.adbraun.lol has a CNAME, so it can't have any other records (found A)
|
||||
## Features
|
||||
|
||||
* **Everything in one place**: primary, secondary, forward, stub, mirror,
|
||||
hint, redirect and in-view zones, reverse zones from a CIDR, split DNS with
|
||||
hint, redirect and in-view zones, reverse zones from a CIDR (suggested,
|
||||
filled and kept in step from your A records), split DNS with
|
||||
views, ACLs, TSIG keys, and every BIND 9.20 option at server, view, zone
|
||||
and top level, with the ARM's description and grammar built in.
|
||||
* **Safe changes.** Every change is validated as a whole (CNAME rules,
|
||||
@@ -43,14 +44,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,9 +72,38 @@ 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.
|
||||
* **Internet exposure checks**: Jarl works out which views are reachable
|
||||
from the internet (a GUA-addressed internal network still counts as
|
||||
yours) and checks them: response rate limiting (set up in log-only mode
|
||||
first, with what it would limit shown before you enforce it), open
|
||||
resolvers, ANY amplification, cache snooping, version and host name
|
||||
leaks, open management interfaces. Fixes are staged for review, changes
|
||||
that open a public view up must be confirmed, and deliberate exceptions
|
||||
can be acknowledged with a reason.
|
||||
* **Prometheus metrics** (optional): health to alert on (named down,
|
||||
configuration problems, drift, failing blocklists, stale backups, DNSSEC
|
||||
steps waiting at the parent, expiring tokens) and query counters per
|
||||
result code, view, zone and blocklist, with example alerts. Set up in
|
||||
jarl.yaml, with an address allow list and/or a bearer token.
|
||||
* **Blocking** like Pi-hole, done by named itself: blocklists (Adblock,
|
||||
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.
|
||||
* **Certificates for the server itself**: Jarl obtains and renews TLS
|
||||
certificates from Let's Encrypt (or any ACME CA), answering the DNS-01
|
||||
challenge in its own zones or through Porkbun's API while Porkbun still
|
||||
serves the domain, decided at every renewal. Use them for the web UI
|
||||
(HTTPS without a restart) and for named's DNS over TLS and HTTPS (named
|
||||
rereads them on renewal), or bring your own.
|
||||
* **DNS over TLS and HTTPS** for your own devices in one step: the
|
||||
certificate, named's listeners on the addresses you choose (after
|
||||
checking the ports are free) and the host name in your private views.
|
||||
* **Linked zones for split DNS**: the same zone in several views, with the
|
||||
shared records kept in step by Jarl and a few records of each view's own
|
||||
(an internal address for a public name, say).
|
||||
* **Adopts an existing server**: imports named.conf and zone files
|
||||
(comments included), then switches named over with an automatic
|
||||
fallback.
|
||||
@@ -99,10 +141,17 @@ 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/encrypted-dns.md](docs/encrypted-dns.md) | DNS over TLS and HTTPS for your clients: setup, ports, client settings |
|
||||
| [docs/certificates.md](docs/certificates.md) | TLS certificates Jarl obtains and renews for its web UI and named's DNS over TLS/HTTPS |
|
||||
| [docs/stats.md](docs/stats.md) | Query statistics: enabling, what is collected, retention |
|
||||
| [docs/reverse.md](docs/reverse.md) | Reverse zones: suggested from your records, created in the right view and filled, PTR records kept in step |
|
||||
| [docs/exposure.md](docs/exposure.md) | Internet exposure: which views are public, hardening checks, response rate limiting |
|
||||
| [docs/metrics.md](docs/metrics.md) | Prometheus metrics: access, what is exported, example alerts |
|
||||
| [docs/blocking.md](docs/blocking.md) | Blocking ads, trackers and malware: blocklists, your own rules, views, memory |
|
||||
| [docs/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 +172,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 +184,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 +202,34 @@ 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.6.3 is the current release; 0.6.4 is in testing (0.6.4-rc.2):
|
||||
failed logins and refused tokens are logged, address first, for Guard
|
||||
and fail2ban, and `jarl cert get` can write a combined file for HAProxy
|
||||
(`--combined`) and apply settings without jarld (`--local`). 0.6.3 brings the lego docs up to
|
||||
lego 5 and explains lego with split DNS. 0.6.2 added a `certificates` role
|
||||
for tokens that get certificates for other hosts. 0.6.1 gets certificates onto your
|
||||
other hosts (`jarl cert get`: Jarl obtains and renews them, a host downloads them and runs a
|
||||
hook, a daily timer keeps them current), exports certificates, fixes
|
||||
PTR records and overrides in linked zones, and makes one-view tokens
|
||||
(such as ACME tokens for the external view) work with linked zones.
|
||||
Reverse zones list addresses in numeric order and no longer report
|
||||
another view's hosts as missing PTR records.
|
||||
0.6.0 makes reverse zones easy (suggested from your records,
|
||||
created in the right view and filled, PTR records kept in step), rebuilds
|
||||
comparing a zone's views, lets you explore the statistics down to each
|
||||
client and name (a query log), shows the databases' size, and gives
|
||||
encrypted DNS several host names and views per listen address. 0.5 added Prometheus metrics,
|
||||
address ranges in ACLs and other address match lists, safer changeset
|
||||
handling, internet exposure checks with response rate limiting, TLS
|
||||
certificates Jarl obtains and renews itself, linked zones for split DNS,
|
||||
DNS over TLS and HTTPS, and statistics per view. 0.4.0 added blocking (blocklists and
|
||||
your own rules, like Pi-hole, done by named, with different blocking for
|
||||
different devices), disabling zones, server statistics and view keys, on
|
||||
top of 0.3.0's disabled records, script-friendly CLI output and client
|
||||
names in the statistics, and 0.2.0's DNSSEC signing, Porkbun support,
|
||||
two-factor login and view colours. Jarl is feature-complete for one server's configuration.
|
||||
Managing several servers from one jarld and dynamic updates are not
|
||||
handled yet ([concepts.md](docs/concepts.md#what-jarl-deliberately-doesnt-do-yet)).
|
||||
|
||||
## License
|
||||
|
||||
|
||||
+1
-1
@@ -83,7 +83,7 @@ func acmeZones(c *client.Client, name, view string) ([]acme.Zone, error) {
|
||||
}
|
||||
refs := make([]acme.Zone, len(zones))
|
||||
for i, z := range zones {
|
||||
refs[i] = acme.Zone{Name: z.Name, View: z.View}
|
||||
refs[i] = acme.Zone{Name: z.Name, View: z.View, Link: z.LinkGroup}
|
||||
}
|
||||
out := acme.Zones(name, refs, view)
|
||||
if len(out) == 0 {
|
||||
|
||||
+82
-6
@@ -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 {
|
||||
@@ -356,6 +378,9 @@ func printUser(w io.Writer, u api.User) {
|
||||
if u.Comment != "" {
|
||||
fmt.Fprintf(w, "Comment:\t%s\n", u.Comment)
|
||||
}
|
||||
if u.CertLimit != nil {
|
||||
fmt.Fprintf(w, "Certificates:\tat most %d (jarl cert get)\n", *u.CertLimit)
|
||||
}
|
||||
fmt.Fprintf(w, "Last login:\t%s\n", fmtTime(u.LastLoginAt))
|
||||
}
|
||||
|
||||
@@ -367,7 +392,8 @@ ACCESS is read or write, and filters take comma separated glob patterns:
|
||||
records:write zones=adbraun.lol names=_acme-challenge.* types=TXT
|
||||
*:read
|
||||
|
||||
Roles are ready-made grant sets: readonly, dns-editor, operator, acme.`
|
||||
Roles are ready-made grant sets: readonly, dns-editor, operator, acme,
|
||||
certificates.`
|
||||
|
||||
func userCmd() *cobra.Command {
|
||||
cmd := &cobra.Command{Use: "user", Aliases: []string{"users"}, Short: "Manage users (admin)", Long: "Manage users (admin).\n\n" + grantHelp}
|
||||
@@ -384,9 +410,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))
|
||||
}
|
||||
})
|
||||
},
|
||||
@@ -413,6 +448,7 @@ func userCmd() *cobra.Command {
|
||||
}
|
||||
var admin, noPassword, disabled bool
|
||||
var role, comment, rename string
|
||||
var certLimit int
|
||||
var grants []string
|
||||
userBody := func(cmd *cobra.Command) (api.UserInput, error) {
|
||||
var in api.UserInput
|
||||
@@ -435,13 +471,17 @@ func userCmd() *cobra.Command {
|
||||
if f.Changed("rename") {
|
||||
in.Username = &rename
|
||||
}
|
||||
if f.Changed("cert-limit") {
|
||||
in.CertLimit = &certLimit
|
||||
}
|
||||
return in, nil
|
||||
}
|
||||
addUserFlags := func(c *cobra.Command) {
|
||||
c.Flags().BoolVar(&admin, "admin", false, "make the user an administrator (--admin=false to remove)")
|
||||
c.Flags().StringVar(&role, "role", "", "replace the grants with a role: readonly, dns-editor, operator or acme")
|
||||
c.Flags().StringVar(&role, "role", "", "replace the grants with a role: readonly, dns-editor, operator, acme or certificates")
|
||||
c.Flags().StringArrayVar(&grants, "grant", nil, `grant (repeatable), e.g. "records:write zones=example.com"`)
|
||||
c.Flags().StringVar(&comment, "comment", "", "comment")
|
||||
c.Flags().IntVar(&certLimit, "cert-limit", -1, "how many certificates the user may own through jarl cert get (-1: certificates.max_per_user in jarl.yaml)")
|
||||
}
|
||||
create := &cobra.Command{
|
||||
Use: "create NAME",
|
||||
@@ -548,7 +588,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
|
||||
}
|
||||
|
||||
@@ -683,7 +759,7 @@ func tokenCmd() *cobra.Command {
|
||||
},
|
||||
}
|
||||
create.Flags().StringVar(&user, "user", "", "create the token for this user (admin)")
|
||||
create.Flags().StringVar(&role, "role", "", "restrict the token to a role: readonly, dns-editor, operator or acme")
|
||||
create.Flags().StringVar(&role, "role", "", "restrict the token to a role: readonly, dns-editor, operator, acme or certificates")
|
||||
create.Flags().StringArrayVar(&grants, "grant", nil, "restrict the token to these grants (repeatable)")
|
||||
create.Flags().StringVar(&expires, "expires", "", "lifetime, e.g. 90d or 720h (default: never)")
|
||||
create.Flags().BoolVar(&save, "save", false, "store the new token in the CLI configuration file")
|
||||
|
||||
@@ -0,0 +1,508 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"jarl/internal/api"
|
||||
"jarl/internal/blocking"
|
||||
)
|
||||
|
||||
// ---- blocking (docs/blocking.md)
|
||||
|
||||
// viewsBody adds the --view flags to a request body: names, or "auto" for
|
||||
// every view that offers recursion.
|
||||
func viewsBody(body map[string]any, views []string) {
|
||||
if len(views) == 1 && strings.EqualFold(views[0], "auto") {
|
||||
body["views"] = []string{}
|
||||
} else if len(views) > 0 {
|
||||
body["views"] = views
|
||||
}
|
||||
}
|
||||
|
||||
func viewsText(views []string, auto bool) string {
|
||||
switch {
|
||||
case auto:
|
||||
return "auto (" + strings.Join(views, ", ") + ")"
|
||||
case len(views) == 0:
|
||||
return "-"
|
||||
}
|
||||
return strings.Join(views, ", ")
|
||||
}
|
||||
|
||||
func blocklistCmd() *cobra.Command {
|
||||
cmd := &cobra.Command{Use: "blocklist", Aliases: []string{"blocklists"}, Short: "Blocklists: domains to block, downloaded and kept up to date",
|
||||
Long: `Blocklists are downloaded by jarld and turned into response policy zones, so
|
||||
named answers "doesn't exist" for the domains on them (docs/blocking.md).
|
||||
Hosts, plain domain and Adblock (||domain^) lists work. Your own block and
|
||||
allow rules ('jarl block') take precedence over every list.`}
|
||||
var views []string
|
||||
var comment, step string
|
||||
|
||||
list := &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "List blocklists and their state",
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := newClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var lists []api.Blocklist
|
||||
if err := c.Get(ctx(), "/blocking/lists", nil, &lists); err != nil {
|
||||
return err
|
||||
}
|
||||
return output(lists, func(w io.Writer) {
|
||||
fmt.Fprintln(w, "NAME\tSTATE\tSTEP\tDOMAINS\tVIEWS\tUPDATED\tMEMORY\tURL")
|
||||
for _, b := range lists {
|
||||
domains, updated, mem := "-", "-", "-"
|
||||
if s := b.Status; s != nil && s.UpdatedAt != nil {
|
||||
domains, updated = fmt.Sprint(s.Entries), s.UpdatedAt.Local().Format("2006-01-02 15:04")
|
||||
}
|
||||
if b.MemoryMB > 0 {
|
||||
mem = fmt.Sprintf("~%d MB", b.MemoryMB)
|
||||
}
|
||||
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n", b.Name, b.State, b.Step, domains, viewsText(b.Views, b.AutoViews), updated, mem, b.URL)
|
||||
}
|
||||
for _, b := range lists {
|
||||
if b.Status != nil && b.Status.Error != "" {
|
||||
fmt.Fprintf(w, "\n%s: %s\n", b.Name, b.Status.Error)
|
||||
}
|
||||
}
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
add := &cobra.Command{
|
||||
Use: "add NAME URL",
|
||||
Short: "Add a blocklist (jarld downloads it right away)",
|
||||
Long: `Add a blocklist. NAME is a short label (letters, digits, hyphens). Without
|
||||
--view the list applies to every view that offers recursion.
|
||||
|
||||
jarl blocklist add hagezi-pro https://raw.githubusercontent.com/hagezi/dns-blocklists/main/adblock/pro.txt
|
||||
jarl blocklist add stevenblack https://raw.githubusercontent.com/StevenBlack/hosts/master/hosts --view internal`,
|
||||
Args: cobra.ExactArgs(2),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := newClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
body := map[string]any{"name": args[0], "url": args[1]}
|
||||
viewsBody(body, views)
|
||||
if step != "" {
|
||||
body["step"] = step
|
||||
}
|
||||
if comment != "" {
|
||||
body["comment"] = comment
|
||||
}
|
||||
resp, err := c.Mutate(ctx(), "POST", "/blocking/lists", nil, body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := report(resp); err != nil || flagQuiet || !resp.Applied {
|
||||
return err
|
||||
}
|
||||
fmt.Println("jarld downloads the list in the background; 'jarl blocklist list' shows when it's in use.")
|
||||
return nil
|
||||
},
|
||||
}
|
||||
add.Flags().StringArrayVar(&views, "view", nil, "a view it applies to (repeat for several; default: every view that offers recursion)")
|
||||
add.Flags().StringVar(&comment, "comment", "", "comment")
|
||||
add.Flags().StringVar(&step, "step", "", "the blocking step it starts at (default: normal; see 'jarl block step')")
|
||||
|
||||
var rename, newURL string
|
||||
update := &cobra.Command{
|
||||
Use: "update LIST",
|
||||
Short: "Change a blocklist's name, URL, views or comment",
|
||||
Example: ` jarl blocklist update hagezi --url https://example.org/new-list.txt
|
||||
jarl blocklist update hagezi --view internal --view guest
|
||||
jarl blocklist update hagezi --view auto # every view that offers recursion`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := newClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
body := map[string]any{}
|
||||
f := cmd.Flags()
|
||||
if f.Changed("name") {
|
||||
body["name"] = rename
|
||||
}
|
||||
if f.Changed("url") {
|
||||
body["url"] = newURL
|
||||
}
|
||||
if f.Changed("comment") {
|
||||
body["comment"] = comment
|
||||
}
|
||||
if f.Changed("step") {
|
||||
body["step"] = step
|
||||
}
|
||||
viewsBody(body, views)
|
||||
if len(body) == 0 {
|
||||
return fmt.Errorf("nothing to change (see --help)")
|
||||
}
|
||||
resp, err := c.Mutate(ctx(), "PATCH", "/blocking/lists/"+url.PathEscape(args[0]), nil, body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return report(resp)
|
||||
},
|
||||
}
|
||||
update.Flags().StringVar(&rename, "name", "", "new name")
|
||||
update.Flags().StringVar(&newURL, "url", "", "new URL")
|
||||
update.Flags().StringArrayVar(&views, "view", nil, `a view it applies to (repeat for several; "auto": every view that offers recursion)`)
|
||||
update.Flags().StringVar(&comment, "comment", "", "new comment")
|
||||
update.Flags().StringVar(&step, "step", "", "the blocking step it starts at (normal, or a step above it)")
|
||||
|
||||
toggle := func(disable bool) *cobra.Command {
|
||||
verb := map[bool]string{true: "disable", false: "enable"}[disable]
|
||||
return &cobra.Command{
|
||||
Use: verb + " LIST",
|
||||
Short: map[bool]string{true: "Stop using a blocklist (it is kept, and re-enabled without downloading)", false: "Use a disabled blocklist again"}[disable],
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := newClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
resp, err := c.Mutate(ctx(), "PATCH", "/blocking/lists/"+url.PathEscape(args[0]), nil, map[string]any{"disabled": disable})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return report(resp)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
var yes bool
|
||||
del := &cobra.Command{
|
||||
Use: "delete LIST",
|
||||
Short: "Delete a blocklist",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if !confirm("Delete blocklist "+args[0]+"?", yes) {
|
||||
return fmt.Errorf("canceled")
|
||||
}
|
||||
c, err := newClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
resp, err := c.Mutate(ctx(), "DELETE", "/blocking/lists/"+url.PathEscape(args[0]), nil, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return report(resp)
|
||||
},
|
||||
}
|
||||
del.Flags().BoolVarP(&yes, "yes", "y", false, "don't ask")
|
||||
|
||||
var force bool
|
||||
refresh := &cobra.Command{
|
||||
Use: "refresh [LIST...]",
|
||||
Short: "Download blocklists now (all enabled ones without LIST)",
|
||||
Long: `Download blocklists now instead of waiting for their next check
|
||||
(blocking.refresh in jarl.yaml, daily by default). A list is only replaced
|
||||
when its content changed; --force downloads it even when the server says
|
||||
it hasn't.`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := newClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
names := args
|
||||
if len(names) == 0 {
|
||||
var lists []api.Blocklist
|
||||
if err := c.Get(ctx(), "/blocking/lists", nil, &lists); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, b := range lists {
|
||||
if b.State != "disabled" && b.State != "idle" {
|
||||
names = append(names, b.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
if flagDryRun {
|
||||
fmt.Printf("would download: %s\n", strings.Join(names, ", "))
|
||||
return nil
|
||||
}
|
||||
failed := 0
|
||||
for _, n := range names {
|
||||
var b api.Blocklist
|
||||
start := time.Now()
|
||||
err := c.Do(ctx(), "POST", "/blocking/lists/"+url.PathEscape(n)+"/refresh", q("force", map[bool]string{true: "1"}[force]), nil, &b)
|
||||
switch {
|
||||
case err != nil:
|
||||
failed++
|
||||
fmt.Printf("%s: %v\n", n, err)
|
||||
case b.Status != nil && b.Status.Error != "":
|
||||
failed++
|
||||
fmt.Printf("%s: %s (the previous version stays in use)\n", n, b.Status.Error)
|
||||
case b.Status != nil:
|
||||
fmt.Printf("%s: %d domains (%d lines skipped), checked in %s\n", n, b.Status.Entries, b.Status.Skipped, time.Since(start).Round(100*time.Millisecond))
|
||||
}
|
||||
}
|
||||
if failed > 0 {
|
||||
return &checkFailed{fmt.Sprintf("%d of %d lists could not be updated", failed, len(names))}
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
refresh.Flags().BoolVar(&force, "force", false, "download even when the server says the list hasn't changed")
|
||||
|
||||
cmd.AddCommand(list, add, update, toggle(true), toggle(false), del, refresh)
|
||||
return cmd
|
||||
}
|
||||
|
||||
func blockCmd() *cobra.Command {
|
||||
cmd := &cobra.Command{Use: "block", Short: "Your own block and allow rules, and checking what is blocked",
|
||||
Long: `Rules block a domain, or allow it despite the blocklists ('jarl blocklist').
|
||||
A rule covers the domain and every name below it, unless --exact. Rules
|
||||
take precedence over every list, and the most specific rule wins: allow
|
||||
cdn.example.com overrides block example.com for cdn.example.com and the
|
||||
names below it (docs/blocking.md).`}
|
||||
var views []string
|
||||
var comment, view string
|
||||
var exact bool
|
||||
|
||||
addRule := func(action string) *cobra.Command {
|
||||
c := &cobra.Command{
|
||||
Use: map[string]string{"block": "add DOMAIN", "allow": "allow DOMAIN"}[action],
|
||||
Short: map[string]string{"block": "Block a domain (and the names below it)", "allow": "Allow a domain (and the names below it) that a blocklist blocks"}[action],
|
||||
Example: map[string]string{
|
||||
"block": " jarl block add doubleclick.net\n jarl block add telemetry.example.com --exact --view internal",
|
||||
"allow": " jarl block allow cdn.example.com\n jarl block allow login.example.com --exact",
|
||||
}[action],
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := newClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
body := map[string]any{"name": args[0], "action": action}
|
||||
if exact {
|
||||
body["exact"] = true
|
||||
}
|
||||
viewsBody(body, views)
|
||||
if comment != "" {
|
||||
body["comment"] = comment
|
||||
}
|
||||
resp, err := c.Mutate(ctx(), "POST", "/blocking/rules", nil, body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return report(resp)
|
||||
},
|
||||
}
|
||||
c.Flags().BoolVar(&exact, "exact", false, "only this name, not the names below it")
|
||||
c.Flags().StringArrayVar(&views, "view", nil, "a view it applies to (repeat for several; default: every view that offers recursion)")
|
||||
c.Flags().StringVar(&comment, "comment", "", "comment")
|
||||
return c
|
||||
}
|
||||
|
||||
list := &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "List your block and allow rules",
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := newClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var rules []api.BlockRule
|
||||
if err := c.Get(ctx(), "/blocking/rules", nil, &rules); err != nil {
|
||||
return err
|
||||
}
|
||||
note := pausedNote(c)
|
||||
return output(rules, func(w io.Writer) {
|
||||
if note != "" {
|
||||
fmt.Fprintln(w, note)
|
||||
}
|
||||
fmt.Fprintln(w, "ID\tACTION\tDOMAIN\tCOVERS\tVIEWS\tCOMMENT")
|
||||
for _, r := range rules {
|
||||
covers := "and below"
|
||||
if r.Exact {
|
||||
covers = "exact"
|
||||
}
|
||||
fmt.Fprintf(w, "%d\t%s\t%s\t%s\t%s\t%s\n", r.ID, r.Action, r.Name, covers, viewsText(r.Views, r.AutoViews), r.Comment)
|
||||
}
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
var id int64
|
||||
var missingOK bool
|
||||
del := &cobra.Command{
|
||||
Use: "delete DOMAIN | delete --id ID",
|
||||
Short: "Delete the rules for a domain (or one rule by ID)",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := newClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var resp *api.MutationResponse
|
||||
switch {
|
||||
case id > 0:
|
||||
resp, err = c.Mutate(ctx(), "DELETE", fmt.Sprintf("/blocking/rules/%d", id), nil, nil)
|
||||
case len(args) == 1:
|
||||
qv := q("name", args[0], "view", view)
|
||||
if missingOK {
|
||||
qv.Set("missing_ok", "1")
|
||||
}
|
||||
resp, err = c.Mutate(ctx(), "DELETE", "/blocking/rules", qv, nil)
|
||||
default:
|
||||
return fmt.Errorf("give DOMAIN, or --id")
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !resp.Applied && !resp.Staged && !resp.DryRun {
|
||||
fmt.Println("nothing to do")
|
||||
return nil
|
||||
}
|
||||
return report(resp)
|
||||
},
|
||||
}
|
||||
del.Flags().Int64Var(&id, "id", 0, "delete the rule with this ID")
|
||||
del.Flags().StringVar(&view, "view", "", "only the rules that apply in this view")
|
||||
del.Flags().BoolVar(&missingOK, "missing-ok", false, "succeed when there is no such rule")
|
||||
|
||||
check := &cobra.Command{
|
||||
Use: "check DOMAIN",
|
||||
Short: "Show whether a name is blocked, and by which rule or list",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := newClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var matches []blocking.Match
|
||||
if err := c.Get(ctx(), "/blocking/check", q("name", args[0]), &matches); err != nil {
|
||||
return err
|
||||
}
|
||||
note := pausedNote(c)
|
||||
return output(matches, func(w io.Writer) {
|
||||
if note != "" {
|
||||
fmt.Fprint(w, note+"While it is, every name is answered. Otherwise:\n")
|
||||
}
|
||||
if len(matches) == 0 {
|
||||
fmt.Fprintln(w, "Blocking isn't set up in any view.")
|
||||
return
|
||||
}
|
||||
for _, m := range matches {
|
||||
where := ""
|
||||
if m.View != "" {
|
||||
where = "view " + m.View + ":\t"
|
||||
}
|
||||
switch {
|
||||
case m.Result == "none":
|
||||
fmt.Fprintf(w, "%snot blocked\n", where)
|
||||
case m.List != "" && m.Step != "":
|
||||
fmt.Fprintf(w, "%sblocked for clients at step %s and above, by blocklist %s (entry %s); not for others\n", where, m.Step, m.List, m.Entry)
|
||||
case m.List != "":
|
||||
fmt.Fprintf(w, "%sblocked by blocklist %s (entry %s)\n", where, m.List, m.Entry)
|
||||
default:
|
||||
fmt.Fprintf(w, "%s%s by your rule %d (entry %s)\n", where, m.Result, m.RuleID, m.Entry)
|
||||
}
|
||||
}
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
pause := &cobra.Command{
|
||||
Use: "pause [DURATION | indefinitely]",
|
||||
Short: "Let every name through for a while (default 10m, at most 24h) or until resumed",
|
||||
Long: `Pause blocking: every name is answered normally until the time is up,
|
||||
then blocking resumes by itself. Useful to see whether blocking is what
|
||||
breaks a site. The lists stay loaded, so pausing and resuming take effect
|
||||
at once. "indefinitely" pauses until 'jarl block resume'.
|
||||
|
||||
jarl block pause # 10 minutes
|
||||
jarl block pause 1h
|
||||
jarl block pause indefinitely
|
||||
jarl block resume`,
|
||||
Args: cobra.MaximumNArgs(1),
|
||||
ValidArgs: []string{"indefinitely", "5m", "10m", "30m", "1h", "24h"},
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if len(args) == 1 && args[0] == "indefinitely" {
|
||||
c, err := newClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
resp, err := c.Mutate(ctx(), "POST", "/blocking/pause", nil, map[string]any{"indefinitely": true})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := report(resp); err != nil || flagQuiet || !resp.Applied {
|
||||
return err
|
||||
}
|
||||
fmt.Println("Blocking is paused until you resume it ('jarl block resume').")
|
||||
return nil
|
||||
}
|
||||
d := 10 * time.Minute
|
||||
if len(args) == 1 {
|
||||
var err error
|
||||
if d, err = time.ParseDuration(args[0]); err != nil || d < time.Minute {
|
||||
return fmt.Errorf("invalid duration %q (like 10m or 2h, or indefinitely)", args[0])
|
||||
}
|
||||
}
|
||||
c, err := newClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
resp, err := c.Mutate(ctx(), "POST", "/blocking/pause", nil, map[string]any{"minutes": int(d.Round(time.Minute) / time.Minute)})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := report(resp); err != nil || flagQuiet || !resp.Applied {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("Blocking is paused until %s.\n", time.Now().Add(d.Round(time.Minute)).Format("15:04"))
|
||||
return nil
|
||||
},
|
||||
}
|
||||
resume := &cobra.Command{
|
||||
Use: "resume",
|
||||
Short: "End a pause: block again now",
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := newClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
resp, err := c.Mutate(ctx(), "POST", "/blocking/resume", nil, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return report(resp)
|
||||
},
|
||||
}
|
||||
|
||||
cmd.AddCommand(addRule("block"), addRule("allow"), list, del, check, pause, resume, blockStepCmd(), blockGroupCmd())
|
||||
return cmd
|
||||
}
|
||||
|
||||
// pausedNote returns a line about a pause of blocking, or "".
|
||||
func pausedNote(c interface {
|
||||
Get(context.Context, string, url.Values, any) error
|
||||
}) string {
|
||||
var st struct {
|
||||
PausedUntil *time.Time `json:"paused_until"`
|
||||
PausedIndefinitely bool `json:"paused_indefinitely"`
|
||||
}
|
||||
if c.Get(ctx(), "/blocking/status", nil, &st) != nil {
|
||||
return ""
|
||||
}
|
||||
if st.PausedIndefinitely {
|
||||
return "Blocking is paused until you resume it ('jarl block resume').\n"
|
||||
}
|
||||
if st.PausedUntil == nil {
|
||||
return ""
|
||||
}
|
||||
return "Blocking is paused until " + st.PausedUntil.Local().Format("2006-01-02 15:04") + " ('jarl block resume' ends it).\n"
|
||||
}
|
||||
@@ -0,0 +1,972 @@
|
||||
package main
|
||||
|
||||
// jarl cert get: certificates for other hosts (docs/certificates.md#certificates-for-other-hosts).
|
||||
// jarld obtains and renews the certificate; this host asks for it, waits,
|
||||
// downloads it when it is newer than its copy, writes the combined file
|
||||
// (--combined) and runs a hook. The
|
||||
// settings of each certificate are saved, so a daily timer (jarl cert
|
||||
// schedule) can run 'jarl cert get' for all of them.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"slices"
|
||||
"strings"
|
||||
"text/tabwriter"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"jarl/internal/client"
|
||||
)
|
||||
|
||||
// savedCert is a certificate's settings, saved by jarl cert get.
|
||||
type savedCert struct {
|
||||
Name string `json:"name"`
|
||||
Domains []string `json:"domains"`
|
||||
KeyType string `json:"key_type,omitempty"`
|
||||
Dir string `json:"dir"`
|
||||
Hook string `json:"hook,omitempty"`
|
||||
// Combined is a file with the key and the full chain together, for
|
||||
// HAProxy (--combined); kept outside Dir.
|
||||
Combined string `json:"combined,omitempty"`
|
||||
Conn connection `json:"connection"`
|
||||
SavedAt time.Time `json:"saved_at"`
|
||||
}
|
||||
|
||||
// localMeta is what this host has of a certificate, kept next to its
|
||||
// files as jarl-cert.json.
|
||||
type localMeta struct {
|
||||
Name string `json:"name"`
|
||||
Domains []string `json:"domains"`
|
||||
Serial string `json:"serial"`
|
||||
NotAfter time.Time `json:"not_after"`
|
||||
Fetched time.Time `json:"fetched"`
|
||||
// Combined is the combined file written for these files, so a changed
|
||||
// --combined removes the old one.
|
||||
Combined string `json:"combined,omitempty"`
|
||||
// HookFailed is set when the hook failed for these files: the next
|
||||
// run runs it again.
|
||||
HookFailed string `json:"hook_failed,omitempty"`
|
||||
}
|
||||
|
||||
const metaFile = "jarl-cert.json"
|
||||
|
||||
// certConfigDir is where saved settings live: /etc/jarl/cert-get for
|
||||
// root, ~/.config/jarl/cert-get otherwise.
|
||||
func certConfigDir(flag string) string {
|
||||
if flag != "" {
|
||||
return flag
|
||||
}
|
||||
if os.Geteuid() == 0 {
|
||||
return "/etc/jarl/cert-get"
|
||||
}
|
||||
if d, err := os.UserConfigDir(); err == nil {
|
||||
return filepath.Join(d, "jarl", "cert-get")
|
||||
}
|
||||
return ".jarl-cert-get"
|
||||
}
|
||||
|
||||
// defaultCertDir is where a certificate's files go without --dir.
|
||||
func defaultCertDir(name string) string {
|
||||
if os.Geteuid() == 0 {
|
||||
return filepath.Join("/etc/jarl/certs", name)
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
func loadSaved(dir string) ([]savedCert, error) {
|
||||
entries, err := os.ReadDir(dir)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var out []savedCert
|
||||
for _, e := range entries {
|
||||
if e.IsDir() || !strings.HasSuffix(e.Name(), ".json") {
|
||||
continue
|
||||
}
|
||||
b, err := os.ReadFile(filepath.Join(dir, e.Name()))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var sc savedCert
|
||||
if err := json.Unmarshal(b, &sc); err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", filepath.Join(dir, e.Name()), err)
|
||||
}
|
||||
out = append(out, sc)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// save writes a certificate's settings, readable only by this user: they
|
||||
// may hold the API token.
|
||||
func (sc savedCert) save(dir string) error {
|
||||
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||
return err
|
||||
}
|
||||
b, _ := json.MarshalIndent(sc, "", " ")
|
||||
return writeAtomic(filepath.Join(dir, sc.Name+".json"), append(b, '\n'), 0o600)
|
||||
}
|
||||
|
||||
// checkCertClash refuses a certificate that would take over what this
|
||||
// host keeps for another one: a name saved for another Jarl server
|
||||
// (each server has its own names, so the server can't refuse it), or a
|
||||
// directory with another certificate's files. saved[i] is sc's saved
|
||||
// settings (i < 0: none).
|
||||
func checkCertClash(saved []savedCert, i int, sc savedCert, replace bool) error {
|
||||
if i >= 0 && !replace && !sameServer(saved[i].Conn.Server, sc.Conn.Server) {
|
||||
return fmt.Errorf("%s is saved here for the Jarl server %s, not %s: choose another name, or take it over with --replace", sc.Name, saved[i].Conn.Server, sc.Conn.Server)
|
||||
}
|
||||
for j, o := range saved {
|
||||
if j != i && o.Name != sc.Name && filepath.Clean(o.Dir) == filepath.Clean(sc.Dir) {
|
||||
return fmt.Errorf("%s holds the files of %s, saved for %s: give %s another --dir", sc.Dir, o.Name, o.Conn.Server, sc.Name)
|
||||
}
|
||||
}
|
||||
if err := checkCombined(saved, i, sc); err != nil {
|
||||
return err
|
||||
}
|
||||
if m, err := readMeta(sc.Dir); err == nil && m.Name != "" && m.Name != sc.Name && !replace {
|
||||
return fmt.Errorf("%s holds the files of the certificate %s: give %s another --dir, or overwrite them with --replace", sc.Dir, m.Name, sc.Name)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// checkCombined refuses a --combined path that is a directory, one of
|
||||
// the files jarl cert get writes in a certificate's directory, or another
|
||||
// saved certificate's combined file.
|
||||
func checkCombined(saved []savedCert, i int, sc savedCert) error {
|
||||
if sc.Combined == "" {
|
||||
return nil
|
||||
}
|
||||
if st, err := os.Stat(sc.Combined); err == nil && st.IsDir() {
|
||||
return fmt.Errorf("--combined %s is a directory: give the file's path, such as %s", sc.Combined, filepath.Join(sc.Combined, sc.Name+".pem"))
|
||||
}
|
||||
dir, base := filepath.Split(sc.Combined)
|
||||
for j, o := range append(slices.Clone(saved), sc) {
|
||||
if j == i {
|
||||
continue // sc itself is last
|
||||
}
|
||||
if filepath.Clean(dir) == filepath.Clean(o.Dir) && (base == metaFile || slices.Contains(certFiles, base)) {
|
||||
return fmt.Errorf("--combined %s would overwrite %s's %s: give another path", sc.Combined, o.Name, base)
|
||||
}
|
||||
if j < len(saved) && o.Name != sc.Name && o.Combined != "" && filepath.Clean(o.Combined) == filepath.Clean(sc.Combined) {
|
||||
return fmt.Errorf("--combined %s is the combined file of %s: give %s another path", sc.Combined, o.Name, sc.Name)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// sameNames reports whether two lists hold the same names, in any order.
|
||||
func sameNames(a, b []string) bool {
|
||||
norm := func(l []string) []string {
|
||||
out := make([]string, len(l))
|
||||
for i, n := range l {
|
||||
out[i] = strings.TrimSuffix(strings.ToLower(n), ".")
|
||||
}
|
||||
slices.Sort(out)
|
||||
return slices.Compact(out)
|
||||
}
|
||||
return slices.Equal(norm(a), norm(b))
|
||||
}
|
||||
|
||||
// sameServer reports whether two server addresses name the same jarld.
|
||||
func sameServer(a, b string) bool {
|
||||
norm := func(s string) string { return strings.TrimRight(strings.ToLower(s), "/") }
|
||||
return norm(a) == norm(b)
|
||||
}
|
||||
|
||||
func readMeta(dir string) (*localMeta, error) {
|
||||
b, err := os.ReadFile(filepath.Join(dir, metaFile))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var m localMeta
|
||||
return &m, json.Unmarshal(b, &m)
|
||||
}
|
||||
|
||||
func (m *localMeta) write(dir string) error {
|
||||
b, _ := json.MarshalIndent(m, "", " ")
|
||||
return writeAtomic(filepath.Join(dir, metaFile), append(b, '\n'), 0o644)
|
||||
}
|
||||
|
||||
// writeAtomic replaces path with data, so a program never reads half a
|
||||
// file.
|
||||
func writeAtomic(path string, data []byte, mode os.FileMode) error {
|
||||
tmp, err := os.CreateTemp(filepath.Dir(path), "."+filepath.Base(path)+".*")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer os.Remove(tmp.Name())
|
||||
if err := tmp.Chmod(mode); err != nil {
|
||||
tmp.Close()
|
||||
return err
|
||||
}
|
||||
if _, err := tmp.Write(data); err != nil {
|
||||
tmp.Close()
|
||||
return err
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(tmp.Name(), path)
|
||||
}
|
||||
|
||||
// requestAnswer is POST /certificates/{name}/request.
|
||||
type requestAnswer struct {
|
||||
Certificate certInfo `json:"certificate"`
|
||||
Created bool `json:"created"`
|
||||
Changed bool `json:"changed"`
|
||||
Warnings []string `json:"warnings"`
|
||||
}
|
||||
|
||||
// getResult is what one jarl cert get did.
|
||||
type getResult struct {
|
||||
Name string `json:"name"`
|
||||
Dir string `json:"dir"`
|
||||
Serial string `json:"serial"`
|
||||
NotAfter time.Time `json:"not_after"`
|
||||
Updated bool `json:"updated"` // new files written
|
||||
// Combined is the combined file; CombinedWritten is set when it was
|
||||
// written now.
|
||||
Combined string `json:"combined,omitempty"`
|
||||
CombinedWritten bool `json:"combined_written,omitempty"`
|
||||
Hook string `json:"hook,omitempty"`
|
||||
}
|
||||
|
||||
// fetchCert asks jarld for the certificate, waits while it is obtained,
|
||||
// downloads it when it is newer than this host's copy (or force), and
|
||||
// then applies it (applyCert).
|
||||
func fetchCert(c *client.Client, sc savedCert, force bool, wait time.Duration) (*getResult, error) {
|
||||
var ans requestAnswer
|
||||
err := c.Do(ctx(), "POST", certPath(sc.Name)+"/request", nil, map[string]any{"domains": sc.Domains, "key_type": sc.KeyType}, &ans)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, w := range ans.Warnings {
|
||||
fmt.Fprintf(os.Stderr, "%s: %s\n", sc.Name, w)
|
||||
}
|
||||
ci := ans.Certificate
|
||||
if ci.IssuingSince != nil {
|
||||
tty := !flagQuiet && flagOutput == "table"
|
||||
if tty {
|
||||
what := "Obtaining"
|
||||
if ans.Changed {
|
||||
what = "Obtaining (its names changed)"
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "%s %s from the CA on jarld", what, sc.Name)
|
||||
}
|
||||
deadline := time.Now().Add(wait)
|
||||
for ci.IssuingSince != nil && time.Now().Before(deadline) {
|
||||
time.Sleep(2 * time.Second)
|
||||
if tty {
|
||||
fmt.Fprint(os.Stderr, ".")
|
||||
}
|
||||
ci = certInfo{}
|
||||
if err := c.Get(ctx(), certPath(sc.Name), nil, &ci); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if tty {
|
||||
fmt.Fprintln(os.Stderr)
|
||||
}
|
||||
if ci.IssuingSince != nil {
|
||||
return nil, fmt.Errorf("%s: jarld is still obtaining it; run jarl cert get again later", sc.Name)
|
||||
}
|
||||
}
|
||||
if ci.NotAfter == nil {
|
||||
msg := firstNonEmpty(ci.LastError, ci.Status.Message, "jarld has no certificate for it yet")
|
||||
return nil, fmt.Errorf("%s: %s", sc.Name, msg)
|
||||
}
|
||||
if ci.LastError != "" && ci.LastAttempt != nil && (ci.LastSuccess == nil || ci.LastAttempt.After(*ci.LastSuccess)) {
|
||||
fmt.Fprintf(os.Stderr, "%s: jarld's last attempt failed (%s); this is the certificate it has\n", sc.Name, ci.LastError)
|
||||
}
|
||||
meta, _ := readMeta(sc.Dir)
|
||||
have := meta != nil && meta.Serial == ci.Serial && filesPresent(sc.Dir)
|
||||
renewed := meta != nil && meta.Serial != "" && meta.Serial != ci.Serial
|
||||
updated := !have || force
|
||||
if updated {
|
||||
if err := downloadCert(c, sc, ci); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
old := ""
|
||||
if meta != nil {
|
||||
old = meta.Combined
|
||||
}
|
||||
meta = &localMeta{Name: sc.Name, Domains: ci.Domains, Serial: ci.Serial, NotAfter: *ci.NotAfter, Fetched: time.Now().UTC(), Combined: old}
|
||||
}
|
||||
return applyCert(sc, meta, updated, renewed, force)
|
||||
}
|
||||
|
||||
// localCert applies the files this host has, without asking jarld
|
||||
// (--local): it writes the combined file and runs the hook.
|
||||
func localCert(sc savedCert) (*getResult, error) {
|
||||
meta, err := readMeta(sc.Dir)
|
||||
if err != nil || !filesPresent(sc.Dir) {
|
||||
return nil, fmt.Errorf("%s: this host has no files for it in %s yet: run jarl cert get %s without --local", sc.Name, sc.Dir, sc.Name)
|
||||
}
|
||||
if meta.Name != "" && meta.Name != sc.Name {
|
||||
return nil, fmt.Errorf("%s holds the files of the certificate %s, not %s", sc.Dir, meta.Name, sc.Name)
|
||||
}
|
||||
return applyCert(sc, meta, false, false, true)
|
||||
}
|
||||
|
||||
// applyCert brings what lies outside the certificate's directory in line
|
||||
// with its files: it writes the combined file when the files are new, its
|
||||
// path changed or it is missing (and removes one written for an older
|
||||
// path), then runs the hook when anything changed, when it failed last
|
||||
// time, or always.
|
||||
func applyCert(sc savedCert, meta *localMeta, updated, renewed, always bool) (*getResult, error) {
|
||||
res := &getResult{Name: sc.Name, Dir: sc.Dir, Serial: meta.Serial, NotAfter: meta.NotAfter, Updated: updated, Combined: sc.Combined}
|
||||
changed := updated
|
||||
if meta.Combined != "" && filepath.Clean(meta.Combined) != filepath.Clean(sc.Combined) {
|
||||
if err := os.Remove(meta.Combined); err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
return nil, fmt.Errorf("%s: removing the old combined file: %w", sc.Name, err)
|
||||
}
|
||||
meta.Combined = ""
|
||||
changed = true
|
||||
}
|
||||
if sc.Combined != "" {
|
||||
_, err := os.Stat(sc.Combined)
|
||||
if updated || always || meta.Combined == "" || err != nil {
|
||||
if err := writeCombined(sc); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
meta.Combined = sc.Combined
|
||||
res.CombinedWritten = true
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
if !changed && !always && meta.HookFailed == "" {
|
||||
return res, nil
|
||||
}
|
||||
if sc.Hook != "" {
|
||||
res.Hook = "ok"
|
||||
if err := runHook(sc, meta, renewed); err != nil {
|
||||
meta.HookFailed = err.Error()
|
||||
res.Hook = "failed: " + err.Error()
|
||||
} else {
|
||||
meta.HookFailed = ""
|
||||
}
|
||||
}
|
||||
if err := meta.write(sc.Dir); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if meta.HookFailed != "" {
|
||||
return res, fmt.Errorf("%s: the hook failed (%s); the next jarl cert get runs it again", sc.Name, meta.HookFailed)
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
var certFiles = []string{"cert.pem", "chain.pem", "fullchain.pem", "privkey.pem"}
|
||||
|
||||
// writeCombined writes the combined file from the certificate's files:
|
||||
// the key, then the full chain (as jarl cert export --part bundle), mode
|
||||
// 600, all at once.
|
||||
func writeCombined(sc savedCert) error {
|
||||
full, err := os.ReadFile(filepath.Join(sc.Dir, "fullchain.pem"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
key, err := os.ReadFile(filepath.Join(sc.Dir, "privkey.pem"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tls.X509KeyPair(full, key); err != nil {
|
||||
return fmt.Errorf("%s: the files in %s don't match (%v): run jarl cert get %s --force", sc.Name, sc.Dir, err, sc.Name)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(sc.Combined), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
data := append(append(append([]byte{}, key...), '\n'), full...)
|
||||
if err := writeAtomic(sc.Combined, data, 0o600); err != nil {
|
||||
return fmt.Errorf("%s: writing the combined file: %w", sc.Name, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func filesPresent(dir string) bool {
|
||||
for _, f := range certFiles {
|
||||
if _, err := os.Stat(filepath.Join(dir, f)); err != nil {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// downloadCert writes the certificate's files: cert.pem, chain.pem (the
|
||||
// intermediates), fullchain.pem and privkey.pem (mode 600).
|
||||
func downloadCert(c *client.Client, sc savedCert, ci certInfo) error {
|
||||
get := func(part string) ([]byte, error) {
|
||||
var out string
|
||||
err := c.Get(ctx(), certPath(sc.Name)+"/pem", url.Values{"part": {part}}, &out)
|
||||
return []byte(out), err
|
||||
}
|
||||
var full, key []byte
|
||||
// Twice at most: a renewal between the two downloads gives a key
|
||||
// that doesn't match.
|
||||
for try := 0; ; try++ {
|
||||
var err error
|
||||
if full, err = get("fullchain"); err != nil {
|
||||
return err
|
||||
}
|
||||
if key, err = get("key"); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err = tls.X509KeyPair(full, key); err == nil {
|
||||
break
|
||||
} else if try == 1 {
|
||||
return fmt.Errorf("%s: the key jarld sent doesn't match the certificate: %w", sc.Name, err)
|
||||
}
|
||||
}
|
||||
block, rest := pem.Decode(full)
|
||||
if block == nil {
|
||||
return fmt.Errorf("%s: jarld sent no certificate", sc.Name)
|
||||
}
|
||||
if err := os.MkdirAll(sc.Dir, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
files := []struct {
|
||||
name string
|
||||
data []byte
|
||||
mode os.FileMode
|
||||
}{
|
||||
{"cert.pem", pem.EncodeToMemory(block), 0o644},
|
||||
{"chain.pem", []byte(strings.TrimLeft(string(rest), "\n")), 0o644},
|
||||
{"fullchain.pem", full, 0o644},
|
||||
{"privkey.pem", key, 0o600},
|
||||
}
|
||||
// The key first: a program that reads both never pairs a new
|
||||
// certificate with the old key for long.
|
||||
slices.Reverse(files)
|
||||
for _, f := range files {
|
||||
if err := writeAtomic(filepath.Join(sc.Dir, f.name), f.data, f.mode); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// runHook runs the certificate's hook with sh, its files in the
|
||||
// environment.
|
||||
func runHook(sc savedCert, meta *localMeta, renewed bool) error {
|
||||
c, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
|
||||
defer cancel()
|
||||
cmd := exec.CommandContext(c, "sh", "-c", sc.Hook)
|
||||
ren := "0"
|
||||
if renewed {
|
||||
ren = "1"
|
||||
}
|
||||
cmd.Env = append(os.Environ(),
|
||||
"JARL_CERT_NAME="+sc.Name,
|
||||
"JARL_CERT_DIR="+sc.Dir,
|
||||
"JARL_CERT_CERT="+filepath.Join(sc.Dir, "cert.pem"),
|
||||
"JARL_CERT_CHAIN="+filepath.Join(sc.Dir, "chain.pem"),
|
||||
"JARL_CERT_FULLCHAIN="+filepath.Join(sc.Dir, "fullchain.pem"),
|
||||
"JARL_CERT_KEY="+filepath.Join(sc.Dir, "privkey.pem"),
|
||||
"JARL_CERT_COMBINED="+sc.Combined,
|
||||
"JARL_CERT_DOMAINS="+strings.Join(meta.Domains, " "),
|
||||
"JARL_CERT_SERIAL="+meta.Serial,
|
||||
"JARL_CERT_NOT_AFTER="+meta.NotAfter.UTC().Format(time.RFC3339),
|
||||
"JARL_CERT_RENEWED="+ren,
|
||||
)
|
||||
cmd.Stdout, cmd.Stderr = os.Stderr, os.Stderr
|
||||
return cmd.Run()
|
||||
}
|
||||
|
||||
func certGetCmd() *cobra.Command {
|
||||
var domains []string
|
||||
var dir, hook, keyType, combined, configDir string
|
||||
var noSave, force, replace, local bool
|
||||
var wait time.Duration
|
||||
cmd := &cobra.Command{
|
||||
Use: "get [NAME]",
|
||||
Short: "Get a certificate for this host: jarld obtains it, this host downloads it",
|
||||
Long: `Gets a certificate for this host (a mail server, a reverse proxy). jarld
|
||||
obtains it from the CA and renews it, answering the challenges in its own
|
||||
zones; this command asks for it, waits until it is there, downloads it
|
||||
when it is newer than this host's copy, writes --combined and runs --hook.
|
||||
|
||||
The token needs a certificates grant for the names, and the certificate
|
||||
belongs to the token's user: another user can't take the name. Asking
|
||||
again with the same names returns the certificate jarld has; adding or
|
||||
removing a name gets a new one.
|
||||
|
||||
Files in --dir (default /etc/jarl/certs/NAME as root, ./NAME otherwise):
|
||||
cert.pem, chain.pem (the intermediates), fullchain.pem, privkey.pem (mode
|
||||
600), and jarl-cert.json (serial, expiry, when it was fetched). Don't
|
||||
point HAProxy's crt at --dir: it loads every file there.
|
||||
|
||||
--combined PATH also writes the key and the full chain in one file (mode
|
||||
600), as HAProxy wants. Keep it outside --dir; a directory of combined
|
||||
files can be HAProxy's crt directory. --combined '' stops writing it and
|
||||
removes the file.
|
||||
|
||||
The settings (names, --dir, --combined, --hook, the server and token) are
|
||||
saved in
|
||||
/etc/jarl/cert-get/NAME.json (as root; ~/.config/jarl/cert-get otherwise,
|
||||
readable only by you). To change one later, run 'jarl cert get NAME' with
|
||||
just that option; the others stay. 'jarl cert get' without a name does every
|
||||
saved certificate: run it daily (jarl cert schedule does that with a
|
||||
systemd timer). Nothing is downloaded until jarld has renewed one.
|
||||
|
||||
A name saved here for another Jarl server isn't taken over, nor a --dir
|
||||
holding another certificate's files, unless --replace is given; a --dir
|
||||
another saved certificate uses is always refused.
|
||||
|
||||
--hook runs with sh after new files are written or the combined file
|
||||
changes (and again on the next run if it failed), with JARL_CERT_NAME,
|
||||
JARL_CERT_DIR, JARL_CERT_CERT, JARL_CERT_CHAIN, JARL_CERT_FULLCHAIN,
|
||||
JARL_CERT_KEY, JARL_CERT_COMBINED (empty without --combined),
|
||||
JARL_CERT_DOMAINS, JARL_CERT_SERIAL, JARL_CERT_NOT_AFTER and
|
||||
JARL_CERT_RENEWED (1 when it replaced an older certificate).
|
||||
|
||||
--local doesn't ask jarld: it uses the files this host has, writes the
|
||||
combined file and runs the hook. Use it after changing --combined or
|
||||
--hook, or to test a hook.`,
|
||||
Example: ` jarl cert get mail -d mail.adbraun.lol --hook 'systemctl reload postfix dovecot'
|
||||
jarl cert get proxy -d adbraun.lol -d '*.adbraun.lol' \
|
||||
--combined /etc/haproxy/certs/adbraun.lol.pem --hook 'systemctl reload haproxy'
|
||||
jarl cert get proxy --hook 'systemctl reload haproxy' --local # change a setting, apply it now
|
||||
jarl cert get # every saved certificate (what the timer runs)
|
||||
jarl cert saved # what this host has, and when it expires`,
|
||||
Args: cobra.MaximumNArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
cdir := certConfigDir(configDir)
|
||||
saved, err := loadSaved(cdir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var todo []savedCert
|
||||
f := cmd.Flags()
|
||||
if local && force {
|
||||
return errors.New("--local and --force don't go together: --local always writes the combined file and runs the hook; --force downloads again from jarld")
|
||||
}
|
||||
switch {
|
||||
case len(args) == 0:
|
||||
if len(domains) > 0 {
|
||||
return errors.New("give the certificate a name: jarl cert get NAME -d DOMAIN")
|
||||
}
|
||||
if len(saved) == 0 {
|
||||
return fmt.Errorf("no saved certificates in %s: get one first (jarl cert get NAME -d DOMAIN)", cdir)
|
||||
}
|
||||
for _, n := range []string{"dir", "hook", "key-type", "combined", "replace", "no-save"} {
|
||||
if f.Changed(n) {
|
||||
return fmt.Errorf("--%s changes one certificate: give its name (jarl cert get NAME --%s ...)", n, n)
|
||||
}
|
||||
}
|
||||
todo = saved
|
||||
default:
|
||||
name := args[0]
|
||||
i := slices.IndexFunc(saved, func(s savedCert) bool { return s.Name == name })
|
||||
var sc savedCert
|
||||
if i >= 0 {
|
||||
sc = saved[i]
|
||||
} else if len(domains) == 0 {
|
||||
return fmt.Errorf("%s isn't saved here: give its names (-d DOMAIN)", name)
|
||||
}
|
||||
sc.Name = name
|
||||
if len(domains) > 0 {
|
||||
if local && i >= 0 && !sameNames(domains, sc.Domains) {
|
||||
return fmt.Errorf("%s: new names need a new certificate from jarld: run jarl cert get %s without --local", name, name)
|
||||
}
|
||||
sc.Domains = domains
|
||||
}
|
||||
if f.Changed("dir") || sc.Dir == "" {
|
||||
sc.Dir = firstNonEmpty(dir, defaultCertDir(name))
|
||||
}
|
||||
if f.Changed("hook") {
|
||||
sc.Hook = hook
|
||||
}
|
||||
if f.Changed("key-type") {
|
||||
sc.KeyType = keyType
|
||||
}
|
||||
if f.Changed("combined") {
|
||||
sc.Combined = ""
|
||||
if combined != "" {
|
||||
abs, err := filepath.Abs(combined)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
sc.Combined = abs
|
||||
}
|
||||
}
|
||||
if abs, err := filepath.Abs(sc.Dir); err == nil {
|
||||
sc.Dir = abs
|
||||
}
|
||||
// The server and token given now (flags, environment, the
|
||||
// CLI configuration) replace the saved ones.
|
||||
if i < 0 || f.Changed("server") || cmd.Root().PersistentFlags().Changed("server") || cmd.Root().PersistentFlags().Changed("token") || os.Getenv("JARL_TOKEN") != "" || os.Getenv("JARL_SERVER") != "" {
|
||||
cn, err := resolveConnection()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
sc.Conn = cn
|
||||
}
|
||||
if err := checkCertClash(saved, i, sc, replace); err != nil {
|
||||
return err
|
||||
}
|
||||
todo = []savedCert{sc}
|
||||
}
|
||||
var results []*getResult
|
||||
var failed []string
|
||||
var lastErr error
|
||||
for _, sc := range todo {
|
||||
var r *getResult
|
||||
var err error
|
||||
if local {
|
||||
r, err = localCert(sc)
|
||||
} else {
|
||||
var c *client.Client
|
||||
if c, err = sc.Conn.open(); err == nil {
|
||||
r, err = fetchCert(c, sc, force, wait)
|
||||
}
|
||||
}
|
||||
if r != nil {
|
||||
results = append(results, r)
|
||||
}
|
||||
if err != nil {
|
||||
if len(todo) > 1 {
|
||||
fmt.Fprintln(os.Stderr, "error:", err)
|
||||
}
|
||||
failed = append(failed, sc.Name)
|
||||
lastErr = err
|
||||
continue
|
||||
}
|
||||
if len(args) == 1 && !noSave {
|
||||
sc.SavedAt = time.Now().UTC()
|
||||
if err := sc.save(cdir); err != nil {
|
||||
return fmt.Errorf("saving the settings: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := output(results, func(w io.Writer) {
|
||||
for _, r := range results {
|
||||
what := "up to date"
|
||||
switch {
|
||||
case r.Updated:
|
||||
what = "new files"
|
||||
case local:
|
||||
what = "this host's files"
|
||||
case r.CombinedWritten:
|
||||
what = "combined file written"
|
||||
}
|
||||
if r.Combined != "" {
|
||||
what += ", combined " + r.Combined
|
||||
}
|
||||
fmt.Fprintf(w, "%s\t%s\t%s, expires %s\t%s\n", r.Name, r.Dir, what, r.NotAfter.Local().Format("2006-01-02"), hookText(r.Hook))
|
||||
}
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
switch {
|
||||
case len(failed) == 1 && len(todo) == 1:
|
||||
return lastErr // its own exit code (3 forbidden, 5 conflict, ...)
|
||||
case len(failed) > 0:
|
||||
return fmt.Errorf("failed: %s", strings.Join(failed, ", "))
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
f := cmd.Flags()
|
||||
f.StringArrayVarP(&domains, "domain", "d", nil, "a name for the certificate (repeatable; a wildcard as '*.adbraun.lol')")
|
||||
f.StringVar(&dir, "dir", "", "directory for the files (default /etc/jarl/certs/NAME as root, ./NAME otherwise)")
|
||||
f.StringVar(&hook, "hook", "", "shell command to run after new files are written ('' removes it)")
|
||||
f.StringVar(&combined, "combined", "", "also write the key and full chain in one file at this path, for HAProxy ('' stops it)")
|
||||
f.BoolVar(&local, "local", false, "don't ask jarld: write the combined file and run the hook from this host's files")
|
||||
f.StringVar(&keyType, "key-type", "", "key type of a new certificate: ec256 (default), ec384, rsa2048, rsa3072, rsa4096")
|
||||
f.BoolVar(&force, "force", false, "download and run the hook even when this host has the current certificate")
|
||||
f.BoolVar(&noSave, "no-save", false, "don't save the settings for later runs")
|
||||
f.BoolVar(&replace, "replace", false, "take over a name saved here for another Jarl server, or a --dir holding another certificate's files")
|
||||
f.DurationVar(&wait, "wait", 15*time.Minute, "how long to wait while jarld obtains it")
|
||||
f.StringVar(&configDir, "config-dir", "", "where the settings are saved (default /etc/jarl/cert-get as root, ~/.config/jarl/cert-get otherwise)")
|
||||
_ = cmd.RegisterFlagCompletionFunc("key-type", cobra.FixedCompletions([]string{"ec256", "ec384", "rsa2048", "rsa3072", "rsa4096"}, cobra.ShellCompDirectiveNoFileComp))
|
||||
return cmd
|
||||
}
|
||||
|
||||
func hookText(h string) string {
|
||||
if h == "" {
|
||||
return ""
|
||||
}
|
||||
return "hook " + h
|
||||
}
|
||||
|
||||
func certSavedCmd() *cobra.Command {
|
||||
var configDir string
|
||||
cmd := &cobra.Command{
|
||||
Use: "saved [NAME]",
|
||||
Short: "The certificates this host gets with jarl cert get, and when they expire",
|
||||
Long: `Lists the certificates saved on this host by jarl cert get. With a name,
|
||||
shows all of that certificate's settings and what this host has of it.
|
||||
The token is never shown.`,
|
||||
Example: ` jarl cert saved
|
||||
jarl cert saved proxy
|
||||
jarl cert saved -o json`,
|
||||
Args: cobra.MaximumNArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
cdir := certConfigDir(configDir)
|
||||
saved, err := loadSaved(cdir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
type row struct {
|
||||
savedCert
|
||||
Local *localMeta `json:"local,omitempty"`
|
||||
}
|
||||
var rows []row
|
||||
for _, sc := range saved {
|
||||
if len(args) == 1 && sc.Name != args[0] {
|
||||
continue
|
||||
}
|
||||
sc.Conn.Token = "" // never print it
|
||||
m, _ := readMeta(sc.Dir)
|
||||
rows = append(rows, row{sc, m})
|
||||
}
|
||||
if len(args) == 1 {
|
||||
if len(rows) == 0 {
|
||||
return fmt.Errorf("%s isn't saved in %s (jarl cert saved lists what is)", args[0], cdir)
|
||||
}
|
||||
r := rows[0]
|
||||
return output(r, func(w io.Writer) { printSaved(w, r.savedCert, r.Local) })
|
||||
}
|
||||
return output(rows, func(w io.Writer) {
|
||||
if len(rows) == 0 {
|
||||
fmt.Fprintf(w, "No saved certificates in %s.\n", cdir)
|
||||
return
|
||||
}
|
||||
tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0)
|
||||
fmt.Fprintln(tw, "NAME\tNAMES\tDIR\tCOMBINED\tEXPIRES\tFETCHED\tSERVER\tHOOK")
|
||||
for _, r := range rows {
|
||||
exp, fetched := "-", "-"
|
||||
if r.Local != nil {
|
||||
exp = r.Local.NotAfter.Local().Format("2006-01-02") + fmt.Sprintf(" (%dd)", int(time.Until(r.Local.NotAfter).Hours()/24))
|
||||
fetched = r.Local.Fetched.Local().Format("2006-01-02 15:04")
|
||||
if r.Local.HookFailed != "" {
|
||||
fetched += " HOOK FAILED"
|
||||
}
|
||||
}
|
||||
fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n", r.Name, strings.Join(r.Domains, ","), r.Dir, dash(r.Combined), exp, fetched, r.Conn.Server, firstNonEmpty(r.Hook, "-"))
|
||||
}
|
||||
tw.Flush()
|
||||
})
|
||||
},
|
||||
}
|
||||
cmd.Flags().StringVar(&configDir, "config-dir", "", "where the settings are saved")
|
||||
return cmd
|
||||
}
|
||||
|
||||
// printSaved shows one saved certificate: its settings, then this
|
||||
// host's copy.
|
||||
func printSaved(w io.Writer, sc savedCert, m *localMeta) {
|
||||
fmt.Fprintf(w, "Name:\t%s\n", sc.Name)
|
||||
fmt.Fprintf(w, "Names:\t%s\n", strings.Join(sc.Domains, " "))
|
||||
fmt.Fprintf(w, "Key type:\t%s\n", firstNonEmpty(sc.KeyType, "ec256 (default)"))
|
||||
fmt.Fprintf(w, "Dir:\t%s\n", sc.Dir)
|
||||
fmt.Fprintf(w, "Combined:\t%s\n", dash(sc.Combined))
|
||||
fmt.Fprintf(w, "Hook:\t%s\n", dash(sc.Hook))
|
||||
fmt.Fprintf(w, "Server:\t%s\n", sc.Conn.Server)
|
||||
if sc.Conn.CACert != "" {
|
||||
fmt.Fprintf(w, "CA cert:\t%s\n", sc.Conn.CACert)
|
||||
}
|
||||
if sc.Conn.Insecure {
|
||||
fmt.Fprintf(w, "TLS:\tnot verified (--insecure)\n")
|
||||
}
|
||||
if !sc.SavedAt.IsZero() {
|
||||
fmt.Fprintf(w, "Saved:\t%s\n", fmtTime(&sc.SavedAt))
|
||||
}
|
||||
if m == nil {
|
||||
fmt.Fprintf(w, "Files:\tnone yet (jarl cert get %s fetches them)\n", sc.Name)
|
||||
return
|
||||
}
|
||||
fmt.Fprintf(w, "Serial:\t%s\n", m.Serial)
|
||||
fmt.Fprintf(w, "Expires:\t%s (%dd)\n", fmtTime(&m.NotAfter), int(time.Until(m.NotAfter).Hours()/24))
|
||||
fmt.Fprintf(w, "Fetched:\t%s\n", fmtTime(&m.Fetched))
|
||||
if m.HookFailed != "" {
|
||||
fmt.Fprintf(w, "Hook failed:\t%s (the next jarl cert get runs it again)\n", m.HookFailed)
|
||||
}
|
||||
}
|
||||
|
||||
func certForgetCmd() *cobra.Command {
|
||||
var configDir string
|
||||
var del, yes bool
|
||||
cmd := &cobra.Command{
|
||||
Use: "forget NAME",
|
||||
Short: "Stop getting a certificate on this host (its files stay)",
|
||||
Long: `Removes a certificate's saved settings, so 'jarl cert get' (and the timer)
|
||||
no longer fetches it. Its files stay where they are. With --delete, the
|
||||
certificate is deleted on jarld too, so it is no longer renewed.`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
cdir := certConfigDir(configDir)
|
||||
path := filepath.Join(cdir, args[0]+".json")
|
||||
saved, err := loadSaved(cdir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
i := slices.IndexFunc(saved, func(s savedCert) bool { return s.Name == args[0] })
|
||||
if i < 0 {
|
||||
return fmt.Errorf("%s isn't saved in %s", args[0], cdir)
|
||||
}
|
||||
if del {
|
||||
if !confirm("Delete certificate "+args[0]+" on jarld too?", yes) {
|
||||
return errors.New("aborted")
|
||||
}
|
||||
c, err := saved[i].Conn.open()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := c.Do(ctx(), "DELETE", certPath(args[0]), nil, nil, nil); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := os.Remove(path); err != nil {
|
||||
return err
|
||||
}
|
||||
if !flagQuiet {
|
||||
fmt.Printf("forgot %s (its files stay in %s)\n", args[0], saved[i].Dir)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cmd.Flags().StringVar(&configDir, "config-dir", "", "where the settings are saved")
|
||||
cmd.Flags().BoolVar(&del, "delete", false, "delete the certificate on jarld too")
|
||||
cmd.Flags().BoolVarP(&yes, "yes", "y", false, "don't ask")
|
||||
return cmd
|
||||
}
|
||||
|
||||
// Units written by jarl cert schedule.
|
||||
const (
|
||||
scheduleUnit = "jarl-cert-get"
|
||||
unitDir = "/etc/systemd/system"
|
||||
)
|
||||
|
||||
func scheduleUnits(bin, configDir string) (service, timer string) {
|
||||
service = fmt.Sprintf(`# Written by 'jarl cert schedule'; 'jarl cert schedule --remove' removes it.
|
||||
[Unit]
|
||||
Description=Fetch certificates from Jarl (jarl cert get)
|
||||
Wants=network-online.target
|
||||
After=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
ExecStart=%s cert get --config-dir %s
|
||||
`, bin, configDir)
|
||||
timer = `# Written by 'jarl cert schedule'; 'jarl cert schedule --remove' removes it.
|
||||
[Unit]
|
||||
Description=Fetch certificates from Jarl daily
|
||||
|
||||
[Timer]
|
||||
OnCalendar=daily
|
||||
RandomizedDelaySec=1h
|
||||
Persistent=true
|
||||
|
||||
[Install]
|
||||
WantedBy=timers.target
|
||||
`
|
||||
return service, timer
|
||||
}
|
||||
|
||||
func certScheduleCmd() *cobra.Command {
|
||||
var remove, noSystemctl bool
|
||||
var dir string
|
||||
cmd := &cobra.Command{
|
||||
Use: "schedule",
|
||||
Short: "Run jarl cert get daily with a systemd timer (Linux, as root)",
|
||||
Long: `Writes /etc/systemd/system/jarl-cert-get.service and .timer, which run
|
||||
'jarl cert get' once a day for every certificate saved in /etc/jarl/cert-get,
|
||||
and enables the timer. jarld renews certificates well before they expire;
|
||||
the daily run downloads a renewed one and runs its hook.
|
||||
|
||||
Linux with systemd only, as root. Elsewhere, run 'jarl cert get' daily
|
||||
yourself (cron, launchd, Task Scheduler).`,
|
||||
Example: ` sudo jarl cert schedule
|
||||
systemctl list-timers jarl-cert-get.timer
|
||||
sudo jarl cert schedule --remove`,
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if runtime.GOOS != "linux" {
|
||||
return fmt.Errorf("jarl cert schedule writes systemd units, so it only works on Linux; run 'jarl cert get' daily with this system's scheduler")
|
||||
}
|
||||
if dir == unitDir && os.Geteuid() != 0 {
|
||||
return errors.New("jarl cert schedule writes to /etc/systemd/system: run it as root (sudo)")
|
||||
}
|
||||
service := filepath.Join(dir, scheduleUnit+".service")
|
||||
timer := filepath.Join(dir, scheduleUnit+".timer")
|
||||
systemctl := func(a ...string) error {
|
||||
if noSystemctl {
|
||||
return nil
|
||||
}
|
||||
out, err := exec.Command("systemctl", a...).CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("systemctl %s: %v: %s", strings.Join(a, " "), err, strings.TrimSpace(string(out)))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if remove {
|
||||
_ = systemctl("disable", "--now", scheduleUnit+".timer")
|
||||
for _, f := range []string{timer, service} {
|
||||
if err := os.Remove(f); err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := systemctl("daemon-reload"); err != nil {
|
||||
return err
|
||||
}
|
||||
if !flagQuiet {
|
||||
fmt.Println("removed the timer; saved certificates stay in", certConfigDir(""))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
bin, err := os.Executable()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if b, err := filepath.EvalSymlinks(bin); err == nil {
|
||||
bin = b
|
||||
}
|
||||
cdir := certConfigDir("")
|
||||
saved, err := loadSaved(cdir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(saved) == 0 {
|
||||
fmt.Fprintf(os.Stderr, "note: no certificates are saved in %s yet; get one with: jarl cert get NAME -d DOMAIN\n", cdir)
|
||||
}
|
||||
svc, tmr := scheduleUnits(bin, cdir)
|
||||
if err := writeAtomic(service, []byte(svc), 0o644); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := writeAtomic(timer, []byte(tmr), 0o644); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := systemctl("daemon-reload"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := systemctl("enable", "--now", scheduleUnit+".timer"); err != nil {
|
||||
return err
|
||||
}
|
||||
if !flagQuiet {
|
||||
fmt.Printf("wrote %s and %s; the timer runs jarl cert get daily (systemctl list-timers %s.timer)\n", service, timer, scheduleUnit)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cmd.Flags().BoolVar(&remove, "remove", false, "disable and remove the timer")
|
||||
cmd.Flags().StringVar(&dir, "unit-dir", unitDir, "where to write the units")
|
||||
cmd.Flags().BoolVar(&noSystemctl, "no-systemctl", false, "only write the units")
|
||||
_ = cmd.Flags().MarkHidden("unit-dir")
|
||||
_ = cmd.Flags().MarkHidden("no-systemctl")
|
||||
return cmd
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/pem"
|
||||
"math/big"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestCheckCertClash(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
a := connection{Server: "https://dns-a.example:8053"}
|
||||
b := connection{Server: "https://dns-b.example:8053"}
|
||||
saved := []savedCert{
|
||||
{Name: "proxy", Dir: filepath.Join(tmp, "proxy"), Conn: a},
|
||||
{Name: "mail", Dir: filepath.Join(tmp, "mail"), Conn: a},
|
||||
}
|
||||
// mail's files are on disk; "other" has files but no saved settings.
|
||||
if err := (&localMeta{Name: "mail"}).write(mkdir(t, saved[1].Dir)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := (&localMeta{Name: "other"}).write(mkdir(t, filepath.Join(tmp, "other"))); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cases := []struct {
|
||||
what string
|
||||
i int
|
||||
sc savedCert
|
||||
replace bool
|
||||
want string // "" = allowed
|
||||
}{
|
||||
{"same server again", 0, savedCert{Name: "proxy", Dir: saved[0].Dir, Conn: a}, false, ""},
|
||||
{"same server, other spelling", 0, savedCert{Name: "proxy", Dir: saved[0].Dir, Conn: connection{Server: "HTTPS://dns-a.example:8053/"}}, false, ""},
|
||||
{"other server", 0, savedCert{Name: "proxy", Dir: saved[0].Dir, Conn: b}, false, "saved here for the Jarl server"},
|
||||
{"other server, replace", 0, savedCert{Name: "proxy", Dir: saved[0].Dir, Conn: b}, true, ""},
|
||||
{"new name, saved cert's dir", -1, savedCert{Name: "web", Dir: saved[1].Dir, Conn: b}, false, "holds the files of mail"},
|
||||
{"new name, saved cert's dir, replace", -1, savedCert{Name: "web", Dir: saved[1].Dir + "/", Conn: b}, true, "holds the files of mail"},
|
||||
{"new name, unsaved cert's files", -1, savedCert{Name: "web", Dir: filepath.Join(tmp, "other"), Conn: b}, false, "certificate other"},
|
||||
{"new name, unsaved cert's files, replace", -1, savedCert{Name: "web", Dir: filepath.Join(tmp, "other"), Conn: b}, true, ""},
|
||||
{"new name, empty dir", -1, savedCert{Name: "web", Dir: filepath.Join(tmp, "web"), Conn: b}, false, ""},
|
||||
}
|
||||
for _, c := range cases {
|
||||
err := checkCertClash(saved, c.i, c.sc, c.replace)
|
||||
switch {
|
||||
case c.want == "" && err != nil:
|
||||
t.Errorf("%s: %v", c.what, err)
|
||||
case c.want != "" && (err == nil || !strings.Contains(err.Error(), c.want)):
|
||||
t.Errorf("%s: got %v, want %q", c.what, err, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func mkdir(t *testing.T, dir string) string {
|
||||
t.Helper()
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return dir
|
||||
}
|
||||
|
||||
func TestCheckCombined(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
saved := []savedCert{
|
||||
{Name: "proxy", Dir: filepath.Join(tmp, "proxy"), Combined: filepath.Join(tmp, "haproxy", "proxy.pem")},
|
||||
{Name: "mail", Dir: filepath.Join(tmp, "mail")},
|
||||
}
|
||||
mkdir(t, filepath.Join(tmp, "haproxy"))
|
||||
cases := []struct {
|
||||
what string
|
||||
i int
|
||||
sc savedCert
|
||||
want string
|
||||
}{
|
||||
{"none", 1, savedCert{Name: "mail", Dir: saved[1].Dir}, ""},
|
||||
{"own again", 0, saved[0], ""},
|
||||
{"beside the others", 1, savedCert{Name: "mail", Dir: saved[1].Dir, Combined: filepath.Join(tmp, "haproxy", "mail.pem")}, ""},
|
||||
{"inside its own dir", 1, savedCert{Name: "mail", Dir: saved[1].Dir, Combined: filepath.Join(saved[1].Dir, "combined.pem")}, ""},
|
||||
{"another's combined", 1, savedCert{Name: "mail", Dir: saved[1].Dir, Combined: saved[0].Combined}, "combined file of proxy"},
|
||||
{"own fullchain", 1, savedCert{Name: "mail", Dir: saved[1].Dir, Combined: filepath.Join(saved[1].Dir, "fullchain.pem")}, "overwrite mail's fullchain.pem"},
|
||||
{"another's key", 1, savedCert{Name: "mail", Dir: saved[1].Dir, Combined: filepath.Join(saved[0].Dir, "privkey.pem")}, "overwrite proxy's privkey.pem"},
|
||||
{"a directory", 1, savedCert{Name: "mail", Dir: saved[1].Dir, Combined: filepath.Join(tmp, "haproxy")}, "is a directory"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
err := checkCombined(saved, c.i, c.sc)
|
||||
switch {
|
||||
case c.want == "" && err != nil:
|
||||
t.Errorf("%s: %v", c.what, err)
|
||||
case c.want != "" && (err == nil || !strings.Contains(err.Error(), c.want)):
|
||||
t.Errorf("%s: got %v, want %q", c.what, err, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestApplyCert: the combined file follows --combined, and the hook runs
|
||||
// only when something changed (always with --local).
|
||||
func TestApplyCert(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
dir := mkdir(t, filepath.Join(tmp, "proxy"))
|
||||
full, key := testKeyPair(t)
|
||||
for name, data := range map[string][]byte{"cert.pem": full, "chain.pem": nil, "fullchain.pem": full, "privkey.pem": key} {
|
||||
if err := os.WriteFile(filepath.Join(dir, name), data, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := (&localMeta{Name: "proxy", Serial: "1", NotAfter: time.Now().Add(time.Hour)}).write(dir); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
runs := filepath.Join(tmp, "runs")
|
||||
sc := savedCert{Name: "proxy", Dir: dir, Hook: `echo "$JARL_CERT_COMBINED" >> ` + runs}
|
||||
hookRuns := func() []string {
|
||||
b, _ := os.ReadFile(runs)
|
||||
return strings.Fields(string(b))
|
||||
}
|
||||
apply := func(local bool) *getResult {
|
||||
t.Helper()
|
||||
var r *getResult
|
||||
var err error
|
||||
if local {
|
||||
r, err = localCert(sc)
|
||||
} else {
|
||||
meta, _ := readMeta(dir)
|
||||
r, err = applyCert(sc, meta, false, false, false)
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
if r := apply(false); r.CombinedWritten || len(hookRuns()) != 0 {
|
||||
t.Fatalf("nothing changed, but: %+v, hook ran %d times", r, len(hookRuns()))
|
||||
}
|
||||
first := filepath.Join(tmp, "haproxy", "proxy.pem")
|
||||
sc.Combined = first
|
||||
if r := apply(false); !r.CombinedWritten || len(hookRuns()) != 1 {
|
||||
t.Fatalf("new --combined: %+v, hook ran %d times", r, len(hookRuns()))
|
||||
}
|
||||
b, err := os.ReadFile(first)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := tls.X509KeyPair(b, b); err != nil || !strings.HasPrefix(string(b), string(key)) {
|
||||
t.Errorf("combined file isn't the key, then the chain: %v", err)
|
||||
}
|
||||
if st, _ := os.Stat(first); st.Mode().Perm() != 0o600 {
|
||||
t.Errorf("combined file mode %v, want 600", st.Mode().Perm())
|
||||
}
|
||||
if r := apply(false); r.CombinedWritten || len(hookRuns()) != 1 {
|
||||
t.Fatalf("second run changed something: %+v, hook ran %d times", r, len(hookRuns()))
|
||||
}
|
||||
os.Remove(first)
|
||||
if r := apply(false); !r.CombinedWritten || len(hookRuns()) != 2 {
|
||||
t.Fatalf("missing combined file not rewritten: %+v", r)
|
||||
}
|
||||
if r := apply(true); !r.CombinedWritten || len(hookRuns()) != 3 {
|
||||
t.Fatalf("--local: %+v, hook ran %d times", r, len(hookRuns()))
|
||||
}
|
||||
second := filepath.Join(tmp, "haproxy", "adbraun.lol.pem")
|
||||
sc.Combined = second
|
||||
apply(false)
|
||||
if _, err := os.Stat(first); !os.IsNotExist(err) {
|
||||
t.Errorf("old combined file %s not removed: %v", first, err)
|
||||
}
|
||||
if _, err := os.Stat(second); err != nil {
|
||||
t.Errorf("new combined file: %v", err)
|
||||
}
|
||||
sc.Combined = ""
|
||||
apply(false)
|
||||
if _, err := os.Stat(second); !os.IsNotExist(err) {
|
||||
t.Errorf("--combined '' left %s: %v", second, err)
|
||||
}
|
||||
// Five runs; the last had JARL_CERT_COMBINED empty, so echo wrote no field.
|
||||
if got := hookRuns(); len(got) != 4 || got[3] != second {
|
||||
t.Errorf("hook runs: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func testKeyPair(t *testing.T) (certPEM, keyPEM []byte) {
|
||||
t.Helper()
|
||||
k, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tpl := &x509.Certificate{SerialNumber: big.NewInt(1), Subject: pkix.Name{CommonName: "proxy.example"}, NotBefore: time.Now(), NotAfter: time.Now().Add(time.Hour)}
|
||||
der, err := x509.CreateCertificate(rand.Reader, tpl, tpl, &k.PublicKey, k)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
kd, err := x509.MarshalECPrivateKey(k)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}), pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: kd})
|
||||
}
|
||||
@@ -0,0 +1,687 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"jarl/internal/client"
|
||||
)
|
||||
|
||||
type certStatus struct {
|
||||
State string `json:"state"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
type certInfo struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Source string `json:"source"`
|
||||
Domains []string `json:"domains"`
|
||||
CertFile string `json:"cert_file,omitempty"`
|
||||
KeyFile string `json:"key_file,omitempty"`
|
||||
Uses []string `json:"uses"`
|
||||
Challenge string `json:"challenge,omitempty"`
|
||||
KeyType string `json:"key_type,omitempty"`
|
||||
Comment string `json:"comment"`
|
||||
Names []string `json:"names,omitempty"`
|
||||
NotBefore *time.Time `json:"not_before,omitempty"`
|
||||
NotAfter *time.Time `json:"not_after,omitempty"`
|
||||
Issuer string `json:"issuer,omitempty"`
|
||||
Serial string `json:"serial,omitempty"`
|
||||
RenewAt *time.Time `json:"renew_at,omitempty"`
|
||||
LastAttempt *time.Time `json:"last_attempt,omitempty"`
|
||||
LastSuccess *time.Time `json:"last_success,omitempty"`
|
||||
LastError string `json:"last_error,omitempty"`
|
||||
Failures int `json:"failures,omitempty"`
|
||||
LastRoutes []string `json:"last_routes,omitempty"`
|
||||
Status certStatus `json:"status"`
|
||||
NextAttempt *time.Time `json:"next_attempt,omitempty"`
|
||||
IssuingSince *time.Time `json:"issuing_since,omitempty"`
|
||||
Serving bool `json:"serving,omitempty"`
|
||||
NamedUse []string `json:"named_use,omitempty"`
|
||||
Files *struct {
|
||||
Chain string `json:"chain"`
|
||||
Key string `json:"key"`
|
||||
} `json:"files,omitempty"`
|
||||
OwnerID int64 `json:"owner_id,omitempty"`
|
||||
Owner string `json:"owner,omitempty"`
|
||||
PulledBy []struct {
|
||||
Who string `json:"who"`
|
||||
Serial string `json:"serial"`
|
||||
IP string `json:"ip"`
|
||||
At time.Time `json:"at"`
|
||||
} `json:"pulled_by,omitempty"`
|
||||
}
|
||||
|
||||
type certAccount struct {
|
||||
Directory string `json:"directory"`
|
||||
Email string `json:"email"`
|
||||
AgreeTOS bool `json:"agree_tos"`
|
||||
EAB bool `json:"eab"`
|
||||
Registered bool `json:"registered"`
|
||||
URI string `json:"uri"`
|
||||
RegisteredAt *time.Time `json:"registered_at"`
|
||||
Porkbun bool `json:"porkbun"`
|
||||
Resolvers []string `json:"resolvers"`
|
||||
Dir string `json:"dir"`
|
||||
ActiveWebCertificate string `json:"active_web_certificate"`
|
||||
}
|
||||
|
||||
type certRoute struct {
|
||||
Domain string `json:"domain"`
|
||||
Record string `json:"record"`
|
||||
Target string `json:"target"`
|
||||
Route string `json:"route"`
|
||||
Zone string `json:"zone"`
|
||||
PublicNS []string `json:"public_ns"`
|
||||
Why string `json:"why"`
|
||||
Error string `json:"error"`
|
||||
}
|
||||
|
||||
func certPath(name string) string { return "/certificates/" + url.PathEscape(name) }
|
||||
|
||||
func certCmd() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "cert",
|
||||
Aliases: []string{"certificate", "certificates"},
|
||||
Short: "TLS certificates Jarl obtains and renews, or reads from files (docs/certificates.md)",
|
||||
Long: `Certificates for jarld's own HTTPS (use "web") and for named's DNS over
|
||||
TLS and HTTPS (use "dns": written where named can read them).
|
||||
|
||||
Jarl obtains them from an ACME CA (Let's Encrypt by default; the account is
|
||||
set up in jarl.yaml under acme.account) and renews them itself. It answers
|
||||
the DNS-01 challenges in a zone it serves, or through Porkbun's API while
|
||||
Porkbun serves the domain ("auto" decides at every renewal from the
|
||||
domain's public name servers). A certificate you renew yourself can be
|
||||
used too (--cert-file and --key-file): Jarl rereads it when it changes.`,
|
||||
Example: ` jarl cert check --domain ns1.adbraun.lol # where would the challenge go?
|
||||
jarl cert create ns1 --domain ns1.adbraun.lol --use web --use dns
|
||||
jarl cert create wild --domain adbraun.lol --domain '*.adbraun.lol' --use dns
|
||||
jarl cert create own --cert-file /etc/ssl/ns1/fullchain.pem --key-file /etc/ssl/ns1/privkey.pem --use web
|
||||
jarl cert list
|
||||
jarl cert issue ns1 # renew now
|
||||
jarl cert update ns1 --challenge porkbun # pin the route (auto, jarl or porkbun)
|
||||
jarl cert export ns1 --dir /etc/ssl/ns1 # fullchain.pem and privkey.pem, for another program`,
|
||||
}
|
||||
cmd.AddCommand(certListCmd(), certShowCmd(), certCreateCmd(), certUpdateCmd(), certIssueCmd(), certCheckCmd(), certExportCmd(), certGetCmd(), certSavedCmd(), certForgetCmd(), certScheduleCmd(), certDeleteCmd(), certAccountCmd())
|
||||
return cmd
|
||||
}
|
||||
|
||||
func certListCmd() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "list",
|
||||
Aliases: []string{"ls"},
|
||||
Short: "List certificates",
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := newClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var r struct {
|
||||
Certificates []certInfo `json:"certificates"`
|
||||
}
|
||||
if err := c.Get(ctx(), "/certificates", nil, &r); err != nil {
|
||||
return err
|
||||
}
|
||||
return output(r.Certificates, func(w io.Writer) {
|
||||
fmt.Fprintln(w, "NAME\tSOURCE\tUSES\tNAMES\tEXPIRES\tPULLED\tSTATUS")
|
||||
for _, ci := range r.Certificates {
|
||||
names := ci.Names
|
||||
if len(names) == 0 {
|
||||
names = ci.Domains
|
||||
}
|
||||
state := ci.Status.State
|
||||
if ci.IssuingSince != nil {
|
||||
state = "obtaining"
|
||||
}
|
||||
uses := strings.Join(ci.Uses, ",")
|
||||
if uses == "" && ci.OwnerID != 0 {
|
||||
uses = "get" // for another host (jarl cert get)
|
||||
}
|
||||
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\t%s\n", ci.Name, ci.Source, dash(uses), strings.Join(names, " "), fmtDate(ci.NotAfter), lastPull(ci), state)
|
||||
}
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// lastPull is when the certificate was last downloaded, marked "(old)"
|
||||
// while someone who downloaded it still has the one before its renewal.
|
||||
func lastPull(ci certInfo) string {
|
||||
if len(ci.PulledBy) == 0 {
|
||||
return "-"
|
||||
}
|
||||
s := fmtDate(&ci.PulledBy[0].At) // newest first
|
||||
for _, p := range ci.PulledBy {
|
||||
if p.Serial != ci.Serial {
|
||||
return s + " (old)"
|
||||
}
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func fmtDate(t *time.Time) string {
|
||||
if t == nil {
|
||||
return "-"
|
||||
}
|
||||
return t.Local().Format("2006-01-02")
|
||||
}
|
||||
|
||||
func printCert(w io.Writer, ci certInfo) {
|
||||
fmt.Fprintf(w, "Name:\t%s\n", ci.Name)
|
||||
switch ci.Source {
|
||||
case "acme":
|
||||
fmt.Fprintf(w, "Source:\tacme (Jarl obtains and renews it)\n")
|
||||
fmt.Fprintf(w, "Domains:\t%s\n", strings.Join(ci.Domains, " "))
|
||||
fmt.Fprintf(w, "Challenge:\t%s\n", ci.Challenge)
|
||||
fmt.Fprintf(w, "Key type:\t%s\n", ci.KeyType)
|
||||
default:
|
||||
fmt.Fprintf(w, "Source:\tfile (you renew it)\n")
|
||||
fmt.Fprintf(w, "Cert file:\t%s\n", ci.CertFile)
|
||||
fmt.Fprintf(w, "Key file:\t%s\n", ci.KeyFile)
|
||||
}
|
||||
uses := []string{}
|
||||
for _, u := range ci.Uses {
|
||||
switch u {
|
||||
case "web":
|
||||
s := "web (jarld's HTTPS"
|
||||
if ci.Serving {
|
||||
s += ", serving now"
|
||||
}
|
||||
uses = append(uses, s+")")
|
||||
case "dns":
|
||||
uses = append(uses, "dns (for named)")
|
||||
}
|
||||
}
|
||||
fmt.Fprintf(w, "Uses:\t%s\n", dash(strings.Join(uses, ", ")))
|
||||
for _, u := range ci.NamedUse {
|
||||
fmt.Fprintf(w, "named:\t%s\n", u)
|
||||
}
|
||||
if ci.Comment != "" {
|
||||
fmt.Fprintf(w, "Comment:\t%s\n", ci.Comment)
|
||||
}
|
||||
if ci.Owner != "" {
|
||||
fmt.Fprintf(w, "Owner:\t%s (jarl cert get)\n", ci.Owner)
|
||||
}
|
||||
for i, p := range ci.PulledBy {
|
||||
label := ""
|
||||
if i == 0 {
|
||||
label = "Pulled by:"
|
||||
}
|
||||
cur := "current"
|
||||
if p.Serial != ci.Serial {
|
||||
cur = "an older one: not fetched since the renewal"
|
||||
}
|
||||
fmt.Fprintf(w, "%s\t%s from %s, %s (%s)\n", label, p.Who, dash(p.IP), fmtTime(&p.At), cur)
|
||||
}
|
||||
state := ci.Status.State
|
||||
if ci.Status.Message != "" {
|
||||
state += ": " + ci.Status.Message
|
||||
}
|
||||
if ci.IssuingSince != nil {
|
||||
state = "obtaining since " + fmtTime(ci.IssuingSince)
|
||||
}
|
||||
fmt.Fprintf(w, "Status:\t%s\n", state)
|
||||
if ci.NotAfter != nil {
|
||||
fmt.Fprintf(w, "Names:\t%s\n", strings.Join(ci.Names, " "))
|
||||
fmt.Fprintf(w, "Valid:\t%s to %s\n", fmtTime(ci.NotBefore), fmtTime(ci.NotAfter))
|
||||
fmt.Fprintf(w, "Issuer:\t%s\n", ci.Issuer)
|
||||
fmt.Fprintf(w, "Serial:\t%s\n", ci.Serial)
|
||||
}
|
||||
if ci.Source == "acme" {
|
||||
if ci.NextAttempt != nil && ci.NotAfter != nil {
|
||||
fmt.Fprintf(w, "Renews:\t%s\n", fmtTime(ci.NextAttempt))
|
||||
} else if ci.NextAttempt != nil {
|
||||
fmt.Fprintf(w, "Next try:\t%s\n", fmtTime(ci.NextAttempt))
|
||||
}
|
||||
if len(ci.LastRoutes) > 0 {
|
||||
fmt.Fprintf(w, "Challenges:\t%s\n", strings.Join(ci.LastRoutes, "; "))
|
||||
}
|
||||
}
|
||||
if ci.LastSuccess != nil {
|
||||
fmt.Fprintf(w, "Last success:\t%s\n", fmtTime(ci.LastSuccess))
|
||||
}
|
||||
if ci.LastError != "" {
|
||||
fmt.Fprintf(w, "Last error:\t%s\n", ci.LastError)
|
||||
}
|
||||
if ci.Files != nil {
|
||||
fmt.Fprintf(w, "For named:\t%s\n\t%s\n", ci.Files.Chain, ci.Files.Key)
|
||||
}
|
||||
}
|
||||
|
||||
func certShowCmd() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "show NAME",
|
||||
Short: "Show a certificate",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := newClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var ci certInfo
|
||||
if err := c.Get(ctx(), certPath(args[0]), nil, &ci); err != nil {
|
||||
return err
|
||||
}
|
||||
return output(ci, func(w io.Writer) { printCert(w, ci) })
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
type certFlags struct {
|
||||
domains []string
|
||||
uses []string
|
||||
challenge, keyType string
|
||||
comment string
|
||||
certFile, keyFile string
|
||||
}
|
||||
|
||||
func (f *certFlags) add(cmd *cobra.Command) {
|
||||
fl := cmd.Flags()
|
||||
fl.StringArrayVarP(&f.domains, "domain", "d", nil, "a name on the certificate (repeat for several; *.example.com for a wildcard)")
|
||||
fl.StringArrayVarP(&f.uses, "use", "u", nil, "what it is for: web (jarld's HTTPS) or dns (named's DNS over TLS/HTTPS); repeat for both")
|
||||
fl.StringVar(&f.challenge, "challenge", "", "where DNS-01 challenges go: auto (from the domain's public name servers), jarl or porkbun")
|
||||
fl.StringVar(&f.keyType, "key-type", "", "key type: ec256 (default), ec384, rsa2048, rsa3072 or rsa4096")
|
||||
fl.StringVar(&f.comment, "comment", "", "a comment")
|
||||
fl.StringVar(&f.certFile, "cert-file", "", "a certificate you renew yourself: the full chain (PEM)")
|
||||
fl.StringVar(&f.keyFile, "key-file", "", "its private key (PEM)")
|
||||
}
|
||||
|
||||
func (f *certFlags) body(cmd *cobra.Command) map[string]any {
|
||||
b := map[string]any{}
|
||||
ch := cmd.Flags().Changed
|
||||
if ch("domain") {
|
||||
b["domains"] = f.domains
|
||||
}
|
||||
if ch("use") {
|
||||
uses := []string{}
|
||||
for _, u := range f.uses {
|
||||
for _, x := range strings.Split(u, ",") {
|
||||
if x = strings.TrimSpace(x); x != "" && x != "none" {
|
||||
uses = append(uses, x)
|
||||
}
|
||||
}
|
||||
}
|
||||
b["uses"] = uses
|
||||
}
|
||||
for flag, key := range map[string]string{"challenge": "challenge", "key-type": "key_type", "comment": "comment", "cert-file": "cert_file", "key-file": "key_file"} {
|
||||
if ch(flag) {
|
||||
v, _ := cmd.Flags().GetString(flag)
|
||||
b[key] = v
|
||||
}
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func certCreateCmd() *cobra.Command {
|
||||
var f certFlags
|
||||
var noIssue, noWait bool
|
||||
cmd := &cobra.Command{
|
||||
Use: "create NAME",
|
||||
Short: "Add a certificate: obtained by Jarl (--domain), or one you renew (--cert-file, --key-file)",
|
||||
Long: `Adds a certificate. With --domain, Jarl obtains it from the CA at once
|
||||
(and renews it from then on); the command waits for the outcome. With
|
||||
--cert-file and --key-file it is a certificate you renew yourself, which
|
||||
Jarl reads (and rereads when the files change).
|
||||
|
||||
The name is used for the files for named (<certificates.dir>/NAME/).`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := newClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
b := f.body(cmd)
|
||||
b["name"] = args[0]
|
||||
b["source"] = "acme"
|
||||
if f.certFile != "" || f.keyFile != "" {
|
||||
b["source"] = "file"
|
||||
}
|
||||
b["issue"] = !noIssue
|
||||
var r struct {
|
||||
Certificate certInfo `json:"certificate"`
|
||||
Error string `json:"error"`
|
||||
}
|
||||
if err := c.Do(ctx(), "POST", "/certificates", nil, b, &r); err != nil {
|
||||
return err
|
||||
}
|
||||
if r.Certificate.IssuingSince != nil && !noWait {
|
||||
return waitIssue(c, args[0])
|
||||
}
|
||||
if r.Error != "" {
|
||||
return errors.New(r.Error)
|
||||
}
|
||||
return output(r.Certificate, func(w io.Writer) { printCert(w, r.Certificate) })
|
||||
},
|
||||
}
|
||||
f.add(cmd)
|
||||
cmd.Flags().BoolVar(&noIssue, "no-issue", false, "don't obtain it now (jarld does within minutes)")
|
||||
cmd.Flags().BoolVar(&noWait, "no-wait", false, "return at once instead of waiting for the CA")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func certUpdateCmd() *cobra.Command {
|
||||
var f certFlags
|
||||
cmd := &cobra.Command{
|
||||
Use: "update NAME",
|
||||
Short: "Change a certificate (--domain and --use replace the whole list)",
|
||||
Long: `Changes a certificate. --domain and --use replace the whole list
|
||||
(--use none for no use). New names or a new key type make Jarl obtain it
|
||||
again soon (run 'jarl cert issue NAME' to do it now).`,
|
||||
Example: ` jarl cert update ns1 --use web --use dns
|
||||
jarl cert update ns1 --challenge auto
|
||||
jarl cert update ns1 --domain ns1.adbraun.lol --domain dns.adbraun.lol`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := newClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
b := f.body(cmd)
|
||||
if len(b) == 0 {
|
||||
return errors.New("nothing to change")
|
||||
}
|
||||
var r struct {
|
||||
Certificate certInfo `json:"certificate"`
|
||||
}
|
||||
if err := c.Do(ctx(), "PATCH", certPath(args[0]), nil, b, &r); err != nil {
|
||||
return err
|
||||
}
|
||||
return output(r.Certificate, func(w io.Writer) { printCert(w, r.Certificate) })
|
||||
},
|
||||
}
|
||||
f.add(cmd)
|
||||
return cmd
|
||||
}
|
||||
|
||||
func certIssueCmd() *cobra.Command {
|
||||
var noWait bool
|
||||
cmd := &cobra.Command{
|
||||
Use: "issue NAME",
|
||||
Aliases: []string{"renew"},
|
||||
Short: "Obtain the certificate now (reread the files of one you renew yourself)",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := newClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var r struct {
|
||||
Certificate certInfo `json:"certificate"`
|
||||
Error string `json:"error"`
|
||||
}
|
||||
if err := c.Do(ctx(), "POST", certPath(args[0])+"/issue", nil, map[string]any{}, &r); err != nil {
|
||||
return err
|
||||
}
|
||||
if r.Error != "" {
|
||||
return errors.New(r.Error)
|
||||
}
|
||||
if r.Certificate.IssuingSince != nil && !noWait {
|
||||
return waitIssue(c, args[0])
|
||||
}
|
||||
return output(r.Certificate, func(w io.Writer) { printCert(w, r.Certificate) })
|
||||
},
|
||||
}
|
||||
cmd.Flags().BoolVar(&noWait, "no-wait", false, "return at once instead of waiting for the CA")
|
||||
return cmd
|
||||
}
|
||||
|
||||
// waitIssue polls until jarld has finished obtaining the certificate and
|
||||
// reports the outcome.
|
||||
func waitIssue(c *client.Client, name string) error {
|
||||
tty := !flagQuiet && flagOutput == "table"
|
||||
if tty {
|
||||
fmt.Fprintf(os.Stderr, "Obtaining %s from the CA (this can take a few minutes while the challenges reach every name server)", name)
|
||||
}
|
||||
deadline := time.Now().Add(20 * time.Minute)
|
||||
var ci certInfo
|
||||
for {
|
||||
ci = certInfo{} // a field the answer leaves out must not keep the last value
|
||||
if err := c.Get(ctx(), certPath(name), nil, &ci); err != nil {
|
||||
return err
|
||||
}
|
||||
if ci.IssuingSince == nil || time.Now().After(deadline) {
|
||||
break
|
||||
}
|
||||
if tty {
|
||||
fmt.Fprint(os.Stderr, ".")
|
||||
}
|
||||
time.Sleep(2 * time.Second)
|
||||
}
|
||||
if tty {
|
||||
fmt.Fprintln(os.Stderr)
|
||||
}
|
||||
if ci.IssuingSince != nil {
|
||||
return fmt.Errorf("jarld is still obtaining %s; check later with: jarl cert show %s", name, name)
|
||||
}
|
||||
if err := output(ci, func(w io.Writer) { printCert(w, ci) }); err != nil {
|
||||
return err
|
||||
}
|
||||
if ci.LastError != "" && (ci.LastSuccess == nil || ci.LastAttempt != nil && ci.LastAttempt.After(*ci.LastSuccess)) {
|
||||
return fmt.Errorf("obtaining %s failed (jarld tries again at %s)", name, fmtTime(ci.NextAttempt))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func certCheckCmd() *cobra.Command {
|
||||
var f certFlags
|
||||
cmd := &cobra.Command{
|
||||
Use: "check [NAME]",
|
||||
Short: "Show where each name's DNS-01 challenge would go, without asking the CA",
|
||||
Long: `For an existing certificate, or for --domain names, shows where Jarl would
|
||||
publish each DNS-01 challenge (in a zone it serves, or at Porkbun) and why,
|
||||
seen from the resolvers in acme.resolvers, as the CA would see it. Nothing
|
||||
is published and the CA isn't asked.`,
|
||||
Example: ` jarl cert check ns1
|
||||
jarl cert check --domain ns1.adbraun.lol --domain '*.adbraun.lol'`,
|
||||
Args: cobra.MaximumNArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := newClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var r struct {
|
||||
OK bool `json:"ok"`
|
||||
Routes []certRoute `json:"routes"`
|
||||
Account certAccount `json:"account"`
|
||||
}
|
||||
if len(args) == 1 {
|
||||
err = c.Do(ctx(), "POST", certPath(args[0])+"/check", nil, map[string]any{}, &r)
|
||||
} else {
|
||||
b := f.body(cmd)
|
||||
if b["domains"] == nil {
|
||||
return errors.New("give a certificate name, or --domain")
|
||||
}
|
||||
err = c.Do(ctx(), "POST", "/certificates/check", nil, b, &r)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := output(r, func(w io.Writer) {
|
||||
fmt.Fprintln(w, "NAME\tROUTE\tRECORD\tWHY")
|
||||
for _, rt := range r.Routes {
|
||||
rec := strings.TrimSuffix(rt.Target, ".")
|
||||
if rt.Target != rt.Record && rt.Target != "" {
|
||||
rec += " (CNAME from " + strings.TrimSuffix(rt.Record, ".") + ")"
|
||||
}
|
||||
if rt.Error != "" {
|
||||
fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", rt.Domain, "none", dash(rec), rt.Error)
|
||||
continue
|
||||
}
|
||||
fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", rt.Domain, rt.Route, rec, rt.Why)
|
||||
}
|
||||
if !r.Account.AgreeTOS {
|
||||
fmt.Fprintln(w, "\nNote: acme.account.agree_tos isn't set in jarl.yaml, so Jarl can't register with the CA yet.")
|
||||
}
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
if !r.OK {
|
||||
return errors.New("not every name can be validated")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cmd.Flags().StringArrayVarP(&f.domains, "domain", "d", nil, "a name to check (repeat for several)")
|
||||
cmd.Flags().StringVar(&f.challenge, "challenge", "", "the challenge setting to check with: auto, jarl or porkbun")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func certExportCmd() *cobra.Command {
|
||||
var dir, part string
|
||||
var noKey, force bool
|
||||
cmd := &cobra.Command{
|
||||
Use: "export NAME",
|
||||
Short: "Save a certificate (and its key) to use in another program",
|
||||
Long: `Saves a certificate's current files, to use it outside Jarl (a mail
|
||||
server, a reverse proxy): fullchain.pem and privkey.pem in --dir (the
|
||||
key readable only by you), or one part on standard output with --part:
|
||||
|
||||
fullchain the certificate and its chain
|
||||
cert the certificate alone
|
||||
key the private key
|
||||
bundle the key, then the chain (one file, as HAProxy wants)
|
||||
|
||||
Administrators only; every export of the key is audited. Jarl renews the
|
||||
certificate, but not your copy: export it again after a renewal (the
|
||||
'renews' date in 'jarl cert show'), or point the program at the files Jarl
|
||||
writes for named (use dns) instead.`,
|
||||
Example: ` jarl cert export ns1 --dir /etc/ssl/ns1
|
||||
jarl cert export ns1 --part bundle > /etc/haproxy/ns1.pem
|
||||
jarl cert export ns1 --no-key --dir .`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := newClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
get := func(p string) (string, error) {
|
||||
var out string
|
||||
err := c.Get(ctx(), certPath(args[0])+"/pem", url.Values{"part": {p}}, &out)
|
||||
return out, err
|
||||
}
|
||||
if part != "" {
|
||||
out, err := get(part)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = os.Stdout.WriteString(out)
|
||||
return err
|
||||
}
|
||||
files := []struct{ part, name string }{{"fullchain", "fullchain.pem"}, {"key", "privkey.pem"}}
|
||||
if noKey {
|
||||
files = files[:1]
|
||||
}
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, f := range files {
|
||||
path := dir + string(os.PathSeparator) + f.name
|
||||
if _, err := os.Stat(path); err == nil && !force {
|
||||
return fmt.Errorf("%s exists (--force replaces it)", path)
|
||||
}
|
||||
}
|
||||
for _, f := range files {
|
||||
out, err := get(f.part)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
path := dir + string(os.PathSeparator) + f.name
|
||||
mode := os.FileMode(0o644)
|
||||
if f.part == "key" {
|
||||
mode = 0o600
|
||||
}
|
||||
if err := os.WriteFile(path, []byte(out), mode); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Chmod(path, mode); err != nil {
|
||||
return err
|
||||
}
|
||||
if !flagQuiet {
|
||||
fmt.Println("wrote", path)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cmd.Flags().StringVar(&dir, "dir", ".", "directory for fullchain.pem and privkey.pem")
|
||||
cmd.Flags().StringVar(&part, "part", "", "write one part to standard output: fullchain, cert, key or bundle")
|
||||
cmd.Flags().BoolVar(&noKey, "no-key", false, "only fullchain.pem")
|
||||
cmd.Flags().BoolVar(&force, "force", false, "replace existing files")
|
||||
_ = cmd.RegisterFlagCompletionFunc("part", cobra.FixedCompletions([]string{"fullchain", "cert", "key", "bundle"}, cobra.ShellCompDirectiveNoFileComp))
|
||||
return cmd
|
||||
}
|
||||
|
||||
func certDeleteCmd() *cobra.Command {
|
||||
var yes bool
|
||||
cmd := &cobra.Command{
|
||||
Use: "delete NAME",
|
||||
Short: "Delete a certificate (and its files for named)",
|
||||
Long: `Deletes a certificate. Its files for named are removed, so take it out of
|
||||
any tls block in named's configuration first. If jarld served it, jarld
|
||||
goes back to tls.cert_file, or to plain HTTP.`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if !confirm("Delete certificate "+args[0]+"?", yes) {
|
||||
return errors.New("aborted")
|
||||
}
|
||||
c, err := newClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := c.Do(ctx(), "DELETE", certPath(args[0]), nil, nil, nil); err != nil {
|
||||
return err
|
||||
}
|
||||
if !flagQuiet {
|
||||
fmt.Println("deleted certificate", args[0])
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cmd.Flags().BoolVarP(&yes, "yes", "y", false, "don't ask")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func certAccountCmd() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "account",
|
||||
Short: "Show the ACME account and settings (set in jarl.yaml under acme)",
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := newClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var r struct {
|
||||
Account certAccount `json:"account"`
|
||||
}
|
||||
if err := c.Get(ctx(), "/certificates", nil, &r); err != nil {
|
||||
return err
|
||||
}
|
||||
a := r.Account
|
||||
return output(a, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "Directory:\t%s\n", a.Directory)
|
||||
fmt.Fprintf(w, "Email:\t%s\n", dash(a.Email))
|
||||
fmt.Fprintf(w, "Terms agreed:\t%v\n", a.AgreeTOS)
|
||||
if a.Registered {
|
||||
fmt.Fprintf(w, "Account:\t%s (since %s)\n", a.URI, fmtTime(a.RegisteredAt))
|
||||
} else {
|
||||
fmt.Fprintf(w, "Account:\tnot registered yet (it is, with the first certificate)\n")
|
||||
}
|
||||
fmt.Fprintf(w, "EAB:\t%v\n", a.EAB)
|
||||
fmt.Fprintf(w, "Porkbun:\t%v\n", a.Porkbun)
|
||||
fmt.Fprintf(w, "Resolvers:\t%s\n", strings.Join(a.Resolvers, " "))
|
||||
fmt.Fprintf(w, "Files for named:\t%s\n", a.Dir)
|
||||
fmt.Fprintf(w, "Serving HTTPS with:\t%s\n", dash(a.ActiveWebCertificate))
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
+91
-40
@@ -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,17 +106,31 @@ func (b *batchFile) requests() ([]api.ChangeRequest, error) {
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// byIDOrMatch is a request for the record with d's id, or for the records
|
||||
// d matches.
|
||||
func byIDOrMatch(op string, d map[string]any) api.ChangeRequest {
|
||||
if id, ok := toInt64(d["id"]); ok {
|
||||
delete(d, "id")
|
||||
return api.ChangeRequest{Op: op, ID: id, Data: rawJSON(d)}
|
||||
}
|
||||
return api.ChangeRequest{Op: op, Data: rawJSON(d)}
|
||||
}
|
||||
|
||||
// submitBatch applies requests as one changeset, or stages them into the
|
||||
// changeset given with --changeset.
|
||||
func submitBatch(c *client.Client, description string, reqs []api.ChangeRequest) error {
|
||||
var resp api.MutationResponse
|
||||
if c.Changeset > 0 {
|
||||
if err := c.Do(ctx(), "POST", fmt.Sprintf("/changesets/%d/ops", c.Changeset), nil, map[string]any{"ops": reqs}, &resp); err != nil {
|
||||
cs, err := c.UseChangeset(ctx())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if cs > 0 {
|
||||
if err := c.Do(ctx(), "POST", fmt.Sprintf("/changesets/%d/ops", cs), nil, map[string]any{"ops": reqs}, &resp); err != nil {
|
||||
return err
|
||||
}
|
||||
return report(&resp)
|
||||
}
|
||||
if err := c.Do(ctx(), "POST", "/apply", nil, api.BatchRequest{Description: description, Ops: reqs, DryRun: c.DryRun, Force: c.Force}, &resp); err != nil {
|
||||
if err := c.Do(ctx(), "POST", "/apply", nil, api.BatchRequest{Description: description, Ops: reqs, DryRun: c.DryRun, Force: c.Force, AcceptRisk: c.AcceptRisk}, &resp); err != nil {
|
||||
return err
|
||||
}
|
||||
return report(&resp)
|
||||
@@ -218,8 +234,9 @@ func changesetCmd() *cobra.Command {
|
||||
Example: ` export JARL_CHANGESET=$(jarl changeset create "move web servers" -q)
|
||||
jarl record add example.com www2 A 192.0.2.20
|
||||
jarl record delete example.com www A
|
||||
jarl changeset diff $JARL_CHANGESET
|
||||
jarl changeset apply $JARL_CHANGESET`,
|
||||
jarl changeset diff # JARL_CHANGESET's
|
||||
jarl changeset apply
|
||||
unset JARL_CHANGESET`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := newClient()
|
||||
if err != nil {
|
||||
@@ -233,27 +250,31 @@ func changesetCmd() *cobra.Command {
|
||||
fmt.Println(cs.ID)
|
||||
return nil
|
||||
}
|
||||
fmt.Printf("created changeset %d\nstage changes with --changeset %d (or export JARL_CHANGESET=%d)\n", cs.ID, cs.ID, cs.ID)
|
||||
fmt.Printf("created changeset %d\nstage changes with --changeset %d (or export JARL_CHANGESET=%d), then 'jarl changeset apply'\n", cs.ID, cs.ID, cs.ID)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
show := &cobra.Command{
|
||||
Use: "show ID",
|
||||
Short: "Show a changeset's operations",
|
||||
Args: cobra.ExactArgs(1),
|
||||
Use: "show [ID]",
|
||||
Short: "Show a changeset's operations (default: -C or JARL_CHANGESET)",
|
||||
Args: cobra.MaximumNArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := newClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
id, _, err := changesetArg(c, args, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var cs api.Changeset
|
||||
if err := c.Get(ctx(), "/changesets/"+args[0], nil, &cs); err != nil {
|
||||
if err := c.Get(ctx(), "/changesets/"+id, nil, &cs); err != nil {
|
||||
return err
|
||||
}
|
||||
return output(cs, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "Changeset:\t%d (%s)\nDescription:\t%s\nCreated:\t%s by %s via %s\n", cs.ID, cs.Status, cs.Description, cs.CreatedAt.Format("2006-01-02 15:04"), cs.Username, cs.Via)
|
||||
fmt.Fprintf(w, "Changeset:\t%d (%s)\nDescription:\t%s\nCreated:\t%s by %s via %s\n", cs.ID, cs.Status, cs.Description, cs.CreatedAt.Local().Format("2006-01-02 15:04"), cs.Username, cs.Via)
|
||||
if cs.AppliedAt != nil {
|
||||
fmt.Fprintf(w, "Applied:\t%s by %s\n", cs.AppliedAt.Format("2006-01-02 15:04"), cs.AppliedBy)
|
||||
fmt.Fprintf(w, "Applied:\t%s by %s\n", cs.AppliedAt.Local().Format("2006-01-02 15:04"), cs.AppliedBy)
|
||||
}
|
||||
fmt.Fprintln(w, "\n#\tOPERATION")
|
||||
for _, op := range cs.Ops {
|
||||
@@ -268,16 +289,20 @@ func changesetCmd() *cobra.Command {
|
||||
},
|
||||
}
|
||||
diff := &cobra.Command{
|
||||
Use: "diff ID",
|
||||
Short: "Validate an open changeset and show what applying it would change",
|
||||
Args: cobra.ExactArgs(1),
|
||||
Use: "diff [ID]",
|
||||
Short: "Validate an open changeset and show what applying it would change (default: -C or JARL_CHANGESET)",
|
||||
Args: cobra.MaximumNArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := newClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
id, fromEnv, err := changesetArg(c, args, true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var raw json.RawMessage
|
||||
if err := c.Get(ctx(), "/changesets/"+args[0]+"/preview", nil, &raw); err != nil {
|
||||
if err := c.Get(ctx(), "/changesets/"+id+"/preview", nil, &raw); err != nil {
|
||||
return err
|
||||
}
|
||||
if flagOutput != "table" {
|
||||
@@ -300,56 +325,82 @@ func changesetCmd() *cobra.Command {
|
||||
fmt.Println()
|
||||
fmt.Print(r.Diff)
|
||||
if r.Valid {
|
||||
fmt.Println("\nvalid: apply with 'jarl changeset apply " + args[0] + "'")
|
||||
how := "jarl changeset apply " + id
|
||||
if fromEnv {
|
||||
how = "jarl changeset apply"
|
||||
}
|
||||
fmt.Println("\nvalid: apply with '" + how + "'")
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("the changeset can't be applied as it is")
|
||||
},
|
||||
}
|
||||
apply := &cobra.Command{
|
||||
Use: "apply ID",
|
||||
Short: "Apply an open changeset",
|
||||
Args: cobra.ExactArgs(1),
|
||||
Use: "apply [ID]",
|
||||
Short: "Apply an open changeset (default: -C or JARL_CHANGESET)",
|
||||
Args: cobra.MaximumNArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := newClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
id, fromEnv, err := changesetArg(c, args, true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var resp api.MutationResponse
|
||||
if err := c.Do(ctx(), "POST", "/changesets/"+args[0]+"/apply", nil, map[string]any{"force": c.Force, "dry_run": c.DryRun}, &resp); err != nil {
|
||||
if err := c.Do(ctx(), "POST", "/changesets/"+id+"/apply", nil, map[string]any{"force": c.Force, "dry_run": c.DryRun, "accept_risk": c.AcceptRisk}, &resp); err != nil {
|
||||
return err
|
||||
}
|
||||
return report(&resp)
|
||||
if err := report(&resp); err != nil {
|
||||
return err
|
||||
}
|
||||
if fromEnv && resp.Applied {
|
||||
envChangesetDone(id, "applied")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
discard := &cobra.Command{
|
||||
Use: "discard ID",
|
||||
Short: "Throw away an open changeset",
|
||||
Args: cobra.ExactArgs(1),
|
||||
Use: "discard [ID]",
|
||||
Short: "Throw away an open changeset (default: -C or JARL_CHANGESET)",
|
||||
Args: cobra.MaximumNArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := newClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := c.Do(ctx(), "POST", "/changesets/"+args[0]+"/discard", nil, nil, nil); err != nil {
|
||||
id, fromEnv, err := changesetArg(c, args, true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := c.Do(ctx(), "POST", "/changesets/"+id+"/discard", nil, nil, nil); err != nil {
|
||||
return err
|
||||
}
|
||||
if !flagQuiet {
|
||||
fmt.Println("discarded changeset", args[0])
|
||||
fmt.Println("discarded changeset", id)
|
||||
}
|
||||
if fromEnv {
|
||||
envChangesetDone(id, "discarded")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
remove := &cobra.Command{
|
||||
Use: "remove ID SEQ",
|
||||
Short: "Remove one operation from an open changeset",
|
||||
Args: cobra.ExactArgs(2),
|
||||
Use: "remove [ID] SEQ",
|
||||
Short: "Remove one operation from an open changeset (default: -C or JARL_CHANGESET)",
|
||||
Args: cobra.RangeArgs(1, 2),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := newClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return c.Do(ctx(), "DELETE", "/changesets/"+args[0]+"/ops/"+args[1], nil, nil, nil)
|
||||
seq := args[len(args)-1]
|
||||
id, _, err := changesetArg(c, args[:len(args)-1], true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return c.Do(ctx(), "DELETE", "/changesets/"+id+"/ops/"+seq, nil, nil, nil)
|
||||
},
|
||||
}
|
||||
revert := &cobra.Command{
|
||||
|
||||
@@ -0,0 +1,271 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"jarl/internal/api"
|
||||
)
|
||||
|
||||
// Blocking steps and client groups (docs/blocking.md).
|
||||
|
||||
func blockStepCmd() *cobra.Command {
|
||||
cmd := &cobra.Command{Use: "step", Aliases: []string{"steps"}, Short: "Blocking steps: which lists apply to which client groups",
|
||||
Long: `Steps decide which blocklists a client group gets. Each step includes the
|
||||
lists of the steps below it, plus its own:
|
||||
|
||||
off only your rules (built in)
|
||||
normal your rules + the normal lists; clients not in a group (built in)
|
||||
strict normal + the lists at step strict (a step you add)
|
||||
...
|
||||
|
||||
Lists start at a step ('jarl blocklist update LIST --step strict'), client
|
||||
groups are at one ('jarl block group add kids 10.10.0.50 --step strict').`}
|
||||
list := &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "List the steps, lowest first",
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := newClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var steps []api.BlockStep
|
||||
if err := c.Get(ctx(), "/blocking/steps", nil, &steps); err != nil {
|
||||
return err
|
||||
}
|
||||
return output(steps, func(w io.Writer) {
|
||||
fmt.Fprintln(w, "STEP\tLISTS\tGROUPS\tCOMMENT")
|
||||
fmt.Fprintln(w, "off\t-\t(built in)\tonly your rules")
|
||||
fmt.Fprintln(w, "normal\t-\t(built in)\tthe default")
|
||||
for _, s := range steps {
|
||||
fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", s.Name, orDash(strings.Join(s.Lists, ", ")), orDash(strings.Join(s.Groups, ", ")), s.Comment)
|
||||
}
|
||||
})
|
||||
},
|
||||
}
|
||||
var comment string
|
||||
add := &cobra.Command{
|
||||
Use: "add NAME",
|
||||
Short: "Add a step above the existing ones",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := newClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
resp, err := c.Mutate(ctx(), "POST", "/blocking/steps", nil, map[string]any{"name": args[0], "comment": comment})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return report(resp)
|
||||
},
|
||||
}
|
||||
add.Flags().StringVar(&comment, "comment", "", "comment")
|
||||
rename := &cobra.Command{
|
||||
Use: "rename STEP NEW-NAME",
|
||||
Short: "Rename a step",
|
||||
Args: cobra.ExactArgs(2),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := newClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
resp, err := c.Mutate(ctx(), "PATCH", "/blocking/steps/"+url.PathEscape(args[0]), nil, map[string]any{"name": args[1]})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return report(resp)
|
||||
},
|
||||
}
|
||||
order := &cobra.Command{
|
||||
Use: "order STEP...",
|
||||
Short: "Set the order of the steps above normal, lowest first",
|
||||
Example: " jarl block step order strict very-strict",
|
||||
Args: cobra.MinimumNArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := newClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
resp, err := c.Mutate(ctx(), "POST", "/blocking/steps/order", nil, map[string]any{"steps": args})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return report(resp)
|
||||
},
|
||||
}
|
||||
del := &cobra.Command{
|
||||
Use: "delete STEP",
|
||||
Short: "Delete a step (refused while lists or groups use it)",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := newClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
resp, err := c.Mutate(ctx(), "DELETE", "/blocking/steps/"+url.PathEscape(args[0]), nil, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return report(resp)
|
||||
},
|
||||
}
|
||||
cmd.AddCommand(list, add, rename, order, del)
|
||||
return cmd
|
||||
}
|
||||
|
||||
func blockGroupCmd() *cobra.Command {
|
||||
cmd := &cobra.Command{Use: "group", Aliases: []string{"groups"}, Short: "Client groups: blocking steps for some devices",
|
||||
Long: `A client group puts devices at a blocking step: off (only your rules),
|
||||
normal, or a step you added ('jarl block step'). Members are addresses,
|
||||
prefixes, or ACLs made of them. Devices not in a group are at normal.
|
||||
named must see the devices' own addresses (not a router forwarding for
|
||||
them), and they need fixed addresses (DHCP reservations).`}
|
||||
list := &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "List client groups",
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := newClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var groups []api.ClientGroup
|
||||
if err := c.Get(ctx(), "/blocking/groups", nil, &groups); err != nil {
|
||||
return err
|
||||
}
|
||||
return output(groups, func(w io.Writer) {
|
||||
fmt.Fprintln(w, "GROUP\tSTEP\tMEMBERS\tVIEWS\tCOMMENT")
|
||||
for _, g := range groups {
|
||||
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", g.Name, g.Step, orDash(strings.Join(g.Members, ", ")), viewsText(g.Views, g.AutoViews), g.Comment)
|
||||
}
|
||||
})
|
||||
},
|
||||
}
|
||||
var step, comment string
|
||||
var views []string
|
||||
add := &cobra.Command{
|
||||
Use: "add NAME MEMBER...",
|
||||
Short: "Add a client group",
|
||||
Example: ` jarl block group add work 10.10.0.20 --step off
|
||||
jarl block group add kids 10.10.0.50 10.10.0.51 2001:db8:1::/64 --step strict
|
||||
jarl block group add guests guest-net --step normal # an ACL`,
|
||||
Args: cobra.MinimumNArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := newClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
body := map[string]any{"name": args[0], "members": args[1:], "comment": comment}
|
||||
if step != "" {
|
||||
body["step"] = step
|
||||
}
|
||||
viewsBody(body, views)
|
||||
resp, err := c.Mutate(ctx(), "POST", "/blocking/groups", nil, body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return report(resp)
|
||||
},
|
||||
}
|
||||
add.Flags().StringVar(&step, "step", "", "the group's step: off, normal (default) or one you added")
|
||||
add.Flags().StringArrayVar(&views, "view", nil, "a view it applies in (repeat for several; default: every view with blocking)")
|
||||
add.Flags().StringVar(&comment, "comment", "", "comment")
|
||||
var addM, removeM []string
|
||||
var rename string
|
||||
update := &cobra.Command{
|
||||
Use: "update GROUP",
|
||||
Short: "Change a group's members, step, name, views or comment",
|
||||
Example: ` jarl block group update kids --add 10.10.0.52 --remove 10.10.0.50
|
||||
jarl block group update kids --step very-strict`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := newClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
body := map[string]any{}
|
||||
f := cmd.Flags()
|
||||
if len(addM) > 0 || len(removeM) > 0 {
|
||||
var g api.ClientGroup
|
||||
var all []api.ClientGroup
|
||||
if err := c.Get(ctx(), "/blocking/groups", nil, &all); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, x := range all {
|
||||
if x.Name == strings.ToLower(args[0]) {
|
||||
g = x
|
||||
}
|
||||
}
|
||||
if g.ID == 0 {
|
||||
return fmt.Errorf("client group %s not found", args[0])
|
||||
}
|
||||
drop := map[string]bool{}
|
||||
for _, m := range removeM {
|
||||
drop[m] = true
|
||||
}
|
||||
members := []string{}
|
||||
for _, m := range g.Members {
|
||||
if !drop[m] {
|
||||
members = append(members, m)
|
||||
}
|
||||
}
|
||||
body["members"] = append(members, addM...)
|
||||
}
|
||||
if f.Changed("step") {
|
||||
body["step"] = step
|
||||
}
|
||||
if f.Changed("name") {
|
||||
body["name"] = rename
|
||||
}
|
||||
if f.Changed("comment") {
|
||||
body["comment"] = comment
|
||||
}
|
||||
viewsBody(body, views)
|
||||
if len(body) == 0 {
|
||||
return fmt.Errorf("nothing to change (see --help)")
|
||||
}
|
||||
resp, err := c.Mutate(ctx(), "PATCH", "/blocking/groups/"+url.PathEscape(args[0]), nil, body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return report(resp)
|
||||
},
|
||||
}
|
||||
update.Flags().StringArrayVar(&addM, "add", nil, "add a member (repeat for several)")
|
||||
update.Flags().StringArrayVar(&removeM, "remove", nil, "remove a member (repeat for several)")
|
||||
update.Flags().StringVar(&step, "step", "", "new step")
|
||||
update.Flags().StringVar(&rename, "name", "", "new name")
|
||||
update.Flags().StringArrayVar(&views, "view", nil, `a view it applies in (repeat; "auto": every view with blocking)`)
|
||||
update.Flags().StringVar(&comment, "comment", "", "new comment")
|
||||
del := &cobra.Command{
|
||||
Use: "delete GROUP",
|
||||
Short: "Delete a client group (its devices go back to normal)",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := newClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
resp, err := c.Mutate(ctx(), "DELETE", "/blocking/groups/"+url.PathEscape(args[0]), nil, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return report(resp)
|
||||
},
|
||||
}
|
||||
cmd.AddCommand(list, add, update, del)
|
||||
return cmd
|
||||
}
|
||||
|
||||
func orDash(s string) string {
|
||||
if s == "" {
|
||||
return "-"
|
||||
}
|
||||
return s
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"strings"
|
||||
"testing"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
func TestPrintCompare(t *testing.T) {
|
||||
long := `"v=spf1 ` + strings.Repeat("include:_spf.example.net ", 10) + `-all"`
|
||||
r := &compareReport{Zone: "adbraun.lol", Base: "internal", Views: []string{"internal", "external"}, Linked: true}
|
||||
r.Summary = append(r.Summary, struct {
|
||||
View string `json:"view"`
|
||||
Different int `json:"different"`
|
||||
OnlyBase int `json:"only_base"`
|
||||
OnlyOther int `json:"only_other"`
|
||||
Same int `json:"same"`
|
||||
}{View: "external", Different: 1, OnlyBase: 1, Same: 5})
|
||||
r.Different = []compareRow{{Name: "@", Type: "TXT", Views: []compareSet{
|
||||
{View: "internal", Records: []string{long, `"short"`}, IDs: []int64{7, 8}, Linked: "local"},
|
||||
{View: "external", Records: []string{`"other"`}, IDs: []int64{9}, Linked: "local"},
|
||||
}}}
|
||||
r.Only = append(r.Only, struct {
|
||||
Views []string `json:"views"`
|
||||
Rows []compareRow `json:"rows"`
|
||||
}{Views: []string{"internal"}, Rows: []compareRow{{Name: "nas", Type: "A", Views: []compareSet{{View: "internal", Records: []string{"10.0.10.20"}}, {View: "external"}}}}})
|
||||
r.Same = 5
|
||||
|
||||
var b bytes.Buffer
|
||||
printCompare(&b, r, false, 60)
|
||||
out := b.String()
|
||||
// Record lines fit (the heading and summary are prose, and may wrap).
|
||||
for _, l := range strings.Split(strings.TrimRight(out, "\n"), "\n")[3:] {
|
||||
if n := utf8.RuneCountInString(l); n > 60 {
|
||||
t.Errorf("line of %d characters: %q", n, l)
|
||||
}
|
||||
}
|
||||
for _, want := range []string{
|
||||
"adbraun.lol in internal, compared with external\n linked: ",
|
||||
" external: 1 answered differently, 1 only in internal, 0 only in external, 5 the same",
|
||||
"ANSWERED DIFFERENTLY (1)\n @ TXT\n internal \"v=spf1 include:",
|
||||
"…\n \"short\"\n external \"other\" [local, id 9]",
|
||||
"ONLY IN INTERNAL (1)\n nas A 10.0.10.20",
|
||||
"5 names and types are the same (--all lists them)",
|
||||
} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("output lacks %q:\n%s", want, out)
|
||||
}
|
||||
}
|
||||
// Width 0: whole.
|
||||
b.Reset()
|
||||
printCompare(&b, r, false, 0)
|
||||
if !strings.Contains(b.String(), long+" [local, id 7]") {
|
||||
t.Errorf("whole: %s", b.String())
|
||||
}
|
||||
}
|
||||
+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 {
|
||||
|
||||
+177
-12
@@ -4,6 +4,7 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/url"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
@@ -27,16 +28,21 @@ 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)
|
||||
clients, dests := orAny(v.MatchClients), orAny(v.MatchDestinations)
|
||||
if v.Limited {
|
||||
// No views:read: only the zones they can see.
|
||||
clients, dests = "(not shown)", "(not shown)"
|
||||
}
|
||||
fmt.Fprintf(w, "%d\t%d\t%s\t%s\t%s\t%d\t%s\n", i+1, v.ID, v.Name, clients, dests, v.Zones, orDefault(v.Color))
|
||||
}
|
||||
})
|
||||
},
|
||||
}
|
||||
var clients, dests []string
|
||||
var recursiveOnly, comment string
|
||||
var adopt, cascade, yes bool
|
||||
var recursiveOnly, comment, exposure string
|
||||
var adopt, cascade, yes, reverseSuggest bool
|
||||
viewBody := func(cmd *cobra.Command) map[string]any {
|
||||
body := map[string]any{}
|
||||
f := cmd.Flags()
|
||||
@@ -59,6 +65,12 @@ func viewCmd() *cobra.Command {
|
||||
if f.Changed("comment") {
|
||||
body["comment"] = comment
|
||||
}
|
||||
if f.Changed("exposure") {
|
||||
body["exposure"] = exposure
|
||||
}
|
||||
if f.Changed("reverse-suggestions") {
|
||||
body["reverse_suggestions"] = reverseSuggest
|
||||
}
|
||||
return body
|
||||
}
|
||||
addViewFlags := func(c *cobra.Command) {
|
||||
@@ -66,6 +78,8 @@ func viewCmd() *cobra.Command {
|
||||
c.Flags().StringSliceVar(&dests, "match-destinations", nil, "match-destinations elements")
|
||||
c.Flags().StringVar(&recursiveOnly, "recursive-only", "", "match-recursive-only: yes, no or default")
|
||||
c.Flags().StringVar(&comment, "comment", "", "comment")
|
||||
c.Flags().StringVar(&exposure, "exposure", "", "reachable from the internet: auto (worked out), public or private (docs/exposure.md)")
|
||||
c.Flags().BoolVar(&reverseSuggest, "reverse-suggestions", true, "suggest reverse zones for the view's addresses (--reverse-suggestions=false: don't; docs/reverse.md)")
|
||||
}
|
||||
create := &cobra.Command{
|
||||
Use: "create NAME",
|
||||
@@ -172,10 +186,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"
|
||||
@@ -504,6 +591,12 @@ func optionCmd() *cobra.Command {
|
||||
Short: "Set an option (replaces an existing option of the same name)",
|
||||
Long: `Set an option; VALUE is named.conf syntax without the trailing semicolon.
|
||||
|
||||
It replaces the option of that name. Statements told apart by the name
|
||||
after the keyword (tls NAME, key NAME, remote-servers NAME, http NAME,
|
||||
dnssec-policy NAME, server ADDRESS, ...) replace only the one with that
|
||||
name: setting tls cloudflare leaves tls quad9 alone. --add adds another
|
||||
instance of any other option that may occur several times.
|
||||
|
||||
jarl option set recursion no
|
||||
jarl option set allow-recursion '{ homelab; localhost; }'
|
||||
jarl option set forwarders '{ 1.1.1.1; 9.9.9.9; }'
|
||||
@@ -528,7 +621,11 @@ func optionCmd() *cobra.Command {
|
||||
if cmd.Flags().Changed("comment") {
|
||||
body["comment"] = comment
|
||||
}
|
||||
label := optionLabel(strings.ToLower(args[0]), value)
|
||||
for _, o := range opts {
|
||||
if label != "" && optionLabel(o.Name, o.Value) != label {
|
||||
continue // another tls (key, server, ...) statement
|
||||
}
|
||||
if o.Name == strings.ToLower(args[0]) && !multiple {
|
||||
resp, err := c.Mutate(ctx(), "PATCH", fmt.Sprintf("/options/%d", o.ID), nil, body)
|
||||
if err != nil {
|
||||
@@ -549,7 +646,7 @@ func optionCmd() *cobra.Command {
|
||||
set.Flags().StringVar(&comment, "comment", "", "comment")
|
||||
set.Flags().BoolVar(&multiple, "add", false, "add another instance instead of replacing (for options that may occur several times)")
|
||||
unset := &cobra.Command{
|
||||
Use: "unset NAME | unset --id ID",
|
||||
Use: "unset NAME [LABEL] | unset --id ID",
|
||||
Short: "Remove an option (BIND's default applies again)",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := newClient()
|
||||
@@ -563,8 +660,8 @@ func optionCmd() *cobra.Command {
|
||||
}
|
||||
return report(resp)
|
||||
}
|
||||
if len(args) != 1 {
|
||||
return fmt.Errorf("give the option name or --id")
|
||||
if len(args) < 1 || len(args) > 2 {
|
||||
return fmt.Errorf("give the option name (and the name after it, for tls NAME and the like) or --id")
|
||||
}
|
||||
sc, id, err := scope()
|
||||
if err != nil {
|
||||
@@ -576,15 +673,24 @@ func optionCmd() *cobra.Command {
|
||||
}
|
||||
var matches []api.Option
|
||||
for _, o := range opts {
|
||||
if o.Name == strings.ToLower(args[0]) {
|
||||
if o.Name == strings.ToLower(args[0]) && (len(args) == 1 || strings.EqualFold(optionLabel(o.Name, o.Value), strings.Trim(args[1], `"`))) {
|
||||
matches = append(matches, o)
|
||||
}
|
||||
}
|
||||
switch len(matches) {
|
||||
case 0:
|
||||
return fmt.Errorf("option %s is not set here", args[0])
|
||||
return fmt.Errorf("option %s is not set here", strings.Join(args, " "))
|
||||
case 1:
|
||||
default:
|
||||
var labels []string
|
||||
for _, o := range matches {
|
||||
if l := optionLabel(o.Name, o.Value); l != "" {
|
||||
labels = append(labels, l)
|
||||
}
|
||||
}
|
||||
if len(labels) == len(matches) {
|
||||
return fmt.Errorf("option %s is set %d times (%s); say which: jarl option unset %s %s", args[0], len(matches), strings.Join(labels, ", "), args[0], labels[0])
|
||||
}
|
||||
return fmt.Errorf("option %s is set %d times; remove one with --id", args[0], len(matches))
|
||||
}
|
||||
resp, err := c.Mutate(ctx(), "DELETE", fmt.Sprintf("/options/%d", matches[0].ID), nil, nil)
|
||||
@@ -643,7 +749,35 @@ func optionCmd() *cobra.Command {
|
||||
}
|
||||
scopeArgs(describe)
|
||||
describe.Flags().StringVar(&zoneType, "zone-type", "primary", "with --zone: the zone type")
|
||||
cmd.AddCommand(list, set, unset, describe)
|
||||
generated := &cobra.Command{
|
||||
Use: "generated",
|
||||
Short: "What Jarl adds to named.conf by itself, why, and what controls it",
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := newClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var gen []struct {
|
||||
Where string `json:"where"`
|
||||
Text string `json:"text"`
|
||||
Why string `json:"why"`
|
||||
Setting string `json:"setting"`
|
||||
}
|
||||
if err := c.Get(ctx(), "/options/generated", nil, &gen); err != nil {
|
||||
return err
|
||||
}
|
||||
return output(gen, func(w io.Writer) {
|
||||
for i, g := range gen {
|
||||
if i > 0 {
|
||||
fmt.Fprintln(w)
|
||||
}
|
||||
fmt.Fprintf(w, "# %s: %s\n# controlled by: %s\n%s\n", g.Where, g.Why, g.Setting, g.Text)
|
||||
}
|
||||
})
|
||||
},
|
||||
}
|
||||
cmd.AddCommand(list, set, unset, describe, generated)
|
||||
return cmd
|
||||
}
|
||||
|
||||
@@ -682,7 +816,7 @@ func settingsCmd() *cobra.Command {
|
||||
},
|
||||
}
|
||||
var ns []string
|
||||
var contact, policy string
|
||||
var contact, policy, srvExposure string
|
||||
var ttl, refresh, retry, expire, neg uint32
|
||||
set := &cobra.Command{
|
||||
Use: "set",
|
||||
@@ -703,6 +837,9 @@ func settingsCmd() *cobra.Command {
|
||||
if f.Changed("serial-policy") {
|
||||
body["serial_policy"] = policy
|
||||
}
|
||||
if f.Changed("exposure") {
|
||||
body["exposure"] = srvExposure
|
||||
}
|
||||
for name, v := range map[string]uint32{"ttl": ttl, "refresh": refresh, "retry": retry, "expire": expire, "negative-ttl": neg} {
|
||||
if f.Changed(name) {
|
||||
key := map[string]string{"ttl": "default_ttl", "negative-ttl": "negative_ttl"}[name]
|
||||
@@ -725,6 +862,7 @@ func settingsCmd() *cobra.Command {
|
||||
set.Flags().StringSliceVar(&ns, "ns", nil, "default name servers")
|
||||
set.Flags().StringVar(&contact, "contact", "", "default SOA contact")
|
||||
set.Flags().StringVar(&policy, "serial-policy", "", "date, increment or unixtime")
|
||||
set.Flags().StringVar(&srvExposure, "exposure", "", "without views: is the server reachable from the internet? auto, public or private (docs/exposure.md)")
|
||||
set.Flags().Uint32Var(&ttl, "ttl", 0, "default TTL")
|
||||
set.Flags().Uint32Var(&refresh, "refresh", 0, "SOA refresh")
|
||||
set.Flags().Uint32Var(&retry, "retry", 0, "SOA retry")
|
||||
@@ -733,3 +871,30 @@ func settingsCmd() *cobra.Command {
|
||||
cmd.AddCommand(show, set)
|
||||
return cmd
|
||||
}
|
||||
|
||||
// labelled are the statements told apart by the name after the keyword:
|
||||
// tls quad9 { ... }, key xfer { ... }, server 192.0.2.1 { ... }.
|
||||
var labelled = map[string]bool{"tls": true, "http": true, "key": true, "remote-servers": true, "primaries": true, "masters": true,
|
||||
"parental-agents": true, "dnssec-policy": true, "key-store": true, "server": true, "view": true, "acl": true, "zone": true}
|
||||
|
||||
// optionLabel returns the name a labelled statement's value starts with
|
||||
// ("quad9" for tls quad9 { ... }), lowercase; "" for other statements.
|
||||
func optionLabel(name, value string) string {
|
||||
if !labelled[name] {
|
||||
return ""
|
||||
}
|
||||
v := strings.TrimSpace(value)
|
||||
if v == "" || strings.HasPrefix(v, "{") {
|
||||
return ""
|
||||
}
|
||||
if strings.HasPrefix(v, `"`) {
|
||||
if end := strings.Index(v[1:], `"`); end >= 0 {
|
||||
return strings.ToLower(v[1 : end+1])
|
||||
}
|
||||
}
|
||||
end := strings.IndexAny(v, " \t\n{;")
|
||||
if end < 0 {
|
||||
end = len(v)
|
||||
}
|
||||
return strings.ToLower(v[:end])
|
||||
}
|
||||
@@ -0,0 +1,400 @@
|
||||
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 for a domain, the
|
||||
parent zone for a subdomain), and 'status' says when that is safe and where. 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 its DS record to the parent zone only when\n'jarl zone dnssec status %s' says the zone is ready; it also says where it goes.\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 the
|
||||
parent ('status' says where), 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 %s, 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), dsPlace(st), 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 there) 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" {
|
||||
if r.DSAt == "none" {
|
||||
fmt.Fprintf(w, "\n%s\n", r.DSNote)
|
||||
}
|
||||
where := dsPlace(r)
|
||||
if r.ParentZone != "" {
|
||||
where += ", 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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// dsPlace says where the zone's DS record goes ("at your registrar").
|
||||
func dsPlace(r *api.DNSSECResponse) string {
|
||||
if r.DSPlace != "" {
|
||||
return r.DSPlace
|
||||
}
|
||||
return "at the parent zone"
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
@@ -0,0 +1,465 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
type encEndpoint struct {
|
||||
Address string `json:"address"`
|
||||
Port int `json:"port"`
|
||||
}
|
||||
|
||||
type encStatus struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Certificate string `json:"certificate"`
|
||||
Hostname string `json:"hostname"`
|
||||
Hostnames []string `json:"hostnames"`
|
||||
AddrViews map[string][]string `json:"address_views"`
|
||||
DoTHost string `json:"dot_host"`
|
||||
DoHURL string `json:"doh_url"`
|
||||
DoT []encEndpoint `json:"dot"`
|
||||
DoH []encEndpoint `json:"doh"`
|
||||
Other bool `json:"other"`
|
||||
Health *encHealth `json:"health"`
|
||||
Addresses []struct {
|
||||
Address string `json:"address"`
|
||||
Private bool `json:"private"`
|
||||
} `json:"addresses"`
|
||||
Views []struct {
|
||||
Name string `json:"name"`
|
||||
Public bool `json:"public"`
|
||||
HasZone bool `json:"has_zone"`
|
||||
} `json:"views"`
|
||||
Certificates []struct {
|
||||
Name string `json:"name"`
|
||||
Names []string `json:"names"`
|
||||
Domains []string `json:"domains"`
|
||||
DNS bool `json:"dns"`
|
||||
Obtained bool `json:"obtained"`
|
||||
} `json:"certificates"`
|
||||
}
|
||||
|
||||
type encConflict struct {
|
||||
Address string `json:"address"`
|
||||
Port int `json:"port"`
|
||||
What string `json:"what"`
|
||||
}
|
||||
|
||||
func endpoints(es []encEndpoint) string {
|
||||
var out []string
|
||||
for _, e := range es {
|
||||
out = append(out, fmt.Sprintf("%s port %d", e.Address, e.Port))
|
||||
}
|
||||
return strings.Join(out, ", ")
|
||||
}
|
||||
|
||||
func encryptedDNSCmd() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "encrypted-dns",
|
||||
Aliases: []string{"encdns"},
|
||||
Short: "DNS over TLS and HTTPS for your clients (docs/encrypted-dns.md)",
|
||||
Long: `Shows whether named serves DNS over TLS (DoT) and DNS over HTTPS (DoH),
|
||||
where, and what to give clients. 'setup' sets it up: a certificate for the
|
||||
host name (obtained if needed), named's tls statement and listeners on the
|
||||
addresses you choose, and the host name's address records in your private
|
||||
views.`,
|
||||
Example: ` jarl encrypted-dns
|
||||
jarl encrypted-dns setup --hostname dns.adbraun.lol --address 10.0.10.5 --address 2a02:8013:7f3d:10::5
|
||||
jarl encrypted-dns setup --hostname dns.adbraun.lol --address 10.0.10.5 --no-doh
|
||||
jarl encrypted-dns check --address 10.0.10.5 --doh-port 443
|
||||
jarl encrypted-dns disable`,
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := newClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var s encStatus
|
||||
if err := c.Get(ctx(), "/encrypted-dns", nil, &s); err != nil {
|
||||
return err
|
||||
}
|
||||
return output(s, func(w io.Writer) {
|
||||
if !s.Enabled {
|
||||
fmt.Fprintln(w, "Encrypted DNS:\toff (jarl encrypted-dns setup --hostname NAME --address ADDR)")
|
||||
} else {
|
||||
fmt.Fprintf(w, "Host names:\t%s\n", strings.Join(s.Hostnames, ", "))
|
||||
fmt.Fprintf(w, "Certificate:\t%s\n", s.Certificate)
|
||||
for _, e := range append(append([]encEndpoint{}, s.DoT...), s.DoH...) {
|
||||
if vs, ok := s.AddrViews[e.Address]; ok {
|
||||
fmt.Fprintf(w, "Records:\t%s in %s\n", e.Address, strings.Join(vs, ", "))
|
||||
delete(s.AddrViews, e.Address)
|
||||
}
|
||||
}
|
||||
if len(s.DoT) > 0 {
|
||||
fmt.Fprintf(w, "DNS over TLS:\t%s\n", endpoints(s.DoT))
|
||||
}
|
||||
if len(s.DoH) > 0 {
|
||||
fmt.Fprintf(w, "DNS over HTTPS:\t%s\n", endpoints(s.DoH))
|
||||
}
|
||||
if s.DoTHost != "" {
|
||||
fmt.Fprintf(w, "For DoT clients:\t%s (Android: Private DNS hostname; Linux: DNS=ADDRESS#%s, DNSOverTLS=yes)\n", s.DoTHost, s.Hostname)
|
||||
}
|
||||
if s.DoHURL != "" {
|
||||
fmt.Fprintf(w, "For DoH clients:\t%s (Firefox, Chrome, Windows 11)\n", s.DoHURL)
|
||||
}
|
||||
}
|
||||
if s.Enabled {
|
||||
printHealth(w, s.Health)
|
||||
}
|
||||
if s.Other {
|
||||
fmt.Fprintln(w, "Note:\tnamed has DoT or DoH listeners written by hand too; setup leaves them alone")
|
||||
}
|
||||
var addrs []string
|
||||
for _, a := range s.Addresses {
|
||||
t := a.Address
|
||||
if !a.Private {
|
||||
t += " (public)"
|
||||
}
|
||||
addrs = append(addrs, t)
|
||||
}
|
||||
fmt.Fprintf(w, "This server's addresses:\t%s\n", strings.Join(addrs, ", "))
|
||||
})
|
||||
},
|
||||
}
|
||||
var hostList []string
|
||||
var certName string
|
||||
var addrs, views []string
|
||||
var noDoT, noDoH bool
|
||||
var dohPort, dotPort int
|
||||
setup := &cobra.Command{
|
||||
Use: "setup --hostname NAME... --address ADDR[=VIEW,...]...",
|
||||
Short: "Set up DNS over TLS and HTTPS (obtaining a certificate if needed)",
|
||||
Long: `Sets up DoT (port 853) and DoH (port 443, or --doh-port) on the addresses
|
||||
given, which should be this server's addresses on your own networks:
|
||||
|
||||
- the certificate: --certificate, or one with the dns use whose names
|
||||
cover the host name, or a new one Jarl obtains for it (and waits for);
|
||||
- a check that nothing else listens on those ports there;
|
||||
- named's tls statement and listen-on statements (and an explicit
|
||||
listen-on { any; } when there was none, as the first listen-on would
|
||||
otherwise stop named listening on port 53 everywhere);
|
||||
- the host names' A and AAAA records, local to each view: per address,
|
||||
the views after = (--address 10.0.10.5=internal,lab), or the views of
|
||||
--view for every address (default: the private views that have the
|
||||
zone). A view no longer given loses the address.
|
||||
|
||||
Several --hostname: the certificate must be for all of them. It is one
|
||||
change, staged or applied like any other. Running it again replaces the
|
||||
earlier setup, ports included.`,
|
||||
Example: ` jarl encrypted-dns setup --hostname dns.adbraun.lol --address 10.0.10.5=internal --address fdab:abab:abab:10::5=internal
|
||||
jarl encrypted-dns setup --hostname dns.adbraun.lol --hostname doh.adbraun.lol --address 10.0.10.5 --view internal --dot-port 8853`,
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := newClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var hosts []string
|
||||
for _, h := range hostList {
|
||||
for _, x := range strings.Split(h, ",") {
|
||||
if x = strings.ToLower(strings.TrimSuffix(strings.TrimSpace(x), ".")); x != "" {
|
||||
hosts = append(hosts, x)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(hosts) == 0 {
|
||||
return errors.New("give --hostname, the name clients connect to (e.g. dns.adbraun.lol)")
|
||||
}
|
||||
host := hosts[0]
|
||||
var s encStatus
|
||||
if err := c.Get(ctx(), "/encrypted-dns", q("hostname", strings.Join(hosts, ",")), &s); err != nil {
|
||||
return err
|
||||
}
|
||||
// ADDR=VIEW,VIEW gives an address its own views.
|
||||
addrViews := map[string][]string{}
|
||||
perAddress := false
|
||||
for i, a := range addrs {
|
||||
addr, vs, ok := strings.Cut(a, "=")
|
||||
addrs[i] = strings.TrimSpace(addr)
|
||||
if ok {
|
||||
perAddress = true
|
||||
addrViews[addrs[i]] = nil
|
||||
for _, v := range strings.Split(vs, ",") {
|
||||
if v = strings.TrimSpace(v); v != "" {
|
||||
addrViews[addrs[i]] = append(addrViews[addrs[i]], v)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if perAddress && len(views) > 0 {
|
||||
return errors.New("give the views per address (--address ADDR=VIEW,...) or for all of them (--view), not both")
|
||||
}
|
||||
if len(addrs) == 0 {
|
||||
var list []string
|
||||
for _, a := range s.Addresses {
|
||||
list = append(list, a.Address)
|
||||
}
|
||||
return fmt.Errorf("give --address (this server's addresses on your own networks); it has %s", strings.Join(list, ", "))
|
||||
}
|
||||
// The certificate.
|
||||
if certName == "" {
|
||||
certs:
|
||||
for _, ci := range s.Certificates {
|
||||
for _, h := range hosts {
|
||||
if !coversName(append(ci.Names, ci.Domains...), h) {
|
||||
continue certs
|
||||
}
|
||||
}
|
||||
certName = ci.Name
|
||||
break
|
||||
}
|
||||
}
|
||||
if certName == "" {
|
||||
certName = strings.SplitN(host, ".", 2)[0]
|
||||
fmt.Printf("Creating certificate %s for %s\n", certName, strings.Join(hosts, ", "))
|
||||
var r struct {
|
||||
Certificate certInfo `json:"certificate"`
|
||||
}
|
||||
if err := c.Do(ctx(), "POST", "/certificates", nil, map[string]any{"name": certName, "domains": hosts, "uses": []string{"dns"}}, &r); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := waitIssue(c, certName); err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
var ci certInfo
|
||||
if err := c.Get(ctx(), certPath(certName), nil, &ci); err != nil {
|
||||
return err
|
||||
}
|
||||
if !contains(ci.Uses, "dns") {
|
||||
fmt.Printf("Adding the dns use to certificate %s\n", certName)
|
||||
if err := c.Do(ctx(), "PATCH", certPath(certName), nil, map[string]any{"uses": append(ci.Uses, "dns")}, nil); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if ci.NotAfter == nil {
|
||||
if ci.IssuingSince == nil {
|
||||
if err := c.Do(ctx(), "POST", certPath(certName)+"/issue", nil, map[string]any{}, nil); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := waitIssue(c, certName); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
body := map[string]any{"hostnames": hosts, "certificate": certName, "addresses": addrs, "dot": !noDoT, "doh": !noDoH}
|
||||
if cmd.Flags().Changed("doh-port") {
|
||||
body["doh_port"] = dohPort
|
||||
}
|
||||
if cmd.Flags().Changed("dot-port") {
|
||||
body["dot_port"] = dotPort
|
||||
}
|
||||
// Nothing else may listen there.
|
||||
var chk struct {
|
||||
Conflicts []encConflict `json:"conflicts"`
|
||||
FreeDoHPort int `json:"free_doh_port"`
|
||||
Error string `json:"error"`
|
||||
}
|
||||
if err := c.Do(ctx(), "POST", "/encrypted-dns/check", nil, body, &chk); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(chk.Conflicts) > 0 {
|
||||
var msgs []string
|
||||
for _, cf := range chk.Conflicts {
|
||||
msgs = append(msgs, fmt.Sprintf("%s listens on %s port %d", cf.What, cf.Address, cf.Port))
|
||||
}
|
||||
hint := ""
|
||||
if chk.FreeDoHPort != 0 && !noDoH {
|
||||
hint = fmt.Sprintf("; for DoH, port %d is free there (--doh-port %d)", chk.FreeDoHPort, chk.FreeDoHPort)
|
||||
}
|
||||
return fmt.Errorf("%s%s", strings.Join(msgs, "; "), hint)
|
||||
}
|
||||
if chk.Error != "" && !flagQuiet {
|
||||
fmt.Println("Note:", chk.Error)
|
||||
}
|
||||
switch {
|
||||
case perAddress:
|
||||
for _, a := range addrs {
|
||||
if _, ok := addrViews[a]; !ok {
|
||||
addrViews[a] = []string{} // no records for it
|
||||
}
|
||||
}
|
||||
body["address_views"] = addrViews
|
||||
default:
|
||||
if len(views) == 0 {
|
||||
for _, v := range s.Views {
|
||||
if !v.Public && v.HasZone {
|
||||
views = append(views, v.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
body["views"] = views
|
||||
}
|
||||
resp, err := c.Mutate(ctx(), "POST", "/encrypted-dns", nil, body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return report(resp)
|
||||
},
|
||||
}
|
||||
f := setup.Flags()
|
||||
f.StringArrayVar(&hostList, "hostname", nil, "a name clients connect to (repeat, or comma separated, for several; the certificate must be for all)")
|
||||
f.StringVar(&certName, "certificate", "", "the Jarl certificate to serve (default: one for the host name, obtained if there is none)")
|
||||
f.StringArrayVar(&addrs, "address", nil, "this server's address to listen on, with the views that get it: 10.0.10.5=internal,lab (repeat for several)")
|
||||
f.BoolVar(&noDoT, "no-dot", false, "no DNS over TLS")
|
||||
f.BoolVar(&noDoH, "no-doh", false, "no DNS over HTTPS")
|
||||
f.IntVar(&dohPort, "doh-port", 443, "port for DNS over HTTPS")
|
||||
f.IntVar(&dotPort, "dot-port", 853, "port for DNS over TLS (clients expect 853)")
|
||||
f.StringArrayVar(&views, "view", nil, "views where every address gets the host names' records (default: the private views that have the zone); per address: --address ADDR=VIEW")
|
||||
|
||||
var cAddrs []string
|
||||
var cDoH int
|
||||
check := &cobra.Command{
|
||||
Use: "check --address ADDR...",
|
||||
Short: "See whether something already listens on the DoT and DoH ports",
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := newClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var r struct {
|
||||
Conflicts []encConflict `json:"conflicts"`
|
||||
FreeDoHPort int `json:"free_doh_port"`
|
||||
Error string `json:"error"`
|
||||
}
|
||||
if err := c.Do(ctx(), "POST", "/encrypted-dns/check", nil, map[string]any{"addresses": cAddrs, "dot": true, "doh": true, "doh_port": cDoH}, &r); err != nil {
|
||||
return err
|
||||
}
|
||||
return output(r, func(w io.Writer) {
|
||||
if r.Error != "" {
|
||||
fmt.Fprintln(w, r.Error)
|
||||
}
|
||||
if len(r.Conflicts) == 0 {
|
||||
fmt.Fprintln(w, "The ports are free.")
|
||||
}
|
||||
for _, cf := range r.Conflicts {
|
||||
fmt.Fprintf(w, "%s port %d:\t%s listens there\n", cf.Address, cf.Port, cf.What)
|
||||
}
|
||||
if r.FreeDoHPort != 0 {
|
||||
fmt.Fprintf(w, "Free for DoH:\tport %d\n", r.FreeDoHPort)
|
||||
}
|
||||
})
|
||||
},
|
||||
}
|
||||
check.Flags().StringArrayVar(&cAddrs, "address", nil, "address to check (repeat for several)")
|
||||
check.Flags().IntVar(&cDoH, "doh-port", 443, "port for DNS over HTTPS")
|
||||
|
||||
disable := &cobra.Command{
|
||||
Use: "disable",
|
||||
Short: "Turn DNS over TLS and HTTPS off (the certificate and records stay)",
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := newClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
resp, err := c.Mutate(ctx(), "DELETE", "/encrypted-dns", url.Values{}, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return report(resp)
|
||||
},
|
||||
}
|
||||
health := &cobra.Command{
|
||||
Use: "health",
|
||||
Short: "Check the DoT and DoH listeners now, as a client would",
|
||||
Long: `Connects to each listener, checks the certificate it serves (for the
|
||||
host name, current, the one Jarl last wrote) and asks a question. jarld
|
||||
also does this every 10 minutes; the dashboard and Prometheus
|
||||
(jarl_encrypted_dns_up) show the result. Exits 1 when a listener doesn't
|
||||
work.`,
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := newClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var h encHealth
|
||||
if err := c.Do(ctx(), "POST", "/encrypted-dns/health", nil, map[string]any{}, &h); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := output(h, func(w io.Writer) { printHealth(w, &h) }); err != nil {
|
||||
return err
|
||||
}
|
||||
ok := true
|
||||
for _, p := range h.Probes {
|
||||
ok = ok && p.OK
|
||||
}
|
||||
if !ok {
|
||||
return errors.New("encrypted DNS isn't working everywhere")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cmd.AddCommand(setup, check, disable, health)
|
||||
return cmd
|
||||
}
|
||||
|
||||
// coversName reports whether certificate names cover host (a wildcard
|
||||
// covers one label).
|
||||
func coversName(names []string, host string) bool {
|
||||
for _, n := range names {
|
||||
n = strings.ToLower(strings.TrimSuffix(n, "."))
|
||||
if n == host {
|
||||
return true
|
||||
}
|
||||
if base, ok := strings.CutPrefix(n, "*."); ok {
|
||||
if _, rest, ok := strings.Cut(host, "."); ok && rest == base {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func contains(list []string, s string) bool {
|
||||
for _, x := range list {
|
||||
if x == s {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
type encHealth struct {
|
||||
Checked time.Time `json:"checked"`
|
||||
Probes []struct {
|
||||
Kind string `json:"kind"`
|
||||
Address string `json:"address"`
|
||||
Port int `json:"port"`
|
||||
OK bool `json:"ok"`
|
||||
Problem string `json:"problem"`
|
||||
Warning string `json:"warning"`
|
||||
RCode string `json:"rcode"`
|
||||
Ms int64 `json:"ms"`
|
||||
} `json:"probes"`
|
||||
}
|
||||
|
||||
func printHealth(w io.Writer, h *encHealth) bool {
|
||||
if h == nil {
|
||||
fmt.Fprintln(w, "Health:\tnot checked yet (jarl encrypted-dns health)")
|
||||
return true
|
||||
}
|
||||
ok := true
|
||||
for _, p := range h.Probes {
|
||||
kind := map[string]string{"dot": "DoT", "doh": "DoH"}[p.Kind]
|
||||
state := fmt.Sprintf("working (%s in %d ms)", p.RCode, p.Ms)
|
||||
if !p.OK {
|
||||
state, ok = "NOT WORKING: "+p.Problem, false
|
||||
} else if p.Warning != "" {
|
||||
state += "; " + p.Warning
|
||||
}
|
||||
fmt.Fprintf(w, "%s %s port %d:\t%s\n", kind, p.Address, p.Port, state)
|
||||
}
|
||||
fmt.Fprintf(w, "Checked:\t%s\n", h.Checked.Local().Format("2006-01-02 15:04:05"))
|
||||
return ok
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
|
||||
"jarl/internal/api"
|
||||
"jarl/internal/client"
|
||||
"jarl/internal/model"
|
||||
)
|
||||
|
||||
// A changeset given with --changeset is used as given (jarld still refuses
|
||||
// what isn't allowed). One taken from JARL_CHANGESET is checked first: an
|
||||
// exported ID outlives the changeset (it gets applied), and changeset IDs
|
||||
// keep growing, so a stale or mistyped one can name someone else's
|
||||
// changeset later. It is used only when it is open and the caller's own,
|
||||
// and the CLI says on stderr which changeset that is.
|
||||
|
||||
// envChangeset returns the changeset in JARL_CHANGESET (0 if unset).
|
||||
func envChangeset() (int64, error) {
|
||||
v := os.Getenv("JARL_CHANGESET")
|
||||
if v == "" {
|
||||
return 0, nil
|
||||
}
|
||||
n, err := strconv.ParseInt(v, 10, 64)
|
||||
if err != nil || n <= 0 {
|
||||
return 0, fmt.Errorf("invalid JARL_CHANGESET %q", v)
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// checkEnvChangeset checks the changeset id from JARL_CHANGESET before it
|
||||
// is used. With mustOwn, it must also be open and the caller's own (for
|
||||
// staging into it, applying or discarding it).
|
||||
func checkEnvChangeset(c *client.Client, id int64, mustOwn bool) (*api.Changeset, error) {
|
||||
var cs api.Changeset
|
||||
if err := c.Get(ctx(), fmt.Sprintf("/changesets/%d", id), nil, &cs); err != nil {
|
||||
var ce *client.Error
|
||||
if errors.As(err, &ce) && ce.Status == 404 {
|
||||
return nil, fmt.Errorf("JARL_CHANGESET is %d, but there is no changeset %d: start one with 'export JARL_CHANGESET=$(jarl changeset create -q)', or 'unset JARL_CHANGESET' to make changes live", id, id)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
if mustOwn {
|
||||
if cs.Status != model.ChangesetOpen {
|
||||
return nil, fmt.Errorf("JARL_CHANGESET is %d, but changeset %d is %s: 'unset JARL_CHANGESET' to make changes live, or start a new one with 'export JARL_CHANGESET=$(jarl changeset create -q)'", id, id, cs.Status)
|
||||
}
|
||||
var me api.Whoami
|
||||
if err := c.Get(ctx(), "/auth/whoami", nil, &me); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
mine := cs.UserID == me.User.ID && (me.User.ID != 0 || cs.Username == me.User.Username)
|
||||
if !mine {
|
||||
return nil, fmt.Errorf("JARL_CHANGESET is %d, but changeset %d is %s's (made via %s), not yours, so it isn't used from JARL_CHANGESET; give it as -C %d to use it anyway, or 'unset JARL_CHANGESET'", id, id, cs.Username, cs.Via, id)
|
||||
}
|
||||
}
|
||||
if !flagQuiet {
|
||||
desc := cs.Description
|
||||
if desc == "" {
|
||||
desc = "no description"
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "changeset %d (%s, %d operations) from JARL_CHANGESET\n", id, desc, len(cs.Ops))
|
||||
}
|
||||
return &cs, nil
|
||||
}
|
||||
|
||||
// changesetArg returns the changeset a changeset subcommand works on: the
|
||||
// ID argument, else --changeset, else JARL_CHANGESET (checked; mustOwn as
|
||||
// for checkEnvChangeset). fromEnv says it came from JARL_CHANGESET.
|
||||
func changesetArg(c *client.Client, args []string, mustOwn bool) (id string, fromEnv bool, err error) {
|
||||
if len(args) > 0 {
|
||||
return args[0], false, nil
|
||||
}
|
||||
if flagChangeset > 0 {
|
||||
return strconv.FormatInt(flagChangeset, 10), false, nil
|
||||
}
|
||||
n, err := envChangeset()
|
||||
if err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
if n == 0 {
|
||||
return "", false, errors.New("which changeset? Give its ID, or set JARL_CHANGESET (export JARL_CHANGESET=$(jarl changeset create -q))")
|
||||
}
|
||||
if _, err := checkEnvChangeset(c, n, mustOwn); err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
return strconv.FormatInt(n, 10), true, nil
|
||||
}
|
||||
|
||||
// envChangesetDone reminds that JARL_CHANGESET still names a changeset
|
||||
// that was just applied or discarded: later changes would fail to stage.
|
||||
func envChangesetDone(id, what string) {
|
||||
if !flagQuiet {
|
||||
fmt.Fprintf(os.Stderr, "JARL_CHANGESET still names changeset %s, which is now %s: run 'unset JARL_CHANGESET' (or export a new one) before making more changes\n", id, what)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
type exposureFinding struct {
|
||||
Severity string `json:"severity"`
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
View string `json:"view"`
|
||||
Fix string `json:"fix"`
|
||||
}
|
||||
|
||||
type exposureReport struct {
|
||||
Scopes []struct {
|
||||
ViewID int64 `json:"view_id"`
|
||||
View string `json:"view"`
|
||||
Public bool `json:"public"`
|
||||
Override string `json:"override"`
|
||||
Reason string `json:"reason"`
|
||||
RateLimit string `json:"rate_limit"`
|
||||
Findings []exposureFinding `json:"findings"`
|
||||
} `json:"scopes"`
|
||||
Server []exposureFinding `json:"server"`
|
||||
RateLimiting *struct {
|
||||
Since string `json:"since"`
|
||||
Counts []struct {
|
||||
Key string
|
||||
Count int64
|
||||
} `json:"counts"`
|
||||
Networks []struct {
|
||||
Key string
|
||||
Count int64
|
||||
} `json:"networks"`
|
||||
Names []struct {
|
||||
Key string
|
||||
Count int64
|
||||
} `json:"names"`
|
||||
} `json:"rate_limiting"`
|
||||
Mail *mailReport `json:"mail"`
|
||||
}
|
||||
|
||||
type mailReport struct {
|
||||
Checked string `json:"checked"`
|
||||
Results []struct {
|
||||
Zone string `json:"zone"`
|
||||
Address string `json:"address"`
|
||||
Roles []string `json:"roles"`
|
||||
PTR []string `json:"ptr"`
|
||||
OK bool `json:"ok"`
|
||||
Level string `json:"level"`
|
||||
Message string `json:"message"`
|
||||
ManagedBy string `json:"managed_by"`
|
||||
LocalZone string `json:"local_zone"`
|
||||
} `json:"results"`
|
||||
Errors []string `json:"errors"`
|
||||
}
|
||||
|
||||
func printMail(w io.Writer, m *mailReport) {
|
||||
if len(m.Results) == 0 && len(m.Errors) == 0 {
|
||||
fmt.Fprintln(w, "No mail addresses: no public zone has an MX record or SPF addresses.")
|
||||
return
|
||||
}
|
||||
fmt.Fprintln(w, "ZONE\tADDRESS\tFOR\tPTR\tSTATUS")
|
||||
for _, r := range m.Results {
|
||||
ptr := strings.Join(r.PTR, ",")
|
||||
if ptr == "" {
|
||||
ptr = "-"
|
||||
}
|
||||
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", r.Zone, r.Address, strings.Join(r.Roles, ", "), ptr, r.Level)
|
||||
if r.Level != "ok" {
|
||||
fmt.Fprintf(w, " %s\n", r.Message)
|
||||
}
|
||||
if r.LocalZone != "" {
|
||||
fmt.Fprintf(w, " Jarl's reverse zone %s only answers inside: receivers ask %s\n", r.LocalZone, orOwner(r.ManagedBy))
|
||||
}
|
||||
}
|
||||
for _, e := range m.Errors {
|
||||
fmt.Fprintf(w, "error: %s\n", e)
|
||||
}
|
||||
}
|
||||
|
||||
func orOwner(s string) string {
|
||||
if s == "" {
|
||||
return "the address's owner"
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// exposureScope turns "server" (or "") into the server, anything else
|
||||
// into a view name.
|
||||
func exposureScope(s string) map[string]any {
|
||||
if s == "" || strings.EqualFold(s, "server") {
|
||||
return map[string]any{}
|
||||
}
|
||||
return map[string]any{"view": s}
|
||||
}
|
||||
|
||||
func exposureCmd() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "exposure",
|
||||
Short: "What is reachable from the internet, and what to harden (docs/exposure.md)",
|
||||
Long: `Shows which views (or the server, without views) are reachable from the
|
||||
internet and why, their rate limiting, and the exposure findings with the
|
||||
fix Jarl can stage for each.`,
|
||||
Example: ` jarl exposure
|
||||
jarl exposure fix external exposure_rate_limit # rate limiting, log-only first
|
||||
jarl exposure fix external exposure_rate_limit_log_only # then start enforcing
|
||||
jarl exposure fix server exposure_version
|
||||
jarl exposure ack external security_open_resolver --reason "public resolver for the club"
|
||||
jarl view update external --exposure public # override the classification`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := newClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var r exposureReport
|
||||
if err := c.Get(ctx(), "/exposure", nil, &r); err != nil {
|
||||
return err
|
||||
}
|
||||
return output(r, func(w io.Writer) {
|
||||
fmt.Fprintln(w, "VIEW\tEXPOSURE\tRATE LIMIT\tWHY")
|
||||
for _, s := range r.Scopes {
|
||||
name, exp := s.View, "private"
|
||||
if name == "" {
|
||||
name = "(server)"
|
||||
}
|
||||
if s.Public {
|
||||
exp = "public"
|
||||
}
|
||||
if s.Override != "" {
|
||||
exp += " (set)"
|
||||
}
|
||||
rl := s.RateLimit
|
||||
if !s.Public && rl == "off" {
|
||||
rl = "-"
|
||||
}
|
||||
fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", name, exp, rl, s.Reason)
|
||||
}
|
||||
var all []exposureFinding
|
||||
for _, s := range r.Scopes {
|
||||
all = append(all, s.Findings...)
|
||||
}
|
||||
all = append(all, r.Server...)
|
||||
fmt.Fprintln(w)
|
||||
if len(all) == 0 {
|
||||
fmt.Fprintln(w, "No findings.")
|
||||
} else {
|
||||
fmt.Fprintln(w, "SEVERITY\tWHERE\tCODE\tFINDING")
|
||||
for _, f := range all {
|
||||
where := f.View
|
||||
if where == "" {
|
||||
where = "server"
|
||||
}
|
||||
fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", f.Severity, where, f.Code, f.Message)
|
||||
if f.Fix != "" {
|
||||
fmt.Fprintf(w, "\t\t\tfix: jarl exposure fix %s %s (%s)\n", where, f.Code, f.Fix)
|
||||
}
|
||||
}
|
||||
}
|
||||
if r.Mail != nil {
|
||||
fmt.Fprintln(w, "\nMAIL REVERSE DNS")
|
||||
printMail(w, r.Mail)
|
||||
}
|
||||
if a := r.RateLimiting; a != nil && len(a.Counts) > 0 {
|
||||
fmt.Fprintf(w, "\nRATE LIMITING, LAST %s\n", a.Since)
|
||||
for _, e := range a.Counts {
|
||||
fmt.Fprintf(w, "%s\t%d\n", e.Key, e.Count)
|
||||
}
|
||||
for _, e := range a.Networks {
|
||||
fmt.Fprintf(w, "network %s\t%d\n", e.Key, e.Count)
|
||||
}
|
||||
for _, e := range a.Names {
|
||||
fmt.Fprintf(w, "name %s\t%d\n", e.Key, e.Count)
|
||||
}
|
||||
}
|
||||
})
|
||||
},
|
||||
}
|
||||
fix := &cobra.Command{
|
||||
Use: "fix VIEW|server CODE",
|
||||
Short: "Stage (or apply) the fix Jarl suggests for a finding",
|
||||
Args: cobra.ExactArgs(2),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := newClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
body := exposureScope(args[0])
|
||||
body["code"] = args[1]
|
||||
resp, err := c.Mutate(ctx(), "POST", "/exposure/fix", nil, body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return report(resp)
|
||||
},
|
||||
}
|
||||
var reason string
|
||||
ack := &cobra.Command{
|
||||
Use: "ack VIEW|server CODE --reason TEXT",
|
||||
Short: "Accept a finding on purpose: it stays, as info, with who and why",
|
||||
Args: cobra.ExactArgs(2),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := newClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
body := exposureScope(args[0])
|
||||
body["code"], body["reason"] = args[1], reason
|
||||
resp, err := c.Mutate(ctx(), "POST", "/exposure/ack", nil, body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return report(resp)
|
||||
},
|
||||
}
|
||||
ack.Flags().StringVar(&reason, "reason", "", "why the finding is accepted (required)")
|
||||
unack := &cobra.Command{
|
||||
Use: "unack VIEW|server CODE",
|
||||
Short: "Take an acknowledgement back: the finding warns again",
|
||||
Args: cobra.ExactArgs(2),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := newClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
body := exposureScope(args[0])
|
||||
body["code"] = args[1]
|
||||
resp, err := c.Mutate(ctx(), "POST", "/exposure/unack", nil, body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return report(resp)
|
||||
},
|
||||
}
|
||||
mail := &cobra.Command{
|
||||
Use: "mail",
|
||||
Short: "Check the reverse DNS of the addresses that send and receive your mail, now",
|
||||
Long: `Finds the addresses of the public zones' MX hosts and SPF records and checks,
|
||||
through public resolvers, that each has a PTR record whose name resolves
|
||||
back to it, as Gmail and Outlook do. Exits 1 when one doesn't.`,
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := newClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var m mailReport
|
||||
if err := c.Do(ctx(), "POST", "/exposure/mail", nil, map[string]any{}, &m); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := output(m, func(w io.Writer) { printMail(w, &m) }); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, r := range m.Results {
|
||||
if !r.OK {
|
||||
return errors.New("some mail addresses have no working reverse DNS")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cmd.AddCommand(fix, ack, unack, mail)
|
||||
return cmd
|
||||
}
|
||||
+167
-23
@@ -3,15 +3,17 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/netip"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"text/tabwriter"
|
||||
@@ -22,6 +24,7 @@ import (
|
||||
|
||||
"jarl/internal/api"
|
||||
"jarl/internal/client"
|
||||
"jarl/internal/dnsname"
|
||||
"jarl/internal/version"
|
||||
)
|
||||
|
||||
@@ -46,6 +49,7 @@ var (
|
||||
flagChangeset int64
|
||||
flagDryRun bool
|
||||
flagForce bool
|
||||
flagRisk bool
|
||||
flagOutput string
|
||||
flagInsecure bool
|
||||
flagCACert string
|
||||
@@ -99,17 +103,30 @@ func saveCLIConfig(c *cliConfig) error {
|
||||
return os.WriteFile(flagConfig, b, 0o600)
|
||||
}
|
||||
|
||||
func newClient() (*client.Client, error) {
|
||||
// connection is the server a command talks to and how: Token may be
|
||||
// "@FILE" (read by open).
|
||||
type connection struct {
|
||||
Server string `json:"server"`
|
||||
Token string `json:"token,omitempty"`
|
||||
Insecure bool `json:"insecure,omitempty"`
|
||||
CACert string `json:"ca_cert,omitempty"`
|
||||
// Name is the named server's name ("" for one given by address).
|
||||
Name string `json:"-"`
|
||||
}
|
||||
|
||||
// resolveConnection works out the connection from the flags, the
|
||||
// environment and the CLI configuration.
|
||||
func resolveConnection() (connection, error) {
|
||||
cfg, err := loadCLIConfig()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("reading %s: %w", flagConfig, err)
|
||||
return connection{}, fmt.Errorf("reading %s: %w", flagConfig, err)
|
||||
}
|
||||
ts, err := resolveTargets(cfg, targetSpec(cfg))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return connection{}, err
|
||||
}
|
||||
if len(ts) > 1 {
|
||||
return nil, fmt.Errorf("--server names %d servers; this command works on one", len(ts))
|
||||
return connection{}, fmt.Errorf("--server names %d servers; this command works on one", len(ts))
|
||||
}
|
||||
t := ts[0]
|
||||
// A named server's own token wins: a token from the environment
|
||||
@@ -121,15 +138,21 @@ func newClient() (*client.Client, error) {
|
||||
sock, isSocket := client.SocketPath(t.URL)
|
||||
if isSocket {
|
||||
if err := checkSocket(sock, t.Name == localName && flagServer == "" && os.Getenv("JARL_SERVER") == ""); err != nil {
|
||||
return nil, err
|
||||
return connection{}, err
|
||||
}
|
||||
}
|
||||
if token == "" && !isSocket {
|
||||
if t.Name != "" {
|
||||
return nil, fmt.Errorf("no API token for server %s: use 'jarl --server %s config set token'", t.Name, t.Name)
|
||||
return connection{}, fmt.Errorf("no API token for server %s: use 'jarl --server %s config set token'", t.Name, t.Name)
|
||||
}
|
||||
return nil, fmt.Errorf("no API token: use --token, JARL_TOKEN or 'jarl config set token'")
|
||||
return connection{}, fmt.Errorf("no API token: use --token, JARL_TOKEN or 'jarl config set token'")
|
||||
}
|
||||
return connection{Server: t.URL, Token: token, Insecure: flagInsecure || t.Insecure, CACert: firstNonEmpty(flagCACert, t.CACert), Name: t.Name}, nil
|
||||
}
|
||||
|
||||
// open returns a client for the connection.
|
||||
func (cn connection) open() (*client.Client, error) {
|
||||
token := cn.Token
|
||||
if strings.HasPrefix(token, "@") {
|
||||
b, err := os.ReadFile(token[1:])
|
||||
if err != nil {
|
||||
@@ -137,7 +160,15 @@ func newClient() (*client.Client, error) {
|
||||
}
|
||||
token = strings.TrimSpace(string(b))
|
||||
}
|
||||
c, err := client.New(client.Options{Server: t.URL, Token: token, Insecure: flagInsecure || t.Insecure, CACert: firstNonEmpty(flagCACert, t.CACert)})
|
||||
return client.New(client.Options{Server: cn.Server, Token: token, Insecure: cn.Insecure, CACert: cn.CACert})
|
||||
}
|
||||
|
||||
func newClient() (*client.Client, error) {
|
||||
cn, err := resolveConnection()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c, err := cn.open()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -147,17 +178,23 @@ func newClient() (*client.Client, error) {
|
||||
}
|
||||
c.Changeset = flagChangeset
|
||||
if c.Changeset == 0 {
|
||||
if v := os.Getenv("JARL_CHANGESET"); v != "" {
|
||||
n, err := strconv.ParseInt(v, 10, 64)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid JARL_CHANGESET %q", v)
|
||||
}
|
||||
n, err := envChangeset()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if n > 0 {
|
||||
// Checked when a change is first staged into it
|
||||
// (envchangeset.go).
|
||||
c.Changeset = n
|
||||
c.CheckChangeset = func(context.Context) error {
|
||||
_, err := checkEnvChangeset(c, n, true)
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
c.DryRun, c.Force = flagDryRun || c.Preflight, flagForce
|
||||
c.DryRun, c.Force, c.AcceptRisk = flagDryRun || c.Preflight, flagForce, flagRisk
|
||||
if !c.Preflight {
|
||||
c.OnTokenExpiry = tokenExpiryWarning(t.Name)
|
||||
c.OnTokenExpiry = tokenExpiryWarning(cn.Name)
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
@@ -225,7 +262,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 +274,11 @@ 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.BoolVar(&flagRisk, "accept-risk", false, "confirm a change that opens a public view up, such as an open resolver (docs/exposure.md)")
|
||||
pf.StringVarP(&flagOutput, "output", "o", "table", "output format: table, json, yaml, id (just the IDs) or field=NAME[,NAME...] (just those fields, e.g. field=view or field=soa.serial)")
|
||||
pf.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,9 +286,9 @@ 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())
|
||||
add("admin", statusCmd(), exposureCmd(), certCmd(), encryptedDNSCmd(), whoamiCmd(), userCmd(), tokenCmd(), auditCmd(), checkCmd(), resyncCmd(), configCmd())
|
||||
setupCompletion(root)
|
||||
return root
|
||||
}
|
||||
@@ -262,7 +300,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 +332,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 +509,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 +566,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)
|
||||
}
|
||||
@@ -460,7 +598,13 @@ func q(kv ...string) url.Values {
|
||||
}
|
||||
|
||||
// zonePath returns the API path for a zone given by name or ID.
|
||||
// A network names its reverse zone: 10.0.10.0/24 is 10.0.10.in-addr.arpa.
|
||||
func zonePath(zone, view string) (string, url.Values) {
|
||||
if p, err := netip.ParsePrefix(strings.TrimSpace(zone)); err == nil {
|
||||
if name, err := dnsname.ReverseZone(p); err == nil {
|
||||
zone = name
|
||||
}
|
||||
}
|
||||
z := strings.TrimSuffix(zone, ".")
|
||||
if zone == "." {
|
||||
z = "root"
|
||||
|
||||
@@ -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,61 @@ 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestOptionLabel(t *testing.T) {
|
||||
for _, c := range []struct{ name, value, want string }{
|
||||
{"tls", `quad9 { remote-hostname "dns.quad9.net"; }`, "quad9"},
|
||||
{"tls", `Cloudflare{ remote-hostname "cloudflare-dns.com"; }`, "cloudflare"},
|
||||
{"key", `"xfer key" { algorithm hmac-sha256; secret "x"; }`, "xfer key"},
|
||||
{"server", "192.0.2.1 { keys { xfer; }; }", "192.0.2.1"},
|
||||
{"remote-servers", "secondaries { 192.0.2.53; }", "secondaries"},
|
||||
{"forwarders", "{ 9.9.9.9; }", ""},
|
||||
{"logging", "{ channel x { stderr; }; }", ""},
|
||||
{"tls", "", ""},
|
||||
} {
|
||||
if got := optionLabel(c.name, c.value); got != c.want {
|
||||
t.Errorf("optionLabel(%q, %q) = %q, want %q", c.name, c.value, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
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.
|
||||
// localFanOut is set by commands making local changes (--local), which
|
||||
// go to every view named even when the zones are linked.
|
||||
var localFanOut bool
|
||||
|
||||
func zoneViews(c *client.Client, zone, view string) ([]string, error) {
|
||||
if view != "all" && !strings.Contains(view, ",") {
|
||||
return []string{view}, nil
|
||||
}
|
||||
var zones []api.Zone
|
||||
if err := c.Get(ctx(), "/zones", nil, &zones); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
want := strings.ToLower(strings.TrimSuffix(zone, "."))
|
||||
link := map[string]int64{}
|
||||
var out []string
|
||||
for _, z := range zones {
|
||||
if strings.ToLower(strings.TrimSuffix(z.Display, ".")) == want && z.View != "" {
|
||||
link[z.View] = z.LinkGroup
|
||||
if view == "all" {
|
||||
out = append(out, z.View)
|
||||
}
|
||||
}
|
||||
}
|
||||
if view != "all" {
|
||||
for _, v := range strings.Split(view, ",") {
|
||||
if v = strings.TrimSpace(v); v != "" {
|
||||
out = append(out, v)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil, fmt.Errorf("zone %s is in no view", zone)
|
||||
}
|
||||
// Linked zones (docs/views.md#linked-zones) get a shared change once:
|
||||
// Jarl copies it to the others. A local change goes to each.
|
||||
if localFanOut {
|
||||
return out, nil
|
||||
}
|
||||
var dedup []string
|
||||
first := map[int64]string{}
|
||||
for _, v := range out {
|
||||
g := link[v]
|
||||
if g != 0 {
|
||||
if f, ok := first[g]; ok {
|
||||
if !flagQuiet {
|
||||
fmt.Fprintf(os.Stderr, "view %s: linked with %s, so it gets the change from there\n", v, f)
|
||||
}
|
||||
continue
|
||||
}
|
||||
first[g] = v
|
||||
}
|
||||
dedup = append(dedup, v)
|
||||
}
|
||||
return dedup, 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, "accept_risk": c.AcceptRisk}, &resp)
|
||||
if err != nil || c.DryRun {
|
||||
discard()
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return report(&resp)
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"jarl/internal/api"
|
||||
)
|
||||
|
||||
func TestPrintPTRFills(t *testing.T) {
|
||||
var b bytes.Buffer
|
||||
printPTRFills(&b, []api.PTRFill{{Address: "10.0.10.20", Target: "nextcloud.adbraun.lol.", From: "internal"}}, 3)
|
||||
out := b.String()
|
||||
if !strings.Contains(out, "10.0.10.20 -> nextcloud.adbraun.lol (view internal)") || !strings.Contains(out, "and 2 more") {
|
||||
t.Errorf("listed fills:\n%s", out)
|
||||
}
|
||||
|
||||
// An older server sends the count without the list.
|
||||
b.Reset()
|
||||
printPTRFills(&b, nil, 1)
|
||||
if b.Len() != 0 {
|
||||
t.Errorf("no list from the server printed %q, want nothing", b.String())
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
+150
-19
@@ -4,6 +4,8 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"net/url"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -20,8 +22,22 @@ func statsCmd() *cobra.Command {
|
||||
Long: `Query statistics collected from named (see docs/stats.md). They must be
|
||||
enabled in /etc/jarl/jarl.yaml (stats.enabled: true).
|
||||
|
||||
Ranges are given with --since: 90m, 24h, 7d, 2w. Use -o json or -o yaml to
|
||||
export the data.`,
|
||||
Ranges are given with --since: 90m, 24h, 7d, 2w. --view limits the figures
|
||||
to some views (repeat it, or separate with commas): answers from named's
|
||||
response log, per view; named's own counters stay for the whole server.
|
||||
Use -o json or -o yaml to export the data.`,
|
||||
}
|
||||
var statsViews []string
|
||||
cmd.PersistentFlags().StringSliceVar(&statsViews, "view", nil, "only these views (repeat, or comma separated)")
|
||||
// withViews adds the --view filter to a query.
|
||||
withViews := func(v url.Values) url.Values {
|
||||
if len(statsViews) > 0 {
|
||||
if v == nil {
|
||||
v = url.Values{}
|
||||
}
|
||||
v.Set("views", strings.Join(statsViews, ","))
|
||||
}
|
||||
return v
|
||||
}
|
||||
// Each command has its own --since variable: their defaults differ, and
|
||||
// a shared variable would take the last default for all of them.
|
||||
@@ -76,14 +92,27 @@ export the data.`,
|
||||
return err
|
||||
}
|
||||
var s stats.Summary
|
||||
if err := c.Get(ctx(), "/stats/summary", q("since", summarySince, "top", strconv.Itoa(topN)), &s); err != nil {
|
||||
if err := c.Get(ctx(), "/stats/summary", withViews(q("since", summarySince, "top", strconv.Itoa(topN))), &s); err != nil {
|
||||
return err
|
||||
}
|
||||
return output(s, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "Range:\t%s to %s\n", s.From.Local().Format("2006-01-02 15:04"), s.To.Local().Format("2006-01-02 15:04"))
|
||||
fmt.Fprintf(w, "Queries:\t%d (%.2f per second on average)\n", s.Queries, s.AvgQPS)
|
||||
if len(s.FilterViews) > 0 {
|
||||
since := ""
|
||||
if s.ViewsSince != nil {
|
||||
since = ", per view since " + s.ViewsSince.Local().Format("2006-01-02 15:04")
|
||||
}
|
||||
fmt.Fprintf(w, "Views:\t%s (from the response log%s; named's own counters are left out)\n", strings.Join(s.FilterViews, ", "), since)
|
||||
fmt.Fprintf(w, "Answers:\t%d (%.2f per second on average)\n", s.Queries, s.AvgQPS)
|
||||
} else {
|
||||
fmt.Fprintf(w, "Queries:\t%d (%.2f per second on average)\n", s.Queries, s.AvgQPS)
|
||||
}
|
||||
if s.Peak != nil {
|
||||
fmt.Fprintf(w, "Busiest %s:\t%s with %d queries\n", s.Peak.Per, s.Peak.Time.Local().Format("2006-01-02 15:04"), s.Peak.Queries)
|
||||
what := "queries"
|
||||
if len(s.FilterViews) > 0 {
|
||||
what = "answers"
|
||||
}
|
||||
fmt.Fprintf(w, "Busiest %s:\t%s with %d %s\n", s.Peak.Per, s.Peak.Time.Local().Format("2006-01-02 15:04"), s.Peak.Queries, what)
|
||||
}
|
||||
if s.CacheHitRatio != nil {
|
||||
fmt.Fprintf(w, "Cache hit ratio:\t%.1f%%\n", *s.CacheHitRatio*100)
|
||||
@@ -91,6 +120,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 +134,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 +156,15 @@ 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|ratelimited|ratelimited-names]",
|
||||
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
|
||||
jarl stats top ratelimited # client networks rate limiting limits, or would (docs/exposure.md)`,
|
||||
Args: cobra.MaximumNArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
kind := stats.KindName
|
||||
@@ -134,13 +173,19 @@ export the data.`,
|
||||
if kind == "nxdomain" || kind == "servfail" {
|
||||
kind = strings.ToLower(args[0])
|
||||
}
|
||||
if kind == "blocked-client" {
|
||||
kind = stats.KindBlockedClient
|
||||
}
|
||||
if kind == "ratelimited-name" {
|
||||
kind = stats.KindRateLimitedName
|
||||
}
|
||||
}
|
||||
c, err := newClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var es []stats.Entry
|
||||
if err := c.Get(ctx(), "/stats/top", q("kind", kind, "since", topSince, "limit", strconv.Itoa(limit)), &es); err != nil {
|
||||
if err := c.Get(ctx(), "/stats/top", withViews(q("kind", kind, "since", topSince, "limit", strconv.Itoa(limit))), &es); err != nil {
|
||||
return err
|
||||
}
|
||||
return output(es, func(w io.Writer) {
|
||||
@@ -148,13 +193,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))
|
||||
}
|
||||
}
|
||||
})
|
||||
},
|
||||
@@ -181,7 +235,7 @@ export the data.`,
|
||||
qv.Add("key", k)
|
||||
}
|
||||
var tl stats.Timeline
|
||||
if err := c.Get(ctx(), "/stats/timeline", qv, &tl); err != nil {
|
||||
if err := c.Get(ctx(), "/stats/timeline", withViews(qv), &tl); err != nil {
|
||||
return err
|
||||
}
|
||||
return output(tl, func(w io.Writer) {
|
||||
@@ -230,7 +284,7 @@ export the data.`,
|
||||
return err
|
||||
}
|
||||
var hm stats.Heatmap
|
||||
if err := c.Get(ctx(), "/stats/heatmap", q("since", heatmapSince, "key", hmKey), &hm); err != nil {
|
||||
if err := c.Get(ctx(), "/stats/heatmap", withViews(q("since", heatmapSince, "key", hmKey)), &hm); err != nil {
|
||||
return err
|
||||
}
|
||||
return output(hm, func(w io.Writer) {
|
||||
@@ -282,7 +336,7 @@ export the data.`,
|
||||
return err
|
||||
}
|
||||
var es []stats.Entry
|
||||
if err := c.Get(ctx(), "/stats/breakdown", q("group", args[0], "since", breakdownSince), &es); err != nil {
|
||||
if err := c.Get(ctx(), "/stats/breakdown", withViews(q("group", args[0], "since", breakdownSince)), &es); err != nil {
|
||||
return err
|
||||
}
|
||||
return output(es, func(w io.Writer) {
|
||||
@@ -303,7 +357,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 +484,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")
|
||||
}
|
||||
+785
-33
@@ -3,12 +3,15 @@ package main
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"golang.org/x/term"
|
||||
"io"
|
||||
"net/netip"
|
||||
"net/url"
|
||||
"os"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
@@ -43,7 +46,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,15 +78,44 @@ 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)
|
||||
}
|
||||
if z.AutoPTR {
|
||||
fmt.Fprintf(w, "PTR records:\tkept in step with the A and AAAA records\n")
|
||||
}
|
||||
if z.Prefix != "" {
|
||||
fmt.Fprintf(w, "Network:\t%s\n", z.Prefix)
|
||||
if z.PTRMissing > 0 {
|
||||
fmt.Fprintf(w, "Missing PTRs:\t%d (addresses the view's records use here; add them: jarl zone fill-ptr %s)\n", z.PTRMissing, args[0])
|
||||
printPTRFills(w, z.PTRMissingList, z.PTRMissing)
|
||||
}
|
||||
if z.PTROtherViews > 0 {
|
||||
what := "addresses"
|
||||
if z.PTROtherViews == 1 {
|
||||
what = "address"
|
||||
}
|
||||
fmt.Fprintf(w, "Other views:\t%d %s with A or AAAA records only in %s, so not missing here (add PTRs for them in this view too: jarl zone fill-ptr %s --other-views)\n",
|
||||
z.PTROtherViews, what, fromViews(z.PTROtherViewsList), args[0])
|
||||
printPTRFills(w, z.PTROtherViewsList, z.PTROtherViews)
|
||||
}
|
||||
}
|
||||
if len(z.LinkedWith) > 0 {
|
||||
fmt.Fprintf(w, "Linked with:\t%s (shared records are kept in step; 'jarl record list' shows which are local)\n", strings.Join(z.LinkedWith, ", "))
|
||||
}
|
||||
if z.ZoneFile != "" {
|
||||
fmt.Fprintf(w, "File:\t%s\n", z.ZoneFile)
|
||||
}
|
||||
@@ -92,22 +128,28 @@ func zoneCmd() *cobra.Command {
|
||||
show.Flags().StringVar(&view, "view", "", "view of the zone (when the name exists in several views)")
|
||||
|
||||
var ns, records, primaries, forwarders, opts []string
|
||||
var contact, comment, cloneFrom, file, inView, forward string
|
||||
var contact, comment, cloneFrom, file, inView, forward, linkWith string
|
||||
var ttl uint32
|
||||
var fill, fillOther bool
|
||||
create := &cobra.Command{
|
||||
Use: "create NAME",
|
||||
Short: "Create a zone",
|
||||
Long: `Create a zone. NAME may be a network (192.168.1.0/24, 2001:db8::/48) to
|
||||
create its reverse zone: a /32 (or /128) gives a zone for that one address,
|
||||
and a network between octet boundaries, like a /22, gets one zone per /24
|
||||
(created together).
|
||||
(created together). Without --view, a reverse zone goes to the private
|
||||
view whose records use its addresses; --fill adds a PTR record for each
|
||||
of them, and --other-views also for the addresses only other views'
|
||||
records use, named from there (docs/reverse.md).
|
||||
|
||||
jarl zone create example.com --ns ns1.example.com. --record "ns1 A 192.0.2.1"
|
||||
jarl zone create 192.168.1.0/24
|
||||
jarl zone create 10.0.8.0/22 # 8-11.0.10.in-addr.arpa
|
||||
jarl zone create 10.0.10.0/24 --fill # with PTR records for the view's A records there
|
||||
jarl zone create partner.example --type secondary --primary 192.0.2.53
|
||||
jarl zone create consul --type forward --forwarder "127.0.0.1 port 8600"
|
||||
jarl zone create example.com --view external --clone-from example.com --clone-view internal`,
|
||||
jarl zone create example.com --view external --clone-from example.com --clone-view internal
|
||||
jarl zone create example.com --view vps --link-with external # linked: shared records kept in step`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := newClient()
|
||||
@@ -136,6 +178,15 @@ and a network between octet boundaries, like a /22, gets one zone per /24
|
||||
if inView != "" {
|
||||
body["in_view"] = inView
|
||||
}
|
||||
if linkWith != "" {
|
||||
body["link_with"] = linkWith
|
||||
}
|
||||
if fill || fillOther {
|
||||
body["fill"] = true
|
||||
}
|
||||
if fillOther {
|
||||
body["fill_other_views"] = true
|
||||
}
|
||||
var recs []map[string]any
|
||||
for _, r := range records {
|
||||
f := strings.Fields(r)
|
||||
@@ -206,10 +257,14 @@ and a network between octet boundaries, like a /22, gets one zone per /24
|
||||
create.Flags().StringVar(&cloneView, "clone-view", "", "view of --clone-from")
|
||||
create.Flags().StringVar(&file, "file", "", "zone file (hint and redirect zones)")
|
||||
create.Flags().StringVar(&inView, "in-view", "", "for in-view zones: the view to share the zone from")
|
||||
create.Flags().StringVar(&linkWith, "link-with", "", "link with the zone of the same name in this view: copy its shared records and keep them in step (docs/views.md#linked-zones)")
|
||||
create.Flags().StringVar(&comment, "comment", "", "comment")
|
||||
create.Flags().BoolVar(&fill, "fill", false, "reverse zones: add PTR records for the view's A and AAAA records in the network")
|
||||
create.Flags().BoolVar(&fillOther, "other-views", false, "reverse zones: --fill, also naming the addresses only other views' records use")
|
||||
|
||||
var refresh, retry, expire, minimum, serial uint32
|
||||
var mname, policy, moveView string
|
||||
var autoPTR bool
|
||||
update := &cobra.Command{
|
||||
Use: "update ZONE",
|
||||
Short: "Change a zone's settings (SOA, TTL, view, comment)",
|
||||
@@ -251,6 +306,9 @@ and a network between octet boundaries, like a /22, gets one zone per /24
|
||||
if f.Changed("file") {
|
||||
body["file"] = file
|
||||
}
|
||||
if f.Changed("auto-ptr") {
|
||||
body["auto_ptr"] = autoPTR
|
||||
}
|
||||
if len(body) == 0 {
|
||||
return fmt.Errorf("nothing to change (see --help)")
|
||||
}
|
||||
@@ -275,6 +333,7 @@ and a network between octet boundaries, like a /22, gets one zone per /24
|
||||
update.Flags().StringVar(&comment, "comment", "", "comment")
|
||||
update.Flags().StringVar(&moveView, "move-to-view", "", "move the zone to this view")
|
||||
update.Flags().StringVar(&file, "file", "", "zone file (secondary, hint and redirect zones)")
|
||||
update.Flags().BoolVar(&autoPTR, "auto-ptr", false, "keep the PTR records of the zone's A and AAAA records in step (--auto-ptr=false: stop)")
|
||||
|
||||
var yes bool
|
||||
del := &cobra.Command{
|
||||
@@ -300,6 +359,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 +462,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(), zoneLinkCmd(true), zoneLinkCmd(false), zoneDiffCmd(), zoneFillPTRCmd(), zoneSuggestReverseCmd())
|
||||
return cmd
|
||||
}
|
||||
|
||||
@@ -400,11 +495,32 @@ 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, local, shared bool
|
||||
var id int64
|
||||
// linkFlags adds --local and --shared (linked zones) to a command.
|
||||
linkFlags := func(c *cobra.Command, what string) {
|
||||
c.Flags().BoolVar(&local, "local", false, "linked zones: "+what+" in this view only (overriding the shared records of this name and type here)")
|
||||
c.Flags().BoolVar(&shared, "shared", false, "linked zones: "+what+" in every linked view (this version becomes every view's)")
|
||||
}
|
||||
linkBody := func(body map[string]any) error {
|
||||
switch {
|
||||
case local && shared:
|
||||
return fmt.Errorf("give --local or --shared, not both")
|
||||
case local:
|
||||
body["local"] = true
|
||||
localFanOut = true
|
||||
case shared:
|
||||
body["local"] = false
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
list := &cobra.Command{
|
||||
Use: "list ZONE",
|
||||
@@ -419,18 +535,46 @@ 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
|
||||
}
|
||||
linked := false
|
||||
for _, r := range recs {
|
||||
linked = linked || r.Linked != ""
|
||||
}
|
||||
return output(recs, func(w io.Writer) {
|
||||
fmt.Fprintln(w, "ID\tNAME\tTTL\tTYPE\tDATA\tCOMMENT")
|
||||
rows := [][]string{{"ID", "NAME", "TTL", "TYPE", "DATA", "COMMENT"}}
|
||||
if linked {
|
||||
rows[0] = []string{"ID", "NAME", "TTL", "TYPE", "LINK", "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)
|
||||
}
|
||||
if linked {
|
||||
rows = append(rows, []string{fmt.Sprint(r.ID), r.RelativeName, ttl, r.Type, r.Linked, r.Data, comment})
|
||||
continue
|
||||
}
|
||||
rows = append(rows, []string{fmt.Sprint(r.ID), r.RelativeName, ttl, r.Type, r.Data, comment})
|
||||
}
|
||||
if !wide {
|
||||
col := 4
|
||||
if linked {
|
||||
col = 5
|
||||
}
|
||||
fitColumn(rows, col)
|
||||
}
|
||||
for _, row := range rows {
|
||||
fmt.Fprintln(w, strings.Join(row, "\t"))
|
||||
}
|
||||
})
|
||||
},
|
||||
@@ -439,6 +583,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,18 +613,24 @@ 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)
|
||||
if err := linkBody(body); err != nil {
|
||||
return err
|
||||
}
|
||||
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)")
|
||||
linkFlags(add, "add it")
|
||||
|
||||
var data string
|
||||
update := &cobra.Command{
|
||||
@@ -512,6 +664,9 @@ and splitting are automatic).
|
||||
if ptr {
|
||||
body["ptr"] = true
|
||||
}
|
||||
if err := linkBody(body); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(body) == 0 {
|
||||
return fmt.Errorf("nothing to change (see --help)")
|
||||
}
|
||||
@@ -528,6 +683,8 @@ and splitting are automatic).
|
||||
update.Flags().StringVar(&ttlArg, "ttl", "", `new TTL, or "default" for the zone default`)
|
||||
update.Flags().StringVar(&comment, "comment", "", "new comment")
|
||||
update.Flags().BoolVar(&ptr, "ptr", false, "move the PTR record along (A/AAAA)")
|
||||
update.Flags().BoolVar(&local, "local", false, "linked zones: make this name and type local to the record's view (an override)")
|
||||
update.Flags().BoolVar(&shared, "shared", false, "linked zones: share this name and type with the linked views; this view's version becomes every view's")
|
||||
|
||||
del := &cobra.Command{
|
||||
Use: "delete ZONE NAME TYPE [DATA...] | delete --id ID",
|
||||
@@ -560,26 +717,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 +765,108 @@ 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)
|
||||
if err := linkBody(body); err != nil {
|
||||
return err
|
||||
}
|
||||
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")
|
||||
linkFlags(set, "set it")
|
||||
|
||||
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 +884,507 @@ 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]) + "…"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// zoneLinkCmd is 'zone link' and 'zone unlink' (docs/views.md#linked-zones).
|
||||
func zoneLinkCmd(link bool) *cobra.Command {
|
||||
var view string
|
||||
var with []string
|
||||
cmd := &cobra.Command{
|
||||
Use: "link ZONE --view VIEW [--with VIEW...]",
|
||||
Short: "Link zones of the same name in several views: shared records are kept in step",
|
||||
Long: `Links a zone with the zones of the same name in other views (--with, or
|
||||
every view that has one). Jarl then keeps their shared records in step: a
|
||||
change to one reaches the others in the same changeset. Each view can
|
||||
still have records of its own (record add/set --local), which override
|
||||
the shared records of that name and type there, and every zone keeps its
|
||||
own options, so views can still differ in who they answer and how.
|
||||
|
||||
Linking never changes what named answers: a name and type with the same
|
||||
records in every view becomes shared, and one that differs stays local to
|
||||
each view. Share one later from the view whose version is right:
|
||||
jarl record update ID --shared.`,
|
||||
Example: ` jarl zone link adbraun.lol --view external --with vps
|
||||
jarl zone create adbraun.lol --view vps --link-with external # a new zone, linked from the start
|
||||
jarl zone unlink adbraun.lol --view vps`,
|
||||
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)
|
||||
verb := "unlink"
|
||||
var body any
|
||||
if link {
|
||||
verb = "link"
|
||||
body = map[string]any{"with": with}
|
||||
}
|
||||
resp, err := c.Mutate(ctx(), "POST", path+"/"+verb, qv, body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return report(resp)
|
||||
},
|
||||
}
|
||||
cmd.Flags().StringVar(&view, "view", "", "view of the zone")
|
||||
if link {
|
||||
cmd.Flags().StringSliceVar(&with, "with", nil, "views to link with (default: every view with a zone of that name)")
|
||||
return cmd
|
||||
}
|
||||
cmd.Use = "unlink ZONE --view VIEW"
|
||||
cmd.Short = "Take a zone out of its link: its records stay, all local"
|
||||
cmd.Long = `Takes a zone out of its link. Its records stay as they are, all local to
|
||||
its view, and the other zones of the link keep theirs. A link left with one
|
||||
zone is dissolved.`
|
||||
cmd.Example = " jarl zone unlink adbraun.lol --view vps"
|
||||
return cmd
|
||||
}
|
||||
|
||||
// zoneDiffCmd is 'zone diff': a zone's records side by side in the views
|
||||
// that have it.
|
||||
type compareSet struct {
|
||||
View string `json:"view"`
|
||||
Records []string `json:"records"`
|
||||
IDs []int64 `json:"ids"`
|
||||
Linked string `json:"linked"`
|
||||
}
|
||||
|
||||
type compareRow struct {
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
Views []compareSet `json:"views"`
|
||||
}
|
||||
|
||||
type compareReport struct {
|
||||
Zone string `json:"zone"`
|
||||
Base string `json:"base"`
|
||||
Views []string `json:"views"`
|
||||
Linked bool `json:"linked"`
|
||||
Summary []struct {
|
||||
View string `json:"view"`
|
||||
Different int `json:"different"`
|
||||
OnlyBase int `json:"only_base"`
|
||||
OnlyOther int `json:"only_other"`
|
||||
Same int `json:"same"`
|
||||
} `json:"summary"`
|
||||
Different []compareRow `json:"different"`
|
||||
Only []struct {
|
||||
Views []string `json:"views"`
|
||||
Rows []compareRow `json:"rows"`
|
||||
} `json:"only"`
|
||||
Same int `json:"same"`
|
||||
SameRows []compareRow `json:"same_rows"`
|
||||
}
|
||||
|
||||
func zoneDiffCmd() *cobra.Command {
|
||||
var view string
|
||||
var with []string
|
||||
var all, wide bool
|
||||
cmd := &cobra.Command{
|
||||
Use: "diff ZONE [NAME [TYPE]] [--view V] [--with W...] [--all] [--wide]",
|
||||
Short: "Compare a zone with the same zone in other views",
|
||||
Long: `Compares a zone in one view (--view, default the first view that has it)
|
||||
with the same zone in the other views (--with, default all of them): how
|
||||
many names each answers differently, the names and types only some views
|
||||
have, and how many are the same. NAME (and TYPE) narrows it to one name,
|
||||
the same records included. One line per record; long values are cut to
|
||||
fit the terminal (--wide shows them whole).
|
||||
|
||||
In a linked zone a difference is a view's own (local) records, on
|
||||
purpose; between zones that aren't linked it is drift. 'jarl record
|
||||
update ID --shared' makes a linked view's version every view's.`,
|
||||
Example: ` jarl zone diff adbraun.lol
|
||||
jarl zone diff adbraun.lol --view internal --with external
|
||||
jarl zone diff adbraun.lol nas
|
||||
jarl zone diff adbraun.lol @ TXT --wide`,
|
||||
Args: cobra.RangeArgs(1, 3),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := newClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
path, qv := zonePath(args[0], view)
|
||||
if len(with) > 0 {
|
||||
qv.Set("with", strings.Join(with, ","))
|
||||
}
|
||||
if len(args) > 1 {
|
||||
qv.Set("name", args[1])
|
||||
}
|
||||
if len(args) > 2 {
|
||||
qv.Set("type", args[2])
|
||||
}
|
||||
if all {
|
||||
qv.Set("all", "1")
|
||||
}
|
||||
var r compareReport
|
||||
if err := c.Get(ctx(), path+"/compare", qv, &r); err != nil {
|
||||
return err
|
||||
}
|
||||
width := 0
|
||||
if !wide {
|
||||
width = terminalWidth()
|
||||
}
|
||||
return output(r, func(w io.Writer) { printCompare(w, &r, len(args) > 1, width) })
|
||||
},
|
||||
}
|
||||
cmd.Flags().StringVar(&view, "view", "", "the view to compare from (default: the first that has the zone)")
|
||||
cmd.Flags().StringArrayVar(&with, "with", nil, "a view to compare with (repeat; default: every other view with the zone)")
|
||||
cmd.Flags().BoolVar(&all, "all", false, "also list what is the same")
|
||||
cmd.Flags().BoolVarP(&wide, "wide", "w", false, "show long values whole instead of cutting them to the terminal")
|
||||
return cmd
|
||||
}
|
||||
|
||||
// printCompare prints a comparison as sections, one line per record,
|
||||
// each cut to width characters (0: whole).
|
||||
func printCompare(w io.Writer, r *compareReport, oneName bool, width int) {
|
||||
vw := 0 // the views column
|
||||
for _, v := range r.Views {
|
||||
vw = max(vw, len(v))
|
||||
}
|
||||
line := func(indent, label, text string) {
|
||||
s := indent + label + text
|
||||
if width > 0 && utf8.RuneCountInString(s) > width {
|
||||
keep := max(width-utf8.RuneCountInString(indent+label)-1, 8)
|
||||
text = string([]rune(text)[:min(keep, utf8.RuneCountInString(text))]) + "…"
|
||||
}
|
||||
fmt.Fprintln(w, indent+label+text)
|
||||
}
|
||||
link := "not linked, so a difference may have crept in ('jarl zone link " + r.Zone + "' keeps them in step)"
|
||||
if r.Linked {
|
||||
link = "linked: a difference is a view's own records, there on purpose"
|
||||
}
|
||||
fmt.Fprintf(w, "%s in %s, compared with %s\n %s\n", r.Zone, r.Base, strings.Join(r.Views[1:], ", "), link)
|
||||
if !oneName {
|
||||
for _, s := range r.Summary {
|
||||
fmt.Fprintf(w, " %s: %d answered differently, %d only in %s, %d only in %s, %d the same\n", s.View, s.Different, s.OnlyBase, r.Base, s.OnlyOther, s.View, s.Same)
|
||||
}
|
||||
}
|
||||
name := func(row compareRow) string { return row.Name + " " + row.Type }
|
||||
sets := func(row compareRow, local bool) {
|
||||
for _, vs := range row.Views {
|
||||
label := fmt.Sprintf(" %-*s ", vw, vs.View)
|
||||
if len(vs.Records) == 0 {
|
||||
line("", label, "-")
|
||||
continue
|
||||
}
|
||||
for i, d := range vs.Records {
|
||||
if i > 0 {
|
||||
label = strings.Repeat(" ", 4+vw+2)
|
||||
}
|
||||
suffix := ""
|
||||
if local && vs.Linked == "local" && i == 0 {
|
||||
suffix = " [local, id " + fmt.Sprint(vs.IDs[0]) + "]"
|
||||
}
|
||||
line("", label, d+suffix)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(r.Different) > 0 {
|
||||
fmt.Fprintf(w, "\nANSWERED DIFFERENTLY (%d)\n", len(r.Different))
|
||||
for _, row := range r.Different {
|
||||
fmt.Fprintln(w, " "+name(row))
|
||||
sets(row, true)
|
||||
}
|
||||
}
|
||||
for _, o := range r.Only {
|
||||
fmt.Fprintf(w, "\nONLY IN %s (%d)\n", strings.ToUpper(strings.Join(o.Views, ", ")), len(o.Rows))
|
||||
nw := 0
|
||||
for _, row := range o.Rows {
|
||||
nw = max(nw, len(name(row)))
|
||||
}
|
||||
for _, row := range o.Rows {
|
||||
var recs []string
|
||||
for _, vs := range row.Views {
|
||||
if len(vs.Records) > 0 {
|
||||
recs = vs.Records
|
||||
break
|
||||
}
|
||||
}
|
||||
for i, d := range recs {
|
||||
label := fmt.Sprintf(" %-*s ", nw, name(row))
|
||||
if i > 0 {
|
||||
label = strings.Repeat(" ", 2+nw+2)
|
||||
}
|
||||
line("", label, d)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(r.SameRows) > 0 {
|
||||
fmt.Fprintf(w, "\nTHE SAME IN %s (%d)\n", strings.ToUpper(strings.Join(r.Views, ", ")), len(r.SameRows))
|
||||
nw := 0
|
||||
for _, row := range r.SameRows {
|
||||
nw = max(nw, len(name(row)))
|
||||
}
|
||||
for _, row := range r.SameRows {
|
||||
for i, d := range row.Views[0].Records {
|
||||
label := fmt.Sprintf(" %-*s ", nw, name(row))
|
||||
if i > 0 {
|
||||
label = strings.Repeat(" ", 2+nw+2)
|
||||
}
|
||||
line("", label, d)
|
||||
}
|
||||
}
|
||||
} else if r.Same > 0 && !oneName {
|
||||
fmt.Fprintf(w, "\n%d names and types are the same (--all lists them)\n", r.Same)
|
||||
}
|
||||
if oneName && len(r.Different) == 0 && len(r.Only) == 0 && len(r.SameRows) == 0 {
|
||||
fmt.Fprintln(w, "\nNo records of that name in these views.")
|
||||
}
|
||||
}
|
||||
|
||||
// terminalWidth returns the terminal's width, or 0 when standard output
|
||||
// isn't a terminal (then nothing is cut).
|
||||
func terminalWidth() int {
|
||||
fd := int(os.Stdout.Fd())
|
||||
if !term.IsTerminal(fd) {
|
||||
return 0
|
||||
}
|
||||
width, _, err := term.GetSize(fd)
|
||||
if err != nil || width <= 0 {
|
||||
return 0
|
||||
}
|
||||
return width
|
||||
}
|
||||
|
||||
// printPTRFills lists PTR records a reverse zone lacks under zone show's
|
||||
// line about them: address -> name, and the view the name is from. A server
|
||||
// older than the CLI may send the count without the list; then nothing is
|
||||
// listed, rather than "and N more" under no addresses.
|
||||
func printPTRFills(w io.Writer, fills []api.PTRFill, total int) {
|
||||
if len(fills) == 0 {
|
||||
return
|
||||
}
|
||||
for _, f := range fills {
|
||||
from := ""
|
||||
if f.From != "" {
|
||||
from = " (view " + f.From + ")"
|
||||
}
|
||||
fmt.Fprintf(w, "\t %s -> %s%s\n", f.Address, dnsname.Display(f.Target), from)
|
||||
}
|
||||
if total > len(fills) {
|
||||
fmt.Fprintf(w, "\t and %d more\n", total-len(fills))
|
||||
}
|
||||
}
|
||||
|
||||
// fromViews names the views PTR fills take their names from: "view
|
||||
// internal", "views internal and lab".
|
||||
func fromViews(fills []api.PTRFill) string {
|
||||
var vs []string
|
||||
for _, f := range fills {
|
||||
if f.From != "" && !slices.Contains(vs, f.From) {
|
||||
vs = append(vs, f.From)
|
||||
}
|
||||
}
|
||||
switch len(vs) {
|
||||
case 0:
|
||||
return "other views"
|
||||
case 1:
|
||||
return "view " + vs[0]
|
||||
}
|
||||
return "views " + strings.Join(vs[:len(vs)-1], ", ") + " and " + vs[len(vs)-1]
|
||||
}
|
||||
|
||||
func zoneFillPTRCmd() *cobra.Command {
|
||||
var view string
|
||||
var otherViews bool
|
||||
cmd := &cobra.Command{
|
||||
Use: "fill-ptr ZONE [--view VIEW] [--other-views]",
|
||||
Short: "Add the PTR records a reverse zone lacks for its view's A and AAAA records",
|
||||
Long: `Adds a PTR record to a reverse zone for every address in its network that
|
||||
the view's A and AAAA records use and that has none yet. An address with
|
||||
several names gets the mail or name server's name, else a host's rather
|
||||
than the domain's, else the shortest (docs/reverse.md).
|
||||
|
||||
--other-views also adds the addresses in the network that only other
|
||||
views' records use, naming them from there. That publishes those views'
|
||||
host names in this one, so it is never done unasked.`,
|
||||
Example: ` jarl zone fill-ptr 10.0.10.0/24
|
||||
jarl zone fill-ptr 10.0.10.in-addr.arpa --view internal
|
||||
jarl zone fill-ptr 10.0.10.0/24 --view vps --other-views`,
|
||||
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 otherViews {
|
||||
qv.Set("other_views", "1")
|
||||
}
|
||||
resp, err := c.Mutate(ctx(), "POST", path+"/fill-ptr", qv, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return report(resp)
|
||||
},
|
||||
}
|
||||
cmd.Flags().StringVar(&view, "view", "", "view of the zone")
|
||||
cmd.Flags().BoolVar(&otherViews, "other-views", false, "also name the addresses only other views' records use")
|
||||
return cmd
|
||||
}
|
||||
|
||||
type reverseSuggestion struct {
|
||||
ViewID int64 `json:"view_id"`
|
||||
View string `json:"view"`
|
||||
Prefix string `json:"prefix"`
|
||||
Zone string `json:"zone"`
|
||||
Group string `json:"group"`
|
||||
Hidden string `json:"hidden"`
|
||||
Why string `json:"why"`
|
||||
Fill int `json:"fill"`
|
||||
Samples []string `json:"samples"`
|
||||
}
|
||||
|
||||
func zoneSuggestReverseCmd() *cobra.Command {
|
||||
var create, all bool
|
||||
var dismiss, undismiss []string
|
||||
var view string
|
||||
cmd := &cobra.Command{
|
||||
Use: "suggest-reverse [--create] [--dismiss NETWORK --view V]",
|
||||
Short: "The reverse zones your private views lack for their records' addresses",
|
||||
Long: `Lists the reverse zones the private views (or the server, without views)
|
||||
lack for the addresses of their A and AAAA records, in two groups:
|
||||
|
||||
yours the /24 of a private IPv4 network, the /48 of a ULA or of an IPv6
|
||||
prefix this server has an address in
|
||||
public a /32 (or /128) for any other public address: create it only if
|
||||
the address is yours, not a web host's
|
||||
|
||||
--create creates the ones that are yours, filled with PTR records, in one
|
||||
changeset; create a public one with 'jarl zone create NETWORK --view V
|
||||
--fill'. --dismiss hides a suggestion for good (--undismiss shows it
|
||||
again). A view can stop suggestions: jarl view update V
|
||||
--reverse-suggestions=false (docs/reverse.md).
|
||||
|
||||
--all lists every network the views' records use without a reverse zone,
|
||||
also where nothing is suggested unasked (a view with suggestions off, a
|
||||
dismissed network, a public view), to create one by hand; --view narrows
|
||||
the list to one view and adds the networks only other views' records use
|
||||
(hidden "records in VIEW"): create one with --fill --other-views to name
|
||||
them from there. Change the size as you need: a /48 suggested for
|
||||
IPv6 can be created as a /56 or /64 instead.`,
|
||||
Example: ` jarl zone suggest-reverse
|
||||
jarl zone suggest-reverse --create
|
||||
jarl zone create 139.144.144.154/32 --view internal --fill
|
||||
jarl zone suggest-reverse --dismiss 185.199.108.153/32 --view internal
|
||||
jarl zone suggest-reverse --all --view vps
|
||||
jarl zone create 2a02:8013:7f3d::/48 --view internal --fill`,
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := newClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(dismiss) > 0 || len(undismiss) > 0 {
|
||||
path, prefixes := "/reverse-zones/dismiss", dismiss
|
||||
if len(undismiss) > 0 {
|
||||
path, prefixes = "/reverse-zones/undismiss", undismiss
|
||||
}
|
||||
resp, err := c.Mutate(ctx(), "POST", path, nil, map[string]any{"view": view, "prefixes": prefixes})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return report(resp)
|
||||
}
|
||||
if create && (all || view != "") {
|
||||
return fmt.Errorf("--create creates the suggestions that are yours; create others one by one: jarl zone create NETWORK --view VIEW --fill")
|
||||
}
|
||||
qv := q("view", view)
|
||||
if all {
|
||||
qv.Set("all", "1")
|
||||
}
|
||||
var sugg []reverseSuggestion
|
||||
if err := c.Get(ctx(), "/reverse-zones/suggestions", qv, &sugg); err != nil {
|
||||
return err
|
||||
}
|
||||
if !create {
|
||||
return output(sugg, func(w io.Writer) {
|
||||
if len(sugg) == 0 {
|
||||
fmt.Fprintln(w, "No reverse zones missing.")
|
||||
return
|
||||
}
|
||||
head := "GROUP\tVIEW\tNETWORK\tZONE\tPTRS\tFOR EXAMPLE"
|
||||
if all {
|
||||
head = "GROUP\tVIEW\tNETWORK\tZONE\tPTRS\tHIDDEN\tFOR EXAMPLE"
|
||||
}
|
||||
fmt.Fprintln(w, head)
|
||||
for _, s := range sugg {
|
||||
v := s.View
|
||||
if v == "" {
|
||||
v = "-"
|
||||
}
|
||||
cols := []string{s.Group, v, s.Prefix, s.Zone, fmt.Sprint(s.Fill)}
|
||||
if all {
|
||||
cols = append(cols, orDash(s.Hidden))
|
||||
}
|
||||
cols = append(cols, strings.Join(s.Samples, ", "))
|
||||
fmt.Fprintln(w, strings.Join(cols, "\t"))
|
||||
}
|
||||
fmt.Fprintln(w, "\nCreate yours, filled: jarl zone suggest-reverse --create. A public one only if the address is yours:\njarl zone create NETWORK --view VIEW --fill (or hide it: --dismiss NETWORK --view VIEW).")
|
||||
})
|
||||
}
|
||||
var reqs []api.ChangeRequest
|
||||
for _, s := range sugg {
|
||||
if s.Group != "yours" {
|
||||
continue
|
||||
}
|
||||
body := map[string]any{"name": s.Prefix, "fill": true}
|
||||
if s.ViewID != 0 {
|
||||
body["view_id"] = s.ViewID
|
||||
}
|
||||
data, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
reqs = append(reqs, api.ChangeRequest{Op: "zone.create", Data: data})
|
||||
}
|
||||
if len(reqs) == 0 {
|
||||
fmt.Println("No reverse zones of yours missing (public addresses are created one by one: see 'jarl zone suggest-reverse').")
|
||||
return nil
|
||||
}
|
||||
return submitBatch(c, fmt.Sprintf("%d suggested reverse zones", len(reqs)), reqs)
|
||||
},
|
||||
}
|
||||
cmd.Flags().BoolVar(&create, "create", false, "create the ones that are yours, filled with PTR records")
|
||||
cmd.Flags().StringSliceVar(&dismiss, "dismiss", nil, "hide the suggestions for these networks (comma separated or repeated)")
|
||||
cmd.Flags().StringSliceVar(&undismiss, "undismiss", nil, "show dismissed suggestions again")
|
||||
cmd.Flags().StringVar(&view, "view", "", "only this view; with --dismiss/--undismiss: the suggestion's view")
|
||||
cmd.Flags().BoolVar(&all, "all", false, "also networks not suggested unasked (suggestions off, dismissed, public views)")
|
||||
return cmd
|
||||
}
|
||||
+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"))
|
||||
}
|
||||
|
||||
+115
-7
@@ -24,9 +24,12 @@ import (
|
||||
|
||||
"jarl/internal/auth"
|
||||
"jarl/internal/bind"
|
||||
"jarl/internal/certs"
|
||||
"jarl/internal/config"
|
||||
"jarl/internal/engine"
|
||||
"jarl/internal/model"
|
||||
"jarl/internal/nscheck"
|
||||
"jarl/internal/porkbun"
|
||||
"jarl/internal/render"
|
||||
"jarl/internal/server"
|
||||
"jarl/internal/stats"
|
||||
@@ -50,7 +53,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 == "" {
|
||||
@@ -121,7 +124,12 @@ func openApp(allowMissingConfig bool) (*app, error) {
|
||||
ReloadTimeout: time.Duration(cfg.BIND.ReloadTimeout),
|
||||
NoReload: cfg.BIND.NoReload,
|
||||
Extras: renderExtras(cfg),
|
||||
LocalAddrs: nscheck.LocalAddrs,
|
||||
}, 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 +151,8 @@ 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}
|
||||
RetainQueries: time.Duration(sc.Retention.Queries),
|
||||
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 +171,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)
|
||||
@@ -180,6 +189,7 @@ func renderExtras(cfg *config.Config) render.Extras {
|
||||
h := &render.StatsHooks{Channel: netip.MustParseAddrPort(cfg.Stats.StatisticsChannel)}
|
||||
if cfg.Stats.QueryLog {
|
||||
h.LogFile, h.LogVersions, h.LogSize = cfg.Stats.LogFile, cfg.Stats.LogVersions, cfg.Stats.LogSize
|
||||
h.NoRateLimitLog = !cfg.Stats.RateLimitLog
|
||||
}
|
||||
x.Stats = h
|
||||
}
|
||||
@@ -291,12 +301,44 @@ 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)
|
||||
}
|
||||
}
|
||||
if a.cfg.Metrics.Enabled && a.cfg.Metrics.Token != "" {
|
||||
if fi, err := os.Stat(configPath); err == nil && fi.Mode().Perm()&0o004 != 0 {
|
||||
a.log.Warn("the configuration file holds metrics.token and everyone can read it; make it readable only by root and jarld (chown root:jarl, chmod 640)", "file", configPath)
|
||||
}
|
||||
}
|
||||
cm := certs.New(certs.Config{
|
||||
Directory: a.cfg.ACME.Account.DirectoryURL(), Email: a.cfg.ACME.Account.Email, AgreeTOS: a.cfg.ACME.Account.AgreeTOS,
|
||||
EABKeyID: a.cfg.ACME.Account.EABKeyID, EABHMAC: a.cfg.ACME.Account.EABHMAC,
|
||||
Resolvers: a.cfg.ACME.Resolvers, CABundle: a.cfg.ACME.CABundle, CheckPropagation: a.cfg.ACME.CheckPropagation,
|
||||
Dir: a.cfg.CertDir(), Version: version.String(), Porkbun: pb, PorkbunAllows: a.cfg.Porkbun.Allows,
|
||||
MaxPerUser: a.cfg.Certificates.MaxPerUser,
|
||||
}, a.store, a.eng, a.log)
|
||||
cm.Start(ctx)
|
||||
if a.cfg.ACME.Account.EABHMAC != "" {
|
||||
if fi, err := os.Stat(configPath); err == nil && fi.Mode().Perm()&0o004 != 0 {
|
||||
a.log.Warn("the configuration file holds acme.account.eab_hmac 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(),
|
||||
Metrics: server.Metrics{Enabled: a.cfg.Metrics.Enabled, Listen: a.cfg.Metrics.Listen,
|
||||
Allow: a.cfg.Metrics.AllowPrefixes(), Token: a.cfg.Metrics.Token},
|
||||
Certs: cm,
|
||||
Resolvers: a.cfg.ACME.Resolvers,
|
||||
RateLimitLog: a.cfg.Stats.Enabled && a.cfg.Stats.QueryLog && a.cfg.Stats.RateLimitLog,
|
||||
HTTPReq: server.HTTPReq{
|
||||
Enabled: a.cfg.ACME.HTTPReq.Enabled,
|
||||
AllowPlainHTTP: !a.cfg.ACME.HTTPReq.RequireHTTPS,
|
||||
@@ -313,7 +355,9 @@ 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)
|
||||
go cm.Run(ctx)
|
||||
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 +366,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 +535,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 +681,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 +693,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
|
||||
}
|
||||
@@ -7,12 +7,17 @@
|
||||
#
|
||||
# Install it, then:
|
||||
# EXEC_PATH=/usr/local/bin/jarl-lego JARL_TOKEN=@/etc/jarl/lego.token \
|
||||
# lego --email you@example.com --dns exec -d example.com -d '*.example.com' run
|
||||
# lego run --accept-tos --email you@example.com --dns exec -d example.com -d '*.example.com'
|
||||
# (lego 4: lego --accept-tos --email ... --dns exec -d ... run, and "renew" to renew)
|
||||
#
|
||||
# Options (environment):
|
||||
# JARL_SERVER Jarl server URL, e.g. https://ns1.example.com:8053 (optional if the jarl CLI is configured)
|
||||
# JARL_TOKEN API token, or @FILE (optional if the jarl CLI is configured)
|
||||
# JARL_VIEW Only write into this view (optional; default every view that has the zone)
|
||||
# On a host inside a split DNS, lego's own propagation check then can't
|
||||
# see the record: leave it unset, or run lego with
|
||||
# --dns.propagation.disable-ans (lego 5; --dns.propagation-disable-ans in lego 4)
|
||||
# (docs/acme.md, "Split DNS and lego's propagation check").
|
||||
# JARL_BIN Path of the jarl binary (optional; default "jarl")
|
||||
|
||||
JARL_BIN="${JARL_BIN:-jarl}"
|
||||
|
||||
@@ -38,6 +38,13 @@ if [ "$1" = configure ]; then
|
||||
if systemctl is-enabled --quiet jarl-backup.timer 2>/dev/null; then
|
||||
systemctl start jarl-backup.timer || true
|
||||
fi
|
||||
# The jarld.service alias is linked when jarl.service is enabled;
|
||||
# relink a unit enabled before the alias existed (this neither
|
||||
# starts nor stops jarld).
|
||||
if [ -n "$2" ] && systemctl is-enabled --quiet jarl.service 2>/dev/null \
|
||||
&& [ ! -e /etc/systemd/system/jarld.service ]; then
|
||||
systemctl reenable jarl.service >/dev/null 2>&1 || true
|
||||
fi
|
||||
# An upgrade restarts a running jarld (its database is upgraded
|
||||
# when it starts). A first install starts nothing: Jarl needs
|
||||
# 'jarld init' and 'jarld adopt' first.
|
||||
|
||||
@@ -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
|
||||
@@ -61,3 +65,5 @@ SystemCallErrorNumber=EPERM
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
# 'systemctl restart jarld' works too (the daemon's binary is jarld).
|
||||
Alias=jarld.service
|
||||
+96
-6
@@ -1,5 +1,11 @@
|
||||
# Let's Encrypt (ACME DNS-01) with Jarl
|
||||
|
||||
This page is about **other** ACME clients (certbot, acme.sh, lego,
|
||||
Traefik) publishing their DNS-01 challenges through Jarl. For
|
||||
certificates for the DNS server itself (Jarl's web UI, named's DNS over
|
||||
TLS and HTTPS), Jarl can be the ACME client: see
|
||||
[certificates.md](certificates.md).
|
||||
|
||||
The DNS-01 challenge proves that you control a domain by publishing a TXT
|
||||
record at `_acme-challenge.<domain>`. Jarl does this through its API, so no
|
||||
dynamic updates (`allow-update`, nsupdate) are needed. That matters because
|
||||
@@ -42,7 +48,9 @@ jarl acme unset DOMAIN [VALUE...] # remove these values, or all of them
|
||||
* The zone is found automatically: the longest matching primary zone.
|
||||
* If the zone exists in several **views** (split DNS), the record is written
|
||||
into every view, so the CA (on the internet, in your external view) and
|
||||
your internal resolvers see the same thing. `--view NAME` limits it to one.
|
||||
your internal resolvers see the same thing. `--view NAME` limits it to one,
|
||||
and so does a token for one view (`views=external`): in a linked zone its
|
||||
records are then local to that view.
|
||||
* All values and views go into one changeset. Setting a value that is
|
||||
already there succeeds, so a retried hook doesn't fail, and removing a
|
||||
value that isn't there succeeds as well.
|
||||
@@ -90,10 +98,23 @@ sudo install -m 0755 contrib/lego/jarl-lego.sh /usr/local/bin/jarl-lego # or f
|
||||
export EXEC_PATH=/usr/local/bin/jarl-lego
|
||||
export JARL_SERVER=https://ns1.adbraun.lol:8053
|
||||
export JARL_TOKEN=@/etc/jarl/certbot.token
|
||||
lego --email you@adbraun.lol --dns exec -d adbraun.lol -d '*.adbraun.lol' run
|
||||
lego run --accept-tos --email you@adbraun.lol --dns exec -d adbraun.lol -d '*.adbraun.lol'
|
||||
```
|
||||
|
||||
Renew with the same variables and `lego ... renew` in place of `run`.
|
||||
That is lego 5. `lego run` also renews: run the same command daily (a
|
||||
cron job or systemd timer), and it renews once a third of the
|
||||
certificate's lifetime is left. **lego 4** takes its options before the
|
||||
command, and renews with `renew`:
|
||||
|
||||
```sh
|
||||
lego --accept-tos --email you@adbraun.lol --dns exec -d adbraun.lol -d '*.adbraun.lol' run # first time
|
||||
lego --email you@adbraun.lol --dns exec -d adbraun.lol -d '*.adbraun.lol' renew # then daily
|
||||
```
|
||||
|
||||
Upgrading from lego 4 to 5, run `lego migrate` once (with `--path` if you
|
||||
keep lego's data somewhere other than `./.lego`) before anything else:
|
||||
lego 5 stores accounts and certificates differently. The exec and httpreq
|
||||
providers, and so Jarl's side, are the same in both.
|
||||
|
||||
* lego runs `jarl-lego present FQDN VALUE` and `jarl-lego cleanup FQDN VALUE`,
|
||||
and the script passes them to `jarl acme set|unset --exact`. FQDN is the
|
||||
@@ -106,7 +127,66 @@ Renew with the same variables and `lego ... renew` in place of `run`.
|
||||
* Leave `EXEC_MODE` unset: the script needs lego's default mode, not `RAW`.
|
||||
* lego waits until the record is visible on the zone's name servers
|
||||
(`EXEC_PROPAGATION_TIMEOUT`, 60 seconds by default). Jarl answers only
|
||||
once named serves the record, so this check passes on its first try.
|
||||
once named serves the record, so this check passes on its first try,
|
||||
as long as the view that answers lego's host has the record: with
|
||||
`JARL_VIEW` on a host inside your network it doesn't. See
|
||||
[Split DNS and lego's propagation check](#split-dns-and-legos-propagation-check).
|
||||
|
||||
### Split DNS and lego's propagation check
|
||||
|
||||
Before lego asks the CA to look, it checks the record itself, asking the
|
||||
zone's name servers (`ns1.adbraun.lol`) directly. With views, they answer
|
||||
lego's host from the view that matches *it*. On a host inside your
|
||||
network that is the internal view. If the challenge was written into
|
||||
another view only (`JARL_VIEW=external`, `--view external`, a token
|
||||
limited to `views=external`, or the httpreq endpoint
|
||||
`.../httpreq/view/external`), lego never sees it, and gives up after
|
||||
its propagation timeout without the CA having looked:
|
||||
|
||||
```
|
||||
propagation: time limit exceeded: last error: authoritative nameservers:
|
||||
NS ns1.adbraun.lol.:53 returned NXDOMAIN for _acme-challenge.stats.adbraun.lol.
|
||||
```
|
||||
|
||||
A name whose authorization is still valid from an earlier run ("authorization
|
||||
already valid; skipping challenge") isn't checked at all, so one name can
|
||||
work and the next fail with the same settings.
|
||||
|
||||
The ways round it, best first:
|
||||
|
||||
1. **Don't limit the view.** Leave `JARL_VIEW` unset (and give the token
|
||||
no `views=` filter, the httpreq endpoint no `/view/...`): the record
|
||||
goes into every view that has the zone, the internal one too, and
|
||||
lego's check passes at once. The challenge is a random value that is
|
||||
useless once the CA has checked it, and it is removed straight after.
|
||||
2. **Skip lego's check of the name servers.** The record is already
|
||||
served when Jarl answers, so lego's check adds nothing:
|
||||
|
||||
```sh
|
||||
lego run --dns exec --dns.propagation.disable-ans ... # lego 5
|
||||
lego --dns exec --dns.propagation-disable-ans ... run # lego 4.20 to 4.x
|
||||
lego --dns exec --dns.disable-cp ... run # older lego 4
|
||||
```
|
||||
|
||||
or replace the checks with a fixed wait, which also gives secondary
|
||||
name servers time to catch up: `--dns.propagation.wait 30s` (lego 5;
|
||||
`--dns.propagation-wait 30s` in lego 4.20 and later). In lego 5 these
|
||||
can also be set in the environment: `LEGO_DNS_PROPAGATION_DISABLE_ANS=true`,
|
||||
`LEGO_DNS_PROPAGATION_WAIT=30s`.
|
||||
3. **In Traefik**, the same under `dnsChallenge` (Traefik 3.2 and later;
|
||||
older versions have `disablePropagationCheck: true`):
|
||||
|
||||
```yaml
|
||||
dnsChallenge:
|
||||
provider: httpreq
|
||||
propagation:
|
||||
disableANSChecks: true # or delayBeforeChecks: 30s
|
||||
```
|
||||
4. **Run lego where the external view answers it**, such as a host outside
|
||||
your network. Rarely the easy option.
|
||||
|
||||
certbot (section 3) doesn't check by itself, and acme.sh (section 4)
|
||||
checks through public DNS-over-HTTPS resolvers, so neither runs into this.
|
||||
|
||||
## 6. lego and Traefik over HTTP (httpreq), without the jarl CLI
|
||||
|
||||
@@ -291,14 +371,20 @@ of the `acme` role.
|
||||
export HTTPREQ_ENDPOINT=https://ns1.adbraun.lol:8053/api/v1/acme/httpreq
|
||||
export HTTPREQ_USERNAME=lego
|
||||
export HTTPREQ_PASSWORD_FILE=/etc/jarl/lego.token # or HTTPREQ_PASSWORD=jarl_...
|
||||
lego --email you@adbraun.lol --dns httpreq -d adbraun.lol -d '*.adbraun.lol' run
|
||||
lego run --accept-tos --email you@adbraun.lol --dns httpreq -d adbraun.lol -d '*.adbraun.lol'
|
||||
```
|
||||
|
||||
That is lego 5; in lego 4 the options go before the command (`lego
|
||||
--email ... --dns httpreq -d ... run`, and `renew` to renew). See
|
||||
[section 5](#5-lego).
|
||||
|
||||
Leave `HTTPREQ_MODE` unset (its `RAW` mode sends a different body, which
|
||||
jarld refuses with a message saying so). If jarld's certificate doesn't come from a public
|
||||
CA, lego has to trust yours: `SSL_CERT_FILE=/path/to/your-ca.pem` (lego's
|
||||
`LEGO_CA_CERTIFICATES` covers only the ACME server). To write into one view only, put
|
||||
it in the endpoint: `.../api/v1/acme/httpreq/view/external`.
|
||||
it in the endpoint: `.../api/v1/acme/httpreq/view/external`; on a host inside
|
||||
your network, read [Split DNS and lego's propagation check](#split-dns-and-legos-propagation-check)
|
||||
first.
|
||||
|
||||
### 4. Traefik
|
||||
|
||||
@@ -404,4 +490,8 @@ the main zone is never modified by automation.
|
||||
`dig @ns1.adbraun.lol _acme-challenge.adbraun.lol TXT`. With views, make
|
||||
sure the record is in the view that matches the CA (it is by default)
|
||||
and that `allow-query` lets the internet query the zone.
|
||||
* lego says `propagation: time limit exceeded ... returned NXDOMAIN for
|
||||
_acme-challenge...`: the record is in a view that doesn't answer lego's
|
||||
host, usually `JARL_VIEW=external` on a host inside your network. See
|
||||
[Split DNS and lego's propagation check](#split-dns-and-legos-propagation-check).
|
||||
* `jarl audit --user certbot` shows every attempt.
|
||||
+305
-16
@@ -10,6 +10,8 @@ what is documented here.
|
||||
(`www.adbraun.lol.`), and display helpers drop it (`display_name`,
|
||||
`relative_name`).
|
||||
* `GET /healthz` (no authentication) returns `{"ok": true, "version": ...}`.
|
||||
* `GET /metrics` serves Prometheus metrics when `metrics.enabled` is set.
|
||||
Access is set in jarl.yaml, not with API tokens ([metrics.md](metrics.md)).
|
||||
|
||||
## Authentication
|
||||
|
||||
@@ -26,7 +28,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 +112,8 @@ on the resources below, except users and tokens) works the same way:
|
||||
|
||||
Reads accept `?changeset=ID` too. They then show the configuration as it
|
||||
will be after the changeset, with `"pending": "add" | "change" | "delete"`
|
||||
on affected zones and records.
|
||||
on affected zones and records (`"disable"` or `"enable"` for a record
|
||||
that is only being disabled or enabled).
|
||||
|
||||
The response of an applied change:
|
||||
|
||||
@@ -126,6 +149,14 @@ honours `If-Match: <version>`, and batch operations take `base_version`. If
|
||||
the object changed in the meantime the request fails with 409. Staged
|
||||
operations record the version they were staged against automatically.
|
||||
|
||||
**Stale changesets.** When an object a staged operation needs was changed
|
||||
or deleted after it was staged (from the CLI, say), preview and apply fail
|
||||
on that operation: the error has `op` (its number in the changeset),
|
||||
`op_summary` and a message saying what happened; remove the operation
|
||||
(`DELETE /changesets/{id}/ops/{seq}`) or stage it again. A staged delete of
|
||||
a record that is already gone is not an error: it counts as done, with a
|
||||
warning.
|
||||
|
||||
## Resources
|
||||
|
||||
`{zone}` is a zone ID or name (`root` for the root zone). Add `?view=NAME`
|
||||
@@ -139,11 +170,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:
|
||||
@@ -151,6 +190,8 @@ ZoneInput:
|
||||
```json
|
||||
{
|
||||
"name": "adbraun.lol", // or a CIDR like "192.168.1.0/24" for a reverse zone
|
||||
"fill": true, // reverse zones: PTR records for the view's A/AAAA records there
|
||||
"fill_other_views": true, // with fill: also for addresses only other views' records use
|
||||
"view": "internal", // or "view_id"; moving a zone = PATCH with another view
|
||||
"type": "primary", // secondary, stub, static-stub, forward, mirror, hint, redirect, in-view
|
||||
"in_view": "internal", // type in-view only
|
||||
@@ -159,6 +200,8 @@ 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)
|
||||
"auto_ptr": true, // keep the PTRs of the zone's A/AAAA records in step (docs/reverse.md)
|
||||
"comment": "",
|
||||
// create only:
|
||||
"nameservers": ["ns1.adbraun.lol."], // default: settings
|
||||
@@ -168,6 +211,15 @@ ZoneInput:
|
||||
}
|
||||
```
|
||||
|
||||
A reverse zone created without a view goes to the private view whose
|
||||
records use its addresses. Zones come back with `prefix` (reverse zones:
|
||||
the network), `ptr_missing` (addresses in it that the view's A and AAAA
|
||||
records use, without a PTR record) and `ptr_other_views` (addresses in it
|
||||
without a PTR record that only other views' records use; not missing),
|
||||
with the first 25 of each in `ptr_missing_list` and
|
||||
`ptr_other_views_list` (`address`, `name`, `target`, and `from`, the view
|
||||
the name comes from).
|
||||
|
||||
`/serials` and `/delegation` send DNS queries from the server, and only
|
||||
when called ([operations.md](operations.md#checking-name-servers-and-the-delegation)).
|
||||
`/serials` works for primary, secondary and mirror zones:
|
||||
@@ -191,25 +243,162 @@ 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"],
|
||||
"ds_at": "registrar", // parent_zone, registrar, parent, reverse or none
|
||||
"ds_place": "at your registrar", // completes "add the DS record ..."
|
||||
"ds_note": "", // for ds_at none: why it can't have one
|
||||
"parent_zone": "", // set when the parent zone is a primary zone in Jarl (any view, its own first)
|
||||
"registrar": "porkbun" // when the DS can be published through Jarl
|
||||
}
|
||||
```
|
||||
|
||||
`ds_at` says where the DS record goes ([dnssec.md](dnssec.md#should-you-sign)):
|
||||
`parent_zone` (a zone in Jarl: `parent_zone`, `parent_zone_id`,
|
||||
`parent_view`), `registrar` (the zone is a registered domain), `parent` (a
|
||||
subdomain whose parent zone is hosted elsewhere), `reverse` (whoever
|
||||
delegates the addresses) or `none` (nothing on the internet delegates it).
|
||||
|
||||
`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
|
||||
|
||||
In a linked zone ([views.md](views.md#linked-zones)), records carry
|
||||
`shared_id` and `linked` (`shared` or `local`). Creating, updating
|
||||
(`PATCH /records/{id}`) and setting (`PUT .../rrsets/...`) take `"local":
|
||||
true` (this view only, an override) or `false` (shared; on an update or a
|
||||
set it makes this view's version every view's). Zones carry `link_group`
|
||||
and `linked_with` (the other views). Every zone in a view carries
|
||||
`in_views`: the zones of the same name in the other views, linked or not
|
||||
and of any type, in view order (`[{"id": 289, "view": "external", "type":
|
||||
"primary", "linked": true}]`).
|
||||
|
||||
| Method | Path | Body |
|
||||
|--------|------|------|
|
||||
| POST | `/zones/{zone}/link` | `{"with": ["external"]}` (default: every view with that zone). Names and types that are the same everywhere become shared, the rest stay local |
|
||||
| POST | `/zones/{zone}/unlink` | Takes the zone out of its link; its records stay, all local |
|
||||
| POST | `/zones/{zone}/fill-ptr` | Adds the PTR records a reverse zone lacks for its view's A and AAAA records; `?other_views=1` also for the addresses only other views' records use, named from there ([reverse.md](reverse.md)) |
|
||||
| GET | `/reverse-zones/suggestions` | The reverse zones the private views lack (`?all=1`: also the ones not suggested unasked, with `hidden`: public view, suggestions off, dismissed; `?view=`: one view): `view_id`, `view`, `prefix`, `zone`, `group` (`yours`, or `public`: only if the address is yours), `why`, `fill` (PTR records it would get), `samples` |
|
||||
| POST | `/reverse-zones/dismiss` | `{"view": "internal", "prefix": "185.199.108.153/32"}` (or `"prefixes": [...]` for several): don't suggest it again (`/undismiss` brings it back). A view's `reverse_suggestions: false` (PATCH `/views/{id}`) stops its suggestions |
|
||||
| GET | `/zones/{zone}/compare` | The zone compared with the same zone in other views, from its own view ([views.md](views.md#comparing-views)): `?with=a,b` (default: all), `?name=www&type=A` (one name, the same records included), `?all=1`. Returns `base`, `views`, `linked`, `summary` (per other view: `different`, `only_base`, `only_other`, `same`), `different` and `only` (`views`, `rows`) rows (name, `fqdn`, type, per view `records`, `data`, `ids`, `ttl`, `linked`), `same`, `same_rows`. A zone named without its view is compared from the first view that has it |
|
||||
|
||||
`POST /zones` takes `"link_with": "VIEW"` to create a zone linked with
|
||||
that view's zone of the same name.
|
||||
|
||||
| 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 +409,28 @@ 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". A zone update that leaves the zone as it
|
||||
is (the same SOA, TTL, comment...) changes nothing, so applying it is
|
||||
"nothing to do" and staging it is "nothing to change".
|
||||
|
||||
### Views
|
||||
|
||||
@@ -235,33 +440,75 @@ 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 |
|
||||
|--------|------|------|
|
||||
| GET | `/acls` | each with `used_by` |
|
||||
| POST | `/acls` | `{"name": "homelab", "elements": ["192.168.1.0/24", "!192.168.1.66", "key xfer", "localhost"], "comment": ""}` |
|
||||
| POST | `/acls` | `{"name": "homelab", "elements": ["192.168.1.0/24", "!192.168.1.66", "10.0.10.101-10.0.10.199", "key xfer", "localhost"], "comment": ""}` (ranges: [concepts.md](concepts.md#model-views-zones-records-options)) |
|
||||
| GET / PATCH / DELETE | `/acls/{id}` | renaming updates references; deleting one in use → 409 |
|
||||
| GET | `/tsig-keys` | without secrets |
|
||||
| POST | `/tsig-keys` | `{"name": "xfer", "algorithm": "hmac-sha256", "secret": "<base64>"}` (secret generated when absent; the response `object` shows it once) |
|
||||
| GET | `/tsig-keys/{id}` | `?secret=1` includes the secret (needs `tsig:write`, audited) |
|
||||
| PATCH / DELETE | `/tsig-keys/{id}` | PATCH `{"secret": "..."}` rotates |
|
||||
|
||||
### Blocking
|
||||
|
||||
Needs `blocking:read` (`blocking:write` to change). See
|
||||
[blocking.md](blocking.md).
|
||||
|
||||
| Method | Path | Body / query |
|
||||
|--------|------|--------------|
|
||||
| GET | `/blocking/lists` | each with `views` (where it applies), `auto_views` (not chosen: every view that offers recursion), `state` (ok, waiting, error, disabled, idle), `status` (`entries`, `records`, `skipped`, `checked_at`, `updated_at`, `error`) and `memory_mb` (named's memory for it, estimated) |
|
||||
| POST | `/blocking/lists` | `{"name": "hagezi-multi", "url": "https://...", "views": ["internal"], "comment": "", "step": "normal"}` (`views` empty or absent: automatic; `step` the blocking step it starts at); jarld downloads it right after |
|
||||
| GET / PATCH / DELETE | `/blocking/lists/{id or name}` | PATCH any of `name`, `url`, `views`, `disabled`, `comment` |
|
||||
| POST | `/blocking/lists/{id or name}/refresh` | download now and wait; `?force=1` even when the server says nothing changed. Returns the list; a failed update is in `state` and `status.error` |
|
||||
| GET | `/blocking/rules` | each with `views` and `auto_views` |
|
||||
| POST | `/blocking/rules` | `{"name": "doubleclick.net", "action": "block", "exact": false, "views": [], "comment": ""}` (`action` block or allow; `exact`: only the name, not the names below it) |
|
||||
| PATCH / DELETE | `/blocking/rules/{id}` | |
|
||||
| DELETE | `/blocking/rules` | `?name=DOMAIN[&view=V][&missing_ok=1]`: the rules for a domain |
|
||||
| GET | `/blocking/check` | `?name=DOMAIN`: per view with blocking, `{view, result: blocked\|allowed\|none, entry, rule_id, list, step}` (what applies to normal devices when blocking isn't paused; `step` when only devices at that step or above are blocked) |
|
||||
| GET / POST | `/blocking/steps` | the steps above normal, lowest first, with the `lists` and `groups` at each; POST `{"name": "strict", "comment": ""}` adds one on top |
|
||||
| PATCH / DELETE | `/blocking/steps/{id or name}` | `{"name", "position", "comment"}`; delete is refused while lists or groups use it |
|
||||
| POST | `/blocking/steps/order` | `{"steps": ["strict", "very-strict"]}`: every step above normal, lowest first |
|
||||
| GET / POST | `/blocking/groups` | client groups; POST `{"name": "kids", "members": ["10.10.0.50", "tablets"], "step": "strict", "views": []}` |
|
||||
| PATCH / DELETE | `/blocking/groups/{id or name}` | the same fields |
|
||||
| GET | `/blocking/status` | `{paused_until, paused_indefinitely}` (null and false when not paused) |
|
||||
| POST | `/blocking/pause` | `{"minutes": 15}` (1 to 1440): let every name through until then; jarld resumes by itself. `{"indefinitely": true}`: until resumed |
|
||||
| POST | `/blocking/resume` | end a pause now |
|
||||
|
||||
Lists and rules are configuration like the rest: they can be staged, and
|
||||
listing with `?changeset=` marks them `pending`.
|
||||
|
||||
### Options and settings
|
||||
|
||||
| Method | Path | Body / query |
|
||||
|--------|------|--------------|
|
||||
| GET | `/options` | `?scope=options\|view\|zone\|toplevel&scope_id=ID` (none: everything you may see) |
|
||||
| GET | `/options` | `?scope=options\|view\|zone\|toplevel&scope_id=ID` (none: everything you may see). `formatted` lays out a block over several lines, a list of three or more one element per line |
|
||||
| GET | `/options/generated` | What Jarl adds to named.conf by itself: `where`, `text`, `why`, `setting` (what turns it on or off) |
|
||||
| POST | `/options` | `{"scope": "zone", "scope_id": 11, "name": "allow-transfer", "value": "{ key xfer; }", "comment": "", "position": 0}` |
|
||||
| GET / PATCH / DELETE | `/options/{id}` | |
|
||||
| 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
|
||||
|
||||
@@ -270,6 +517,8 @@ checked against the BIND 9.20 grammar for that scope.
|
||||
| GET | `/catalog/options` | scopes and zone types; with `?scope=options\|view\|toplevel\|zone:primary\|...` every statement allowed there (name, grammar, kind, status, ARM description). `?all=1` includes statements Jarl manages itself |
|
||||
| GET | `/catalog/rrtypes` | record types with field hints and examples |
|
||||
| GET | `/catalog/grants` | resources, roles and the grant syntax |
|
||||
| GET | `/exposure` | which views are reachable from the internet, the exposure findings and their fixes ([exposure.md](exposure.md#api)); `POST /exposure/fix`, `/exposure/ack`, `/exposure/unack` |
|
||||
| GET | `/catalog/ranges?r=A-B,C-D` | for each address range: `range` (canonical), `size` ("99 addresses"), and the `prefixes` it is written to named.conf as; or `error`. Text that isn't a range is left out |
|
||||
| GET | `/search?q=text` | zones, records and ACLs matching the text |
|
||||
|
||||
### Changesets
|
||||
@@ -280,7 +529,7 @@ checked against the BIND 9.20 grammar for that scope.
|
||||
| GET | `/changesets` | `?status=open\|applied\|discarded&mine=1&limit=100&before=ID` |
|
||||
| POST | `/changesets` | `{"description": "...", "ops": [...], "apply": false}`: create (and optionally stage or apply) |
|
||||
| GET / PATCH | `/changesets/{id}` | PATCH `{"description": "..."}` |
|
||||
| POST | `/changesets/{id}/ops` | `{"ops": [Operation...]}`: stage more operations |
|
||||
| POST | `/changesets/{id}/ops` | `{"ops": [Operation...]}`: stage more operations; 400 "nothing to change" if they change nothing |
|
||||
| DELETE | `/changesets/{id}/ops/{seq}` | remove one staged operation |
|
||||
| GET | `/changesets/{id}/preview` | dry run of an open changeset: problems, check messages, diff |
|
||||
| POST | `/changesets/{id}/apply` | `{"dry_run": false, "force": false}` |
|
||||
@@ -295,12 +544,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,29 +563,59 @@ 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` |
|
||||
|
||||
### Query statistics
|
||||
|
||||
Every statistics endpoint below takes `views=internal,vps` to limit the
|
||||
figures to those views ([stats.md](stats.md#views)); a breakdown that only
|
||||
exists for the whole server then answers `400 not_per_view`.
|
||||
|
||||
Needs `stats:read`. When statistics are disabled, every endpoint but
|
||||
`status` answers 404 `stats_disabled`. See [stats.md](stats.md).
|
||||
|
||||
| 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/status` | | `{enabled, query_log, statistics_channel, log_file, last_poll, last_log_line, log_lines_read, first_data, retention, retention_seconds, warnings}`: `retention` has `minute`, `hour`, `day`, `queries` as `"7d"`, `retention_seconds` the same in seconds (`queries` 0 without a query log) |
|
||||
| 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/explore` | `?client=&name=&domain=&view=&type=&rcode=&blocked=yes\|no\|LIST&since=24h&views=`; each takes several values, comma separated (any of them), `!value` to leave one out, and names `*.example.com` for it and below | The query log for a filter ([stats.md](stats.md#exploring)): `total`, `blocked`, `series` (per `step` seconds: `t`, `count`, `blocked`), `by` (top 15 of each dimension the filter leaves open: client, name, view, type, rcode, blocked; clients with `name`), `recent` (latest 100: ts, client, client_name, view, name, type, rcode, blocked), `oldest`, `retention` (`"7d"`) and `retention_seconds`, `client_name`. 404 `query_log_off` without a query log |
|
||||
| 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}], database: {path, bytes, query_log_rows}}`; 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)"'
|
||||
```
|
||||
|
||||
### Encrypted DNS
|
||||
|
||||
See [encrypted-dns.md](encrypted-dns.md#api): `GET`, `POST` and `DELETE
|
||||
/encrypted-dns`, `POST /encrypted-dns/check`.
|
||||
|
||||
### Certificates
|
||||
|
||||
Administrators only. See [certificates.md](certificates.md#api).
|
||||
|
||||
| Method | Path | Body |
|
||||
|--------|------|------|
|
||||
| GET | `/certificates` | `certificates` (with `status`, `next_attempt`, `issuing_since`, `serving`, `files`, and for one obtained with `jarl cert get` `owner_id`, `owner` and `pulled_by`: every download for an administrator, the caller's own for the owner) and `account` |
|
||||
| POST | `/certificates` | `{"name", "domains", "uses", "challenge", "key_type", "comment"}`, or `{"name", "source": "file", "cert_file", "key_file", "uses"}`; `"issue": false` doesn't start obtaining it |
|
||||
| POST | `/certificates/check` | `{"domains", "challenge"}`: the route of each challenge |
|
||||
| GET, PATCH, DELETE | `/certificates/{name}` | |
|
||||
| POST | `/certificates/{name}/issue` | 202: being obtained (poll until `issuing_since` is gone); a file certificate is reread (200) |
|
||||
| POST | `/certificates/{name}/check` | the routes of a certificate's names |
|
||||
| POST | `/certificates/{name}/request` | `{"domains": [...], "key_type": "ec256"}`: a certificate for another host, owned by the caller's user (needs `certificates:write` for every name). New: created and obtained, 202 (poll `GET /certificates/{name}` until `issuing_since` is gone). Existing: must be the caller's (else 409); obtained again only when its names change. Answers `certificate`, `created`, `changed` ([certificates.md](certificates.md#certificates-for-other-hosts)) |
|
||||
| GET | `/certificates/{name}/pem?part=PART` | the certificate as PEM: `fullchain` (default), `cert`, `key`, `bundle`; key exports audited ([certificates.md](certificates.md#using-a-certificate-outside-jarl)) |
|
||||
|
||||
`GET /status` includes `certificates` for administrators: `total`,
|
||||
`problems` (failing, expiring, expired), `next` and `next_after`,
|
||||
`serving`.
|
||||
|
||||
### ACME: lego's httpreq provider
|
||||
|
||||
Off unless `acme.httpreq.enabled` is set, and by default HTTPS only. The
|
||||
@@ -354,13 +637,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`, `username` to rename, and `cert_limit` (certificates the user may own through `jarl cert get`; negative: the `certificates.max_per_user` default). 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,331 @@
|
||||
# Blocking
|
||||
|
||||
Jarl can make named answer "this name doesn't exist" (NXDOMAIN) for ads,
|
||||
trackers and malware, like Pi-hole does, for every device that uses your
|
||||
server to resolve names. It has two parts:
|
||||
|
||||
- **Blocklists**: lists of domains published by others, which jarld
|
||||
downloads and keeps up to date.
|
||||
- **Your rules**: domains you block or allow yourself. They take precedence
|
||||
over every list.
|
||||
|
||||
It uses BIND's own mechanism for this, response policy zones (RPZ), so named
|
||||
does the blocking itself; jarld only prepares the lists.
|
||||
|
||||
- [Quick start](#quick-start)
|
||||
- [Where blocking applies](#where-blocking-applies)
|
||||
- [Your rules](#your-rules)
|
||||
- [Blocklists](#blocklists)
|
||||
- [Checking a name](#checking-a-name)
|
||||
- [Pausing](#pausing)
|
||||
- [Statistics](#statistics)
|
||||
- [Memory and performance](#memory-and-performance)
|
||||
- [How it works](#how-it-works)
|
||||
- [Troubleshooting](#troubleshooting)
|
||||
|
||||
## Quick start
|
||||
|
||||
In the web UI, open **Blocking**, pick one of the well-known lists, and
|
||||
click **Add list**. Or with the CLI:
|
||||
|
||||
```sh
|
||||
jarl blocklist add hagezi-multi https://raw.githubusercontent.com/hagezi/dns-blocklists/main/adblock/multi.txt
|
||||
jarl blocklist list # "ok" once downloaded, usually within seconds
|
||||
jarl block check ads.doubleclick.net # blocked? by what?
|
||||
jarl block allow cdn.example.com # something you need was blocked
|
||||
```
|
||||
|
||||
Like every change in Jarl, adding a list or rule can be staged and reviewed
|
||||
first (the web UI does this by default: apply it under **Changes**).
|
||||
|
||||
## Where blocking applies
|
||||
|
||||
Blocking changes the answers named gives when it resolves names for a
|
||||
client (recursion). It doesn't change what the internet sees of your own
|
||||
zones: other resolvers ask your server without recursion.
|
||||
|
||||
**Views.** A list or rule applies to the views you choose, or by default
|
||||
to every view that offers recursion. With the usual split (an `internal`
|
||||
view that resolves for your network, an `external` view that only answers
|
||||
for your zones) that is just `internal`. A view that doesn't offer
|
||||
recursion gets nothing, and naming one gives a warning. Without views,
|
||||
blocking applies to the whole server.
|
||||
|
||||
**What it can't do:**
|
||||
|
||||
- Devices that don't use your server for DNS aren't affected. That includes
|
||||
browsers using their own DNS over HTTPS, and devices with a hard-coded
|
||||
resolver (8.8.8.8). A firewall rule that sends port 53 to your server, and
|
||||
your network's DNS settings, are the usual answers.
|
||||
- A device that validates DNSSEC itself sees a blocked name as a
|
||||
validation failure rather than "doesn't exist". It's still blocked.
|
||||
- A list can contain names in your own zones. They are blocked like any
|
||||
other; allow them with a rule if that's not what you want.
|
||||
|
||||
## Your rules
|
||||
|
||||
A rule **blocks** or **allows** a domain and every name below it, or with
|
||||
`--exact` (web UI: "only this exact name") just the domain itself.
|
||||
|
||||
```sh
|
||||
jarl block add doubleclick.net # doubleclick.net and everything below it
|
||||
jarl block add telemetry.example.com --exact # just that name
|
||||
jarl block allow cdn.example.com --view internal # an exception, in one view
|
||||
jarl block list
|
||||
jarl block delete doubleclick.net # or: --id ID
|
||||
```
|
||||
|
||||
**Precedence:** your rules come before every blocklist, and among your
|
||||
rules the most specific one wins. With these rules:
|
||||
|
||||
| Rule | |
|
||||
|------|-|
|
||||
| block `example.com` | and below |
|
||||
| allow `cdn.example.com` | exact |
|
||||
|
||||
`example.com` and `www.example.com` are blocked, `cdn.example.com` is
|
||||
allowed, and `img.cdn.example.com` is blocked again (the allow rule was for
|
||||
the exact name). An allow rule beats a blocklist entry whatever the lists
|
||||
say.
|
||||
|
||||
There is one rule per domain and view; a second one is refused. A rule
|
||||
takes effect as soon as the change is applied.
|
||||
|
||||
## Blocklists
|
||||
|
||||
**Formats.** Lists are read line by line; one list may mix formats:
|
||||
|
||||
| Line | Blocks |
|
||||
|------|--------|
|
||||
| `\|\|ads.example^` (Adblock) | the name and every name below it |
|
||||
| `*.ads.example` | the name and every name below it |
|
||||
| `0.0.0.0 ads.example` (hosts file, any address) | just the name |
|
||||
| `ads.example` (plain domain) | just the name |
|
||||
|
||||
Comments (`#`, `!`), headers and blank lines are ignored. Lines that DNS
|
||||
can't act on are skipped and counted: Adblock exceptions (`@@`), cosmetic
|
||||
and URL rules, addresses, invalid names, and single labels (a broken list
|
||||
can't block `com`). Prefer lists in Adblock format: they say which entries
|
||||
cover the names below them.
|
||||
|
||||
**Well-known lists** (sizes in October 2026; they change):
|
||||
|
||||
| List | URL | Domains | Memory per view |
|
||||
|------|-----|--------:|----------------:|
|
||||
| Hagezi Light | `https://raw.githubusercontent.com/hagezi/dns-blocklists/main/adblock/light.txt` | 55,000 | ~65 MB |
|
||||
| Hagezi Multi Normal | `https://raw.githubusercontent.com/hagezi/dns-blocklists/main/adblock/multi.txt` | 159,000 | ~190 MB |
|
||||
| Hagezi Pro | `https://raw.githubusercontent.com/hagezi/dns-blocklists/main/adblock/pro.txt` | 195,000 | ~235 MB |
|
||||
| OISD Small | `https://small.oisd.nl/` | 58,000 | ~70 MB |
|
||||
| OISD Big | `https://big.oisd.nl/` | 241,000 | ~290 MB |
|
||||
| StevenBlack hosts | `https://raw.githubusercontent.com/StevenBlack/hosts/master/hosts` | 73,000 | ~45 MB |
|
||||
|
||||
One good list is usually enough; lists overlap a lot. Hagezi Multi Normal
|
||||
is a balanced start; Light and OISD Small block less but almost never
|
||||
something you need.
|
||||
|
||||
**Updates.** jarld checks each list every `blocking.refresh` (a day by
|
||||
default) and downloads it only when the server says it changed. A new
|
||||
version is checked with `named-checkzone` and replaces the old one only
|
||||
when its content differs; named then reloads it. If a download or check
|
||||
fails, named keeps using the previous version, jarld tries again within
|
||||
the hour, and the dashboard shows the error. To update now:
|
||||
|
||||
```sh
|
||||
jarl blocklist refresh # every list; or: refresh hagezi-multi [--force]
|
||||
```
|
||||
|
||||
**Managing lists:**
|
||||
|
||||
```sh
|
||||
jarl blocklist update hagezi-multi --view internal # --view auto: every view with recursion
|
||||
jarl blocklist update hagezi-multi --url URL --name NEW-NAME
|
||||
jarl blocklist disable hagezi-multi # stop using it, keep it (enable: back without a download)
|
||||
jarl blocklist delete hagezi-multi
|
||||
```
|
||||
|
||||
A list's state is **ok** (in use), **waiting** (not downloaded yet),
|
||||
**error** (the last update failed; the previous version stays in use),
|
||||
**disabled**, or **idle** (it applies to no view, so it does nothing).
|
||||
|
||||
## Different blocking for different devices
|
||||
|
||||
Client groups give some devices less or more blocking than the rest, like
|
||||
Pi-hole's groups: no blocking for a work laptop, an extra list for the
|
||||
kids' tablets.
|
||||
|
||||
**Steps** decide which lists a device gets. Each step includes the lists
|
||||
of the steps below it, plus its own:
|
||||
|
||||
| Step | Gets |
|
||||
|------|------|
|
||||
| `off` (built in) | only your rules |
|
||||
| `normal` (built in) | your rules and the normal lists; every device not in a group |
|
||||
| `strict` (one you add) | normal, plus the lists at step `strict` |
|
||||
| `very-strict` (one you add) | strict, plus the lists at step `very-strict` |
|
||||
|
||||
Every list starts at a step (normal unless you choose one), and every
|
||||
group is at a step. Your rules apply at every step, `off` included.
|
||||
|
||||
```sh
|
||||
jarl block step add strict
|
||||
jarl blocklist update social-media --step strict
|
||||
jarl block group add work 10.10.0.20 --step off
|
||||
jarl block group add kids 10.10.0.50 10.10.0.51 2001:db8:1::/64 --step strict
|
||||
jarl block group list
|
||||
```
|
||||
|
||||
In the web UI, the Blocking page's **Devices** panel holds the steps and
|
||||
the groups, and each list has a step.
|
||||
|
||||
- **Members** are addresses, networks (`10.10.0.0/24`), ranges
|
||||
(`10.10.0.20-10.10.0.49`), or ACLs made only of those (`key`, `localnets` and `!` can't be used: blocking matches
|
||||
clients by address). A device is in one group: two groups with the same
|
||||
address are refused, but a more specific address in another group wins
|
||||
(`10.10.0.0/24` in one group, `10.10.0.20` in another).
|
||||
- **Steps only add.** A group gets everything up to its step, so "kids get
|
||||
list A, guests get list B, and neither gets the other's" can't be done
|
||||
with steps. That needs a view per group, each with its own lists (and
|
||||
their memory).
|
||||
- Lists aren't copied per step: each is loaded once, and the steps cost a
|
||||
small zone each. Lists at a step no group reaches block nothing (Jarl
|
||||
warns, and shows them as "no effect").
|
||||
- named must see the device's own address. When a router hands out itself
|
||||
as DNS server and forwards to named, every query comes from the router,
|
||||
and groups can't tell devices apart. Devices also need fixed addresses
|
||||
(DHCP reservations); IPv6 privacy addresses change, so add a device's
|
||||
network, or rely on its IPv4 address.
|
||||
- `jarl block check NAME` (and the Blocking page) answers for normal
|
||||
devices, and says when a list of a higher step blocks the name.
|
||||
|
||||
## Checking a name
|
||||
|
||||
`jarl block check NAME`, or the box at the top of the Blocking page, says
|
||||
for each view whether a name is blocked or allowed, and by which rule or
|
||||
list entry:
|
||||
|
||||
```
|
||||
$ jarl block check img.ads.example.com
|
||||
view internal: blocked by blocklist hagezi-multi (entry *.ads.example.com)
|
||||
view guest: allowed by your rule 12 (entry *.ads.example.com)
|
||||
```
|
||||
|
||||
Views without blocking aren't listed.
|
||||
|
||||
In the web UI a blocked name gets an **Allow** button, and the statistics'
|
||||
most blocked names link to this check.
|
||||
|
||||
## Pausing
|
||||
|
||||
To see whether blocking is what breaks a site, pause it: every name is
|
||||
answered normally until the time is up (at most a day), then blocking
|
||||
resumes by itself, also if jarld was restarted in between. A pause can
|
||||
also last until someone resumes blocking (like Pi-hole's "indefinitely").
|
||||
|
||||
```sh
|
||||
jarl block pause # 10 minutes; or: jarl block pause 1h
|
||||
jarl block pause indefinitely
|
||||
jarl block resume # end it now
|
||||
```
|
||||
|
||||
In the web UI, **Pause blocking** is at the top of the Blocking page (with
|
||||
**Until I resume** among the lengths), and the dashboard says while
|
||||
blocking is paused. The lists stay loaded (the
|
||||
rules zone just lets everything through), so pausing and resuming take
|
||||
effect within a second. Pausing and resuming are recorded in the history
|
||||
like other changes.
|
||||
|
||||
## Statistics
|
||||
|
||||
With [query statistics](stats.md) on (including `query_log`), Jarl counts
|
||||
what blocking does: blocked queries over time (next to all queries) and
|
||||
as a share of them, which rules and lists blocked them, the most blocked
|
||||
names, and the clients with the most blocked queries (`jarl stats summary`,
|
||||
`jarl stats top blocked`, `jarl stats top blocked-clients`, `jarl stats
|
||||
breakdown blocklist`). Blocked answers are left out of the NXDOMAIN figures, which
|
||||
are about names that really don't exist.
|
||||
|
||||
The Blocking page shows the last 24 hours too: a **Blocked (24h)** column
|
||||
for each list, and one count for your rules together (named logs which
|
||||
policy zone matched, not which rule in it).
|
||||
|
||||
Without statistics named doesn't log what it blocks at all.
|
||||
|
||||
## Memory and performance
|
||||
|
||||
**Memory** is the cost to watch. named keeps each list in memory, once per
|
||||
view it applies to: about 0.6 KB per policy record (an Adblock entry is
|
||||
two records: the name, and the names below it). The Blocking page and
|
||||
`jarl blocklist list` show the estimate per list. Hagezi Pro in one view
|
||||
took named from 25 MB to 260 MB in a test with BIND 9.20; while a list
|
||||
reloads, the old and new versions briefly coexist.
|
||||
|
||||
**Answers aren't slowed**: named looks blocked names up in memory, and
|
||||
answers them without asking the internet.
|
||||
|
||||
**Updating a list** (Hagezi Pro: download, parse, check) takes jarld about
|
||||
2 seconds. While named loads the new version, the queries that the loading
|
||||
thread would have answered wait, up to half a second; this only happens
|
||||
when the list changed, at most once per `blocking.refresh`.
|
||||
|
||||
**Other changes are unaffected**: the lists' contents never go through
|
||||
Jarl's apply, so adding a record takes as long with a list as without.
|
||||
|
||||
Downloads are limited by `blocking.max_download_mb` and
|
||||
`blocking.max_entries` ([configuration.md](configuration.md#blocking)).
|
||||
|
||||
## How it works
|
||||
|
||||
For each view (or the server, without views) Jarl writes a
|
||||
`response-policy` statement and the policy zones it lists, in this order:
|
||||
|
||||
```
|
||||
response-policy {
|
||||
zone "rules.jarl.invalid" min-update-interval 0; # your rules
|
||||
zone "hagezi-multi.blocklist.jarl.invalid" min-update-interval 0;
|
||||
} qname-wait-recurse no break-dnssec yes;
|
||||
```
|
||||
|
||||
- Policy zones are named under `.invalid`, so they can't clash with real
|
||||
zones (zone names under `jarl.invalid` are refused). They allow no
|
||||
queries or transfers.
|
||||
- The rules zone of each view is a file next to your zone files
|
||||
(`/var/lib/bind/jarl/VIEW/rules.jarl.invalid.rpz`) and shows up in the
|
||||
changeset diff like any other.
|
||||
- A list's file is `/var/cache/bind/jarl/blocklists/list-ID.rpz`, shared by
|
||||
the views that use it. Until the first download it is an empty policy
|
||||
zone, so named always starts. Files of deleted lists are removed.
|
||||
- `qname-wait-recurse no` blocks without first resolving the real answer;
|
||||
`break-dnssec yes` also blocks for clients that ask for DNSSEC data;
|
||||
`min-update-interval 0` applies a new version at once.
|
||||
- named looks names up in a policy zone like in any zone, so a wildcard
|
||||
doesn't apply below a name the zone also contains. Jarl writes the zones
|
||||
so that the most specific rule still wins.
|
||||
- A `response-policy` option of your own can't be combined with Jarl's
|
||||
blocking in the same view: Jarl refuses the change and says so.
|
||||
- Rewrites are logged to Jarl's response log when statistics read it, and
|
||||
not at all otherwise.
|
||||
- With client groups, a **gate** zone stands in front of the lists of each
|
||||
step: `off.gate.jarl.invalid` before the normal lists,
|
||||
`STEP.gate.jarl.invalid` before a step's. named checks the zones in
|
||||
order and the first match decides; a gate matches the devices below its
|
||||
step by address (`rpz-client-ip` triggers) with `rpz-passthru`, so they
|
||||
never reach the lists behind it. A gate is never logged, and its file is
|
||||
next to the rules zone (`/var/lib/bind/jarl/VIEW/strict.gate.jarl.invalid.rpz`).
|
||||
For "everyone except these devices", a gate lists the address ranges
|
||||
around them, at most a few hundred entries. named matches IPv4 clients as
|
||||
IPv4-mapped IPv6 addresses, so the IPv6 part leaves `::ffff:0:0/96` out.
|
||||
|
||||
Lists and rules need the `blocking` permission ([permissions.md](permissions.md)).
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- **"no domains found in N lines"**: the URL didn't return a list (often an
|
||||
HTML error page). Check it in a browser.
|
||||
- **A list has no effect / is "idle"**: no view it applies to offers
|
||||
recursion. Pick the view your devices use (`--view internal`).
|
||||
- **Something is still answered**: `jarl block check NAME` shows whether
|
||||
Jarl blocks it. If it does, the device probably doesn't use your server
|
||||
(DNS over HTTPS, another resolver), or has the answer cached for a
|
||||
while.
|
||||
- **Something you need is blocked**: `jarl block check NAME` shows which
|
||||
list; `jarl block allow NAME` overrides it.
|
||||
@@ -0,0 +1,416 @@
|
||||
# TLS certificates
|
||||
|
||||
Jarl can obtain TLS certificates from an ACME CA (Let's Encrypt by
|
||||
default) and renew them itself, and put them where they are used:
|
||||
|
||||
- **web**: jarld's own HTTPS (the web UI, the API and the CLI's server).
|
||||
- **dns**: files named can read, for DNS over TLS (DoT) and DNS over HTTPS
|
||||
(DoH) in named's `tls` blocks.
|
||||
|
||||
One certificate can have both uses. Jarl answers the ACME DNS-01
|
||||
challenge itself: in a zone it serves, or through Porkbun's API while
|
||||
Porkbun still serves the domain. So no other ACME client, hook script or
|
||||
DNS provider plugin is needed, and the server doesn't have to be
|
||||
reachable on port 80 or 443 from the internet.
|
||||
|
||||
A certificate you renew some other way (certbot, acme.sh, your own CA)
|
||||
can be used too: Jarl reads it from its files and rereads it when they
|
||||
change.
|
||||
|
||||
In the web UI: **Certificates** (administrators). In the CLI: `jarl cert`.
|
||||
In the API: `/api/v1/certificates`. All of it is for administrators only:
|
||||
a certificate's key can impersonate your server.
|
||||
|
||||
## Quick start
|
||||
|
||||
1. Set up the ACME account in `/etc/jarl/jarl.yaml` and restart jarld
|
||||
(`systemctl restart jarl`). Try Let's Encrypt's staging CA first: its
|
||||
certificates aren't trusted by browsers, but its rate limits are
|
||||
generous, so mistakes cost nothing.
|
||||
|
||||
```yaml
|
||||
acme:
|
||||
account:
|
||||
directory: letsencrypt-staging # then: letsencrypt
|
||||
email: you@adbraun.lol # optional
|
||||
agree_tos: true # after reading https://letsencrypt.org/repository/
|
||||
```
|
||||
|
||||
2. See where the challenges would go, without asking the CA for anything:
|
||||
|
||||
```sh
|
||||
jarl cert check --domain ns1.adbraun.lol
|
||||
```
|
||||
|
||||
```
|
||||
NAME ROUTE RECORD WHY
|
||||
ns1.adbraun.lol porkbun _acme-challenge.ns1.adbraun.lol adbraun.lol is served by Porkbun (curitiba.ns.porkbun.com, ...), so the record goes in through Porkbun's API
|
||||
```
|
||||
|
||||
3. Create the certificate. Jarl obtains it at once; the command waits for
|
||||
the outcome (usually under a minute, a few minutes through Porkbun):
|
||||
|
||||
```sh
|
||||
jarl cert create ns1 --domain ns1.adbraun.lol --use web --use dns
|
||||
```
|
||||
|
||||
4. When it works, switch `directory` to `letsencrypt`, restart jarld and
|
||||
renew: `jarl cert issue ns1`.
|
||||
|
||||
From then on jarld renews it by itself.
|
||||
|
||||
## The ACME account
|
||||
|
||||
| Setting | Default | |
|
||||
|--|--|--|
|
||||
| `acme.account.directory` | `letsencrypt` | `letsencrypt`, `letsencrypt-staging`, or the directory URL of another ACME CA (ZeroSSL, Google, step-ca, ...). |
|
||||
| `acme.account.email` | `""` | Contact address for the CA (optional; Let's Encrypt no longer sends expiry mails). |
|
||||
| `acme.account.agree_tos` | `false` | You accept the CA's terms of service. Jarl registers no account without it, and says so. |
|
||||
| `acme.account.eab_kid`, `eab_hmac` | `""` | External Account Binding, for CAs that need it (ZeroSSL, Google): from the CA's dashboard. |
|
||||
| `acme.resolvers` | `1.1.1.1:53`, `9.9.9.9:53`, `8.8.8.8:53` | Recursive resolvers Jarl uses to see a domain as the CA does (below). |
|
||||
| `acme.ca_bundle` | `""` | A PEM file of extra CA certificates to trust when talking to the ACME server (a private CA). |
|
||||
| `acme.check_propagation` | `true` | Before telling the CA to look, wait until every name server of the zone serves the challenge (asked directly on port 53). |
|
||||
| `certificates.dir` | `<bind.config_dir>/tls` | Where certificates with the dns use are written for named. |
|
||||
|
||||
The account is registered with the first certificate, and its key is
|
||||
kept in Jarl's database. Changing `directory` registers a new account with
|
||||
that CA; changing `email` updates the account.
|
||||
|
||||
## Where the challenge goes
|
||||
|
||||
For each name on a certificate, the CA looks up a TXT record at
|
||||
`_acme-challenge.<name>` (a wildcard `*.example.com` uses
|
||||
`_acme-challenge.example.com`). Jarl first looks the name up the way the
|
||||
CA will, through `acme.resolvers` (public resolvers, not this server,
|
||||
which may answer from an internal view), following a CNAME on
|
||||
`_acme-challenge` if there is one. Then it finds the zone that holds the
|
||||
record on the internet and that zone's public name servers:
|
||||
|
||||
| The zone's public name servers | Route | Jarl writes the record |
|
||||
|--|--|--|
|
||||
| Porkbun's (`*.ns.porkbun.com`), with `porkbun.api_key` set | `porkbun` | through Porkbun's DNS API (TTL 600, Porkbun's minimum), and deletes it afterwards. The key is the one the registrar features use ([configuration.md](configuration.md#porkbun-registrar-and-dns-host)); it needs DNS record write access, "API Access" switched on for the domain, and the domain in `porkbun.domains` if you set that |
|
||||
| the name servers in Jarl's own zone of that name | `jarl` | in that zone, in every view that has it, as a change by `system` (in the history) |
|
||||
| anything else | none | nowhere: the certificate fails with what to do |
|
||||
|
||||
The certificate's `challenge` setting chooses:
|
||||
|
||||
- **`auto`** (the default) decides again at **every** issue and renewal.
|
||||
While Porkbun serves `adbraun.lol`, the challenge goes to Porkbun. Once
|
||||
you point the domain's name servers at your own servers, the next
|
||||
renewal writes into Jarl's zone instead. Nothing needs changing.
|
||||
- **`jarl`** or **`porkbun`** pins the route. Jarl still checks that the
|
||||
CA will see the record there and refuses to try otherwise (no wasted
|
||||
attempt against the CA's rate limits).
|
||||
|
||||
```sh
|
||||
jarl cert update ns1 --challenge porkbun # or jarl, or auto
|
||||
```
|
||||
|
||||
`jarl cert check NAME` (or **Check the names** in the web UI) shows the
|
||||
route for each name, and why, at any time.
|
||||
|
||||
### Domains Jarl can't write to: a CNAME
|
||||
|
||||
For a domain whose DNS is elsewhere (Cloudflare, say), point the
|
||||
challenge name at a zone this server serves on the internet, once, by
|
||||
hand:
|
||||
|
||||
```
|
||||
_acme-challenge.www.example.org. CNAME www-example-org.acme.adbraun.lol.
|
||||
```
|
||||
|
||||
Jarl follows the CNAME and writes the record at its target, in its zone
|
||||
`acme.adbraun.lol` (which must be delegated to this server). Renewals
|
||||
never touch the other provider again. That is the same idea as
|
||||
[acme.md section 7](acme.md#7-a-dedicated-acme-zone-cname-delegation).
|
||||
|
||||
## Renewal
|
||||
|
||||
jarld looks at every certificate every 10 minutes:
|
||||
|
||||
- A certificate Jarl obtains is renewed at the time the CA suggests
|
||||
(ACME Renewal Information, RFC 9773; asked twice a day, so a CA that
|
||||
needs to replace certificates early, e.g. after revoking them, gets its
|
||||
way), or, without one, two thirds into its lifetime: day 60 of a 90-day
|
||||
certificate. Each renewal makes a new key.
|
||||
- When obtaining fails, jarld tries again after 1, 2, 4, 8, 16 hours, then
|
||||
daily. The dashboard and the Certificates page show the error; so do
|
||||
`jarl cert list` and Prometheus (`jarl_certificate_failing`).
|
||||
- New names or a new key type on a certificate make it due at once.
|
||||
- `jarl cert issue NAME` (**Renew** in the web UI) obtains it now. It runs
|
||||
in the background: the command waits and reports, the web page updates
|
||||
itself.
|
||||
|
||||
A certificate counts as **expiring** (a warning) when less than a sixth of
|
||||
its lifetime is left, which for a renewing certificate means renewal has
|
||||
been failing for a while; and **expired** after its end.
|
||||
|
||||
## Use: web (jarld's HTTPS)
|
||||
|
||||
Only one certificate can have the web use. As soon as it has been
|
||||
obtained, jarld serves it on all its `listen` addresses, **without a
|
||||
restart**: each port speaks HTTPS and plain HTTP side by side.
|
||||
|
||||
- HTTPS connections get the certificate (HTTP/2 included).
|
||||
- Plain HTTP from this server itself (`127.0.0.1`, `::1`) is served as
|
||||
before, so local scripts and `--server http://127.0.0.1:8053` keep
|
||||
working. So is plain HTTP from `trusted_proxies`.
|
||||
- Plain HTTP from anywhere else: browsers are redirected to
|
||||
`https://<the same host and port>`; API requests get `400
|
||||
https_required` (and, if they carried a token, a note that it crossed
|
||||
the network unencrypted).
|
||||
|
||||
Point browsers and CLIs at the certificate's name:
|
||||
`jarl config set server https://ns1.adbraun.lol:8053`. A CLI that uses the
|
||||
server's IP address fails the name check.
|
||||
|
||||
A managed certificate takes over from `tls.cert_file`. Taking the web use
|
||||
off (or deleting the certificate) goes back to `tls.cert_file`, or to
|
||||
plain HTTP.
|
||||
|
||||
## Use: dns (for named)
|
||||
|
||||
Jarl writes the certificate to `<certificates.dir>/<name>/`:
|
||||
|
||||
```
|
||||
/etc/bind/jarl/tls/ns1/fullchain.pem (0644)
|
||||
/etc/bind/jarl/tls/ns1/privkey.pem (0640, group bind)
|
||||
```
|
||||
|
||||
Debian's AppArmor profile lets named read `/etc/bind`, and group `bind`
|
||||
lets it read the key. When a renewal changes the files and named's
|
||||
configuration names them, Jarl runs `rndc reconfig`, and named serves the
|
||||
new certificate at once (checked with BIND 9.20: DoT keeps working, with
|
||||
the new certificate).
|
||||
|
||||
`jarl encrypted-dns setup` sets up DNS over TLS and HTTPS with it
|
||||
([encrypted-dns.md](encrypted-dns.md)). By hand, a `tls` statement names
|
||||
the files:
|
||||
|
||||
```sh
|
||||
jarl option set tls 'dot { cert-file "/etc/bind/jarl/tls/ns1/fullchain.pem"; key-file "/etc/bind/jarl/tls/ns1/privkey.pem"; }' --toplevel
|
||||
jarl option set listen-on 'port 853 tls dot { 10.0.10.5; }' --add
|
||||
```
|
||||
|
||||
The Certificates page (and `jarl cert show`) says what named uses each
|
||||
for: `DNS over TLS on 10.0.10.5, fd00::5 port 853`, `DNS over HTTPS on
|
||||
… port 443`, or a hand-written `tls` statement no listener uses.
|
||||
|
||||
A certificate named uses can't be deleted or lose its `dns` use until
|
||||
named no longer names its files. If you change `certificates.dir`, jarld
|
||||
writes the certificates to the new place when it starts and points the
|
||||
`tls` statements encrypted DNS wrote there (a hand-written `tls`
|
||||
statement you change yourself).
|
||||
|
||||
## A certificate you renew yourself
|
||||
|
||||
```sh
|
||||
jarl cert create own --cert-file /etc/ssl/ns1/fullchain.pem --key-file /etc/ssl/ns1/privkey.pem --use web
|
||||
```
|
||||
|
||||
Jarl checks that the key belongs to the certificate, warns when the file
|
||||
holds only the certificate without its intermediates (use the full
|
||||
chain), and rereads both files when they change (within 10 minutes, or at
|
||||
once with `jarl cert issue own`). A pair that doesn't match is reported
|
||||
and the last good one stays in use. jarld runs as user `jarl`, which must
|
||||
be able to read both files (e.g. `chgrp jarl` and `chmod 640`, or have
|
||||
your renewal hook copy them somewhere it can read).
|
||||
|
||||
## Using a certificate outside Jarl
|
||||
|
||||
A certificate needn't be used by Jarl at all ("not used by Jarl" on the
|
||||
Certificates page): Jarl obtains and renews it, and you export it for
|
||||
another program, such as a mail server or a reverse proxy. **Export…** on
|
||||
the Certificates page downloads each file or copies it to the clipboard
|
||||
(copying needs the web UI over HTTPS, or from localhost). Or:
|
||||
|
||||
```sh
|
||||
jarl cert export mail --dir /etc/ssl/mail # fullchain.pem, and privkey.pem (mode 600)
|
||||
jarl cert export mail --part bundle > /etc/haproxy/mail.pem
|
||||
```
|
||||
|
||||
The parts are `fullchain` (the certificate and its chain), `cert` (the
|
||||
certificate alone), `key` and `bundle` (the key, then the chain, in one
|
||||
file, as HAProxy wants). Administrators can export any certificate, and
|
||||
the owner of a certificate for another host (below) their own. Every
|
||||
export of the key is in the audit log (`certificate.export_key`), and a key
|
||||
only goes over HTTPS, the local socket, or to this server itself.
|
||||
|
||||
Jarl renews the certificate, not your copy: export it again after each
|
||||
renewal (`jarl cert show` says when that is). For another host, `jarl
|
||||
cert get` (below) does that for you. A program on this server can instead
|
||||
read the files Jarl writes for named (use `dns`), which Jarl keeps current.
|
||||
|
||||
## Certificates for other hosts
|
||||
|
||||
`jarl cert get` gets a certificate onto another host (a mail server, a
|
||||
reverse proxy) with one command: jarld obtains it from the CA and renews
|
||||
it, answering the challenges in its own zones as for its own certificates;
|
||||
the host asks for it, waits until it is there, downloads it and runs a
|
||||
hook. The host needs only a token, not an ACME client or access to DNS.
|
||||
|
||||
**On Jarl**, as an administrator, give the hosts a user and a token each:
|
||||
|
||||
```sh
|
||||
jarl user create hosts --no-password --grant "certificates:write names=*.adbraun.lol"
|
||||
jarl token create mx --user hosts # one token per host
|
||||
```
|
||||
|
||||
The `certificates` grant says which names the user may have certificates
|
||||
for. It is never part of `*`, so `operator` and `readonly` users can't
|
||||
get certificates (or download keys) unless you add one. A certificate
|
||||
belongs to the user that asked for it: its tokens can download it, other
|
||||
users can't take its name or see it, and the certificates Jarl uses
|
||||
itself are the administrators'. Hosts that should share certificates use
|
||||
the same user (each with its own token, so you can revoke one host
|
||||
alone); hosts that shouldn't, use different users. A user may own
|
||||
`certificates.max_per_user` certificates (10 by default), or what `jarl
|
||||
user update NAME --cert-limit N` (or the user's form) sets.
|
||||
|
||||
**On the host:**
|
||||
|
||||
```sh
|
||||
export JARL_SERVER=https://jarl.adbraun.lol:8053 JARL_TOKEN=@/root/jarl.token
|
||||
jarl cert get mail -d mail.adbraun.lol -d smtp.adbraun.lol --hook 'systemctl reload postfix dovecot'
|
||||
sudo jarl cert schedule # a daily systemd timer runs 'jarl cert get'
|
||||
jarl cert saved # what this host has, and when it expires
|
||||
jarl cert saved mail # all of mail's settings
|
||||
```
|
||||
|
||||
* **Asking again** with the same names returns the certificate jarld has:
|
||||
nothing is obtained, nothing is downloaded until jarld renews it.
|
||||
**Adding or removing a name** gets a new certificate. A different
|
||||
`--key-type` alone doesn't; delete the certificate for that.
|
||||
* **Files**, in `--dir` (default `/etc/jarl/certs/NAME` as root, `./NAME`
|
||||
otherwise): `cert.pem`, `chain.pem` (the intermediates),
|
||||
`fullchain.pem`, `privkey.pem` (mode 600) and `jarl-cert.json` (serial,
|
||||
expiry, when it was fetched). Each file is replaced at once, never
|
||||
half written. The key is checked against the certificate before
|
||||
anything is written.
|
||||
* **A combined file** (`--combined PATH`): the key, then the full chain,
|
||||
in one file (mode 600), as HAProxy wants. See [HAProxy](#haproxy).
|
||||
* **Saved settings**: the names, `--dir`, `--combined`, `--hook`, the
|
||||
server and the token are saved in `/etc/jarl/cert-get/NAME.json` (as
|
||||
root; `~/.config/jarl/cert-get` otherwise), readable only by their
|
||||
owner. `jarl cert get` without a name does every saved certificate. A
|
||||
token given as `@FILE` is saved as that reference, not copied. `jarl
|
||||
cert saved NAME` shows them all (the token never). `jarl cert forget
|
||||
NAME` stops getting one (`--delete` deletes it on jarld too).
|
||||
* **Changing a setting**: run `jarl cert get NAME` with just that option
|
||||
(`--hook '...'`, `--combined PATH`, `--dir DIR`); the others stay.
|
||||
`--hook ''` removes the hook, `--combined ''` stops writing the
|
||||
combined file and deletes it. The run still asks jarld for the
|
||||
certificate, but downloads nothing while this host has the current one.
|
||||
A new or moved combined file is written from the files this host
|
||||
already has, and the hook runs.
|
||||
* **`--local`** doesn't ask jarld at all: it writes the combined file and
|
||||
runs the hook from the files this host has. Use it to apply a changed
|
||||
setting while jarld is unreachable, or to test a hook. New names still
|
||||
need jarld.
|
||||
* **Several Jarl servers on one host**: each server has its own
|
||||
certificate names, so two servers may both have a `proxy`. The host
|
||||
keeps one `proxy` per settings directory, though: asking another server
|
||||
for a name saved here for the first one is refused, as is a `--dir`
|
||||
holding another certificate's files. Choose another name or `--dir`, or
|
||||
pass `--replace` to take the name (or directory) over. A `--dir` that
|
||||
another saved certificate uses is always refused.
|
||||
* **The hook** runs with `sh` after new files are written or the
|
||||
combined file changes (and with `--local`), with `JARL_CERT_NAME`,
|
||||
`JARL_CERT_DIR`, `JARL_CERT_CERT`, `JARL_CERT_CHAIN`,
|
||||
`JARL_CERT_FULLCHAIN`, `JARL_CERT_KEY`, `JARL_CERT_COMBINED` (empty
|
||||
without `--combined`), `JARL_CERT_DOMAINS`, `JARL_CERT_SERIAL`,
|
||||
`JARL_CERT_NOT_AFTER` and `JARL_CERT_RENEWED` (1 when it replaced an
|
||||
older certificate). If it fails, `jarl cert get` exits non-zero and runs
|
||||
it again next time. It has 5 minutes.
|
||||
* **`jarl cert schedule`** (Linux with systemd, as root) writes
|
||||
`/etc/systemd/system/jarl-cert-get.service` and `.timer`, which run
|
||||
`jarl cert get` daily at a random time within the hour after midnight,
|
||||
and catch up after the host was off. `--remove` takes them away. On
|
||||
other systems, run `jarl cert get` daily with their scheduler.
|
||||
|
||||
**On the Certificates page**, the administrators see each certificate's
|
||||
owner and who downloaded it: the token, from where, when, and whether
|
||||
that host has the current certificate or hasn't fetched the renewed one
|
||||
yet. `jarl cert show NAME` says the same. The owner's own tokens see
|
||||
their own downloads there too. In `jarl cert list`, such a certificate's
|
||||
USES is `get`, and PULLED is the last download, with `(old)` while a host
|
||||
hasn't fetched the renewed one.
|
||||
|
||||
### HAProxy
|
||||
|
||||
HAProxy wants the key and the chain in one file. Give `jarl cert get` a
|
||||
`--combined` path outside `--dir`:
|
||||
|
||||
```sh
|
||||
jarl cert get proxy -d adbraun.lol -d '*.adbraun.lol' \
|
||||
--combined /etc/haproxy/certs/adbraun.lol.pem --hook 'systemctl reload haproxy'
|
||||
jarl cert get webmail -d webmail.adbraun.lol \
|
||||
--combined /etc/haproxy/certs/webmail.pem --hook 'systemctl reload haproxy'
|
||||
```
|
||||
|
||||
```
|
||||
frontend https
|
||||
bind :443 ssl crt /etc/haproxy/certs/
|
||||
```
|
||||
|
||||
HAProxy loads every file in a `crt` directory and picks one by SNI, so
|
||||
`/etc/haproxy/certs` should hold only combined files. Don't point `crt`
|
||||
at `--dir` (`/etc/jarl/certs/NAME`): HAProxy would try to load
|
||||
`cert.pem`, `privkey.pem` and `jarl-cert.json` there too, and refuse
|
||||
to start. A `--combined` path that would overwrite a file in a saved
|
||||
certificate's directory, or another certificate's combined file, is
|
||||
refused.
|
||||
|
||||
The combined file is mode 600, owned by the user running `jarl cert get`
|
||||
(root under the timer). HAProxy reads its certificates as root before it
|
||||
drops privileges, so that's fine.
|
||||
|
||||
To add a combined file to a certificate this host already has, run
|
||||
`jarl cert get proxy --combined /etc/haproxy/certs/adbraun.lol.pem`: it's
|
||||
written from the files on disk and the hook runs, with nothing
|
||||
downloaded.
|
||||
|
||||
## Security notes
|
||||
|
||||
- Certificates Jarl obtains, their keys and the ACME account key are kept
|
||||
in Jarl's database, so they are in its backups (like TSIG secrets). The
|
||||
API returns a key only to an administrator who exports it, and records
|
||||
that in the audit log.
|
||||
- Every publicly trusted certificate is published in Certificate
|
||||
Transparency logs, with all its names. A name like `ns1.adbraun.lol`
|
||||
used for both the web UI and DNS reveals less than separate names. Avoid
|
||||
wildcards where one name will do: a wildcard's key is good for every
|
||||
name in the domain.
|
||||
- Using one certificate for jarld and named puts its key in two places.
|
||||
On one server, where jarld already writes named's configuration, that
|
||||
changes little.
|
||||
|
||||
## API
|
||||
|
||||
| Method | Path | |
|
||||
|--|--|--|
|
||||
| GET | `/certificates` | `certificates` (each with `status` {`state`: ok, pending, failing, expiring, expired; `message`}, `next_attempt`, `issuing_since` while being obtained, `serving`, `files`) and `account` |
|
||||
| POST | `/certificates` | `{"name": "ns1", "domains": [...], "uses": ["web", "dns"], "challenge": "auto", "key_type": "ec256"}`, or `{"name", "source": "file", "cert_file", "key_file", "uses"}`. One Jarl obtains starts being obtained at once (`"issue": false` leaves it to the loop) |
|
||||
| POST | `/certificates/check` | `{"domains": [...], "challenge": "auto"}`: where each challenge would go (`routes`: `route`, `target`, `zone`, `public_ns`, `why` or `error`) |
|
||||
| GET, PATCH, DELETE | `/certificates/{name}` | `domains` and `uses` replace the whole list |
|
||||
| POST | `/certificates/{name}/issue` | obtain now: 202, then poll `GET /certificates/{name}` until `issuing_since` is gone; a file certificate is reread (200) |
|
||||
| POST | `/certificates/{name}/check` | the routes for an existing certificate |
|
||||
| GET | `/certificates/{name}/pem?part=fullchain` | the certificate as PEM, to save: `fullchain` (default), `cert`, `key` or `bundle` (key and chain). Exports of the key are audited |
|
||||
|
||||
## Testing with Pebble
|
||||
|
||||
[Pebble](https://github.com/letsencrypt/pebble) is Let's Encrypt's test
|
||||
CA. Point it at a test named, and Jarl at it:
|
||||
|
||||
```sh
|
||||
docker run -d --name pebble --network host -e PEBBLE_VA_NOSLEEP=1 \
|
||||
ghcr.io/letsencrypt/pebble -config test/config/pebble-config.json -dnsserver 127.0.0.1:53
|
||||
docker cp pebble:/test/certs/pebble.minica.pem /etc/jarl/pebble.pem
|
||||
```
|
||||
|
||||
```yaml
|
||||
acme:
|
||||
account: {directory: "https://localhost:14000/dir", agree_tos: true}
|
||||
resolvers: ["127.0.0.1:53"]
|
||||
ca_bundle: /etc/jarl/pebble.pem
|
||||
```
|
||||
+280
-17
@@ -41,11 +41,22 @@ jarl whoami
|
||||
| API token (`@FILE` reads it from a file) | `--token` | `JARL_TOKEN` | `token` |
|
||||
| Skip TLS verification | `--insecure` | | `insecure` |
|
||||
| Extra trusted CA (PEM) | `--ca-cert` | | `ca_cert` |
|
||||
| Changeset to stage into | `-C/--changeset` | `JARL_CHANGESET` | |
|
||||
| Changeset to stage into (and the default for `jarl changeset diff/apply/...`) | `-C/--changeset` | `JARL_CHANGESET` (checked: open and yours, see [Changesets](#changesets-stage-review-apply)) | |
|
||||
|
||||
Flags win over the environment, and the environment wins over the file. The
|
||||
file is `~/.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,12 @@ 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. |
|
||||
| `--accept-risk` | Confirm a change that opens a public view up, such as an open resolver ([exposure.md](exposure.md)). Without it such a change stops with exit 5. |
|
||||
| `-q, --quiet` | Print only errors, or just the IDs: of what a command creates, or what `show` and `list` find (like `-o id`). |
|
||||
|
||||
## Exit codes
|
||||
|
||||
@@ -139,7 +152,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 |
|
||||
@@ -164,18 +177,35 @@ jarl zone create lab.adbraun.lol --ns ns1.adbraun.lol.
|
||||
jarl zone create 192.168.20.0/24 # reverse zone from a network
|
||||
jarl zone create 10.0.8.0/22 # four zones, 8-11.0.10.in-addr.arpa
|
||||
jarl zone create 10.0.10.100/32 # a zone for just that address
|
||||
jarl zone create 10.0.10.0/24 --fill # in the view whose records use it, with their PTRs
|
||||
jarl zone fill-ptr 10.0.10.0/24 # add the PTRs it lacks (zone commands take a network too)
|
||||
jarl zone fill-ptr 10.0.10.0/24 --view vps --other-views # also name addresses only other views' records use
|
||||
jarl zone suggest-reverse [--create] # reverse zones your private views lack (--create: yours)
|
||||
jarl zone suggest-reverse --dismiss 185.199.108.153/32,185.199.109.153/32 --view internal # not yours: don't suggest them again
|
||||
jarl zone suggest-reverse --all [--view V] # every network without a reverse zone, also the hidden ones
|
||||
jarl view update vps --reverse-suggestions=false # no suggestions for this view
|
||||
jarl zone update adbraun.lol --auto-ptr # keep PTRs in step with the A/AAAA records (docs/reverse.md)
|
||||
jarl zone create adbraun.lol --view external --clone-from adbraun.lol --clone-view internal
|
||||
jarl zone create partner.example --type secondary --primary "192.0.2.53 key xfer"
|
||||
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
|
||||
@@ -189,6 +219,20 @@ router, answers for the rest of the /24. IPv4 networks smaller than a /24
|
||||
are deleted. Try it with `--dry-run` first. `--option "NAME VALUE"` on
|
||||
`zone create` sets any zone option in the same step.
|
||||
|
||||
Linked zones ([views.md](views.md#linked-zones)) keep their shared
|
||||
records in step across views:
|
||||
|
||||
```sh
|
||||
jarl zone link adbraun.lol --view internal --with external
|
||||
jarl zone create adbraun.lol --view vps --link-with external
|
||||
jarl zone unlink adbraun.lol --view vps
|
||||
jarl record add adbraun.lol nas A 10.0.10.20 --view internal --local # this view only
|
||||
jarl record set adbraun.lol www A 10.0.10.5 --view internal --local # override the shared www A here
|
||||
jarl record update 1234 --local | --shared
|
||||
jarl zone diff adbraun.lol [--view V] [--with W] [--all] [--wide] # compared with the same zone in other views
|
||||
jarl zone diff adbraun.lol nas [A] # just one name
|
||||
```
|
||||
|
||||
When a zone exists in several views, add `--view NAME` to commands that
|
||||
name it.
|
||||
|
||||
@@ -200,10 +244,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 +290,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
|
||||
@@ -227,10 +317,23 @@ adds the quotes and splits long values into 255-byte strings. In reverse zones t
|
||||
host name (a bare `nas` is refused, since it would mean
|
||||
`nas.0.10.10.in-addr.arpa.`). `--ptr`
|
||||
keeps the PTR record in the reverse zone in step (when that zone is managed
|
||||
by Jarl). Every record type BIND supports can be used. Jarl knows the
|
||||
by Jarl); `jarl record update ID --ptr` with nothing else adds the PTR record
|
||||
if it is missing, e.g. after creating the reverse zone. In linked zones, the
|
||||
reverse zone of each linked view that has the record counts
|
||||
([reverse.md](reverse.md#keeping-ptr-records-in-step)). 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 +348,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]}
|
||||
@@ -267,9 +371,9 @@ reads standard input.
|
||||
export JARL_CHANGESET=$(jarl changeset create "renumber lab" -q)
|
||||
jarl record set lab.adbraun.lol gw A 10.20.0.1
|
||||
jarl record set lab.adbraun.lol nas A 10.20.0.10
|
||||
jarl changeset diff $JARL_CHANGESET # validation + named-checkconf + diff
|
||||
jarl changeset remove $JARL_CHANGESET 2 # drop operation #2
|
||||
jarl changeset apply $JARL_CHANGESET
|
||||
jarl changeset diff # validation + named-checkconf + diff
|
||||
jarl changeset remove 2 # drop operation #2
|
||||
jarl changeset apply
|
||||
unset JARL_CHANGESET
|
||||
|
||||
jarl changeset list [--status open|applied|discarded] [--mine]
|
||||
@@ -279,8 +383,87 @@ jarl history [--limit 50]
|
||||
jarl changeset revert 42 -q | xargs jarl changeset apply # undo an applied changeset
|
||||
```
|
||||
|
||||
`show`, `diff`, `apply`, `discard` and `remove` work on the changeset you
|
||||
name, else the one in `-C`, else the one in `JARL_CHANGESET`.
|
||||
|
||||
A changeset from `JARL_CHANGESET` is checked before anything uses it,
|
||||
because an exported ID easily outlives its changeset, and IDs keep growing
|
||||
(every change, live ones included, gets the next one), so a stale or
|
||||
mistyped ID can later name a changeset that isn't yours:
|
||||
|
||||
- Staging into it, and `diff`, `apply`, `discard` and `remove` on it, need
|
||||
it to be open and yours. Otherwise the command stops and says why
|
||||
(`there is no changeset 99`, `changeset 42 is applied`, `changeset 57 is
|
||||
certbot's`) and what to do. Commands that only read (`jarl zone list`)
|
||||
and `jarl changeset create` don't care, so you can always start a new one.
|
||||
- Each command says on stderr which changeset it uses: `changeset 42 (renumber
|
||||
lab, 2 operations) from JARL_CHANGESET` (not with `-q`).
|
||||
- After `apply` or `discard`, a reminder to `unset JARL_CHANGESET`: the CLI
|
||||
can't change your shell's environment, and later changes would fail to
|
||||
stage.
|
||||
|
||||
`-C ID` and an ID argument are used as given, without these checks (jarld
|
||||
still refuses a changeset that isn't open, or another user's unless you are
|
||||
an administrator).
|
||||
|
||||
Changesets staged in the web UI show up here too, and the other way round.
|
||||
|
||||
## Internet exposure
|
||||
|
||||
```sh
|
||||
jarl exposure # public views, findings, fixes, rate limiting
|
||||
jarl exposure fix external exposure_rate_limit # rate limiting, log-only first
|
||||
jarl exposure fix external exposure_rate_limit_log_only # then start enforcing
|
||||
jarl exposure fix server exposure_version
|
||||
jarl exposure mail # reverse DNS of the addresses that send and receive mail
|
||||
jarl exposure ack external security_open_resolver --reason "public resolver, monitored"
|
||||
jarl exposure unack external security_open_resolver
|
||||
jarl view update external --exposure public|private|auto
|
||||
```
|
||||
|
||||
See [exposure.md](exposure.md).
|
||||
|
||||
## Encrypted DNS
|
||||
|
||||
```sh
|
||||
jarl encrypted-dns # what is set up, what to give clients
|
||||
jarl encrypted-dns setup --hostname dns.adbraun.lol --address 10.0.10.5 [--address IPv6] [--doh-port 8443] [--no-doh]
|
||||
jarl encrypted-dns check --address 10.0.10.5 --doh-port 443
|
||||
jarl encrypted-dns health # check the listeners now, as a client would
|
||||
jarl encrypted-dns disable
|
||||
```
|
||||
|
||||
See [encrypted-dns.md](encrypted-dns.md).
|
||||
|
||||
## Certificates
|
||||
|
||||
```sh
|
||||
jarl cert check --domain ns1.adbraun.lol # where each challenge would go, and why
|
||||
jarl cert create ns1 --domain ns1.adbraun.lol --use web --use dns # obtained at once; waits
|
||||
jarl cert create wild -d adbraun.lol -d '*.adbraun.lol' --use dns --key-type ec384
|
||||
jarl cert create own --cert-file /etc/ssl/ns1/fullchain.pem --key-file /etc/ssl/ns1/privkey.pem --use web
|
||||
jarl cert list
|
||||
jarl cert show ns1
|
||||
jarl cert issue ns1 # renew now (--no-wait to return at once)
|
||||
jarl cert update ns1 --challenge porkbun # auto, jarl or porkbun
|
||||
jarl cert update ns1 --use dns # --use and --domain replace the list (--use none)
|
||||
jarl cert export mail --dir /etc/ssl/mail # fullchain.pem and privkey.pem, for another program
|
||||
jarl cert export mail --part bundle # one part to stdout: fullchain, cert, key, bundle
|
||||
jarl cert get mail -d mail.adbraun.lol --hook 'systemctl reload postfix' # on another host: get, download, hook
|
||||
jarl cert get # every saved certificate (what the timer runs)
|
||||
jarl cert get proxy -s other --replace # take over a name saved here for another server
|
||||
jarl cert get proxy --combined /etc/haproxy/certs/proxy.pem # also key + chain in one file (HAProxy); '' stops it
|
||||
jarl cert get proxy --hook 'systemctl reload haproxy' --local # change a setting; apply it from this host's files, without jarld
|
||||
jarl cert saved # this host's certificates and their expiry
|
||||
jarl cert saved proxy # all of one certificate's settings (never the token)
|
||||
jarl cert forget mail [--delete] # stop getting it here
|
||||
sudo jarl cert schedule [--remove] # daily systemd timer for jarl cert get
|
||||
jarl cert delete ns1
|
||||
jarl cert account # the ACME account (set in jarl.yaml)
|
||||
```
|
||||
|
||||
Administrators only. See [certificates.md](certificates.md).
|
||||
|
||||
## Views, ACLs, TSIG keys, options, settings
|
||||
|
||||
```sh
|
||||
@@ -289,8 +472,11 @@ 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 create dhcp 10.0.10.101-10.0.10.199 2001:db8::100-2001:db8::1ff # ranges (concepts.md)
|
||||
jarl acl update homelab --add 10.20.0.0/16 --remove 10.10.0.0/16
|
||||
jarl acl update homelab --rename lan # references are updated
|
||||
jarl acl delete lan # refused while in use
|
||||
@@ -304,12 +490,33 @@ jarl option list [--view V | --zone Z | --toplevel | --all]
|
||||
jarl option set recursion yes
|
||||
jarl option set allow-recursion '{ homelab; localhost; }'
|
||||
jarl option set forwarders '{ 1.1.1.1; 9.9.9.9; }'
|
||||
jarl option set allow-transfer '{ key xfer; }' --zone adbraun.lol
|
||||
jarl option set forwarders 1.1.1.1 9.9.9.9 # the same: braces are added to a list
|
||||
jarl option set allow-transfer 'key xfer; 10.0.10.53' --zone adbraun.lol
|
||||
jarl option set allow-query '10.0.20.1-10.0.20.99; localhost' --view internal
|
||||
jarl option set response-policy '{ zone "rpz.local"; }' --view internal
|
||||
jarl option set logging '{ channel q { file "/var/log/named/query.log" versions 3 size 20m; }; category queries { q; }; }' --toplevel
|
||||
jarl option set tls 'quad9 { remote-hostname "dns.quad9.net"; ca-file "/etc/ssl/certs/ca-certificates.crt"; }' --toplevel
|
||||
jarl option set tls 'cloudflare { remote-hostname "cloudflare-dns.com"; }' --toplevel # tls quad9 stays
|
||||
jarl option unset tls cloudflare --toplevel # statements with a name: say which
|
||||
jarl option unset forwarders
|
||||
jarl option describe # every option allowed at this scope
|
||||
jarl option describe allow-query-cache # grammar and ARM description
|
||||
jarl option generated # what Jarl adds to named.conf by itself, and why
|
||||
```
|
||||
|
||||
`option set` replaces the option of that name. Statements told apart by
|
||||
the name after the keyword (`tls NAME`, `key NAME`, `remote-servers NAME`,
|
||||
`http NAME`, `dnssec-policy NAME`, `server ADDRESS`, ...) replace only the
|
||||
one with that name; `--add` adds another instance of any other option that
|
||||
may occur several times. Forwarding to Quad9 or Cloudflare over TLS:
|
||||
[encrypted-dns.md](encrypted-dns.md#forwarding-over-tls).
|
||||
|
||||
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 +530,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]
|
||||
@@ -331,10 +540,16 @@ jarl token list --all --expiring [60d] # expiring within 14 days
|
||||
jarl token create laptop --expires 365d
|
||||
jarl token create backup-script --grant "*:read"
|
||||
jarl token create acme --user certbot --role acme
|
||||
jarl token create web01 --grant "certificates:write names=web01.adbraun.lol" # jarl cert get on one host (or --role certificates)
|
||||
jarl token rotate laptop --save # new secret, same token and grants
|
||||
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
|
||||
@@ -351,18 +566,49 @@ working.
|
||||
|
||||
## Query statistics
|
||||
|
||||
`--view internal,vps` (or repeated) limits any `jarl stats` command to those
|
||||
views ([stats.md](stats.md#views)).
|
||||
|
||||
```sh
|
||||
jarl stats status
|
||||
jarl stats summary --since 7d
|
||||
jarl stats top names | clients | nxdomain | servfail | refused [--since 24h] [--limit 20]
|
||||
jarl stats top names | clients | nxdomain | servfail | refused | blocked | blocked-clients [--since 24h] [--limit 20]
|
||||
jarl stats timeline --since 6h [--key rcode:NXDOMAIN] [--table]
|
||||
jarl stats heatmap --since 28d
|
||||
jarl stats breakdown qtype | rcode | view | zone | answer | rtt
|
||||
jarl stats breakdown qtype | rcode | view | zone | answer | rtt | blocklist
|
||||
jarl stats server # named's memory, CPU, cache, clients; host; disk space
|
||||
```
|
||||
|
||||
Statistics must be enabled in `jarl.yaml`, and `-o json` exports the data.
|
||||
See [stats.md](stats.md).
|
||||
|
||||
## Blocking
|
||||
|
||||
```sh
|
||||
jarl blocklist list
|
||||
jarl blocklist add NAME URL [--view V]... # default: every view that offers recursion
|
||||
jarl blocklist update NAME [--url U] [--name N] [--view V | --view auto] [--comment C]
|
||||
jarl blocklist disable | enable | delete NAME
|
||||
jarl blocklist refresh [NAME...] [--force] # download now (all lists without NAME)
|
||||
|
||||
jarl block add DOMAIN [--exact] [--view V]... # block it (and the names below it)
|
||||
jarl block allow DOMAIN [--exact] [--view V]... # an exception that beats every list
|
||||
jarl block list
|
||||
jarl block delete DOMAIN [--view V] | --id ID
|
||||
jarl block check DOMAIN # blocked? by which rule or list?
|
||||
jarl block pause [DURATION] # let everything through for a while (default 10m, at most 24h)
|
||||
jarl block pause indefinitely # ... until 'jarl block resume'
|
||||
jarl block step list | add NAME | rename STEP NEW | order STEP... | delete STEP
|
||||
jarl block group list
|
||||
jarl block group add NAME MEMBER... [--step S] [--view V]... # addresses, networks or ACLs
|
||||
jarl block group update GROUP [--add M] [--remove M] [--step S] [--name N]
|
||||
jarl block group delete GROUP
|
||||
jarl blocklist update LIST --step STEP # devices at that step or above get it
|
||||
jarl block resume
|
||||
```
|
||||
|
||||
See [blocking.md](blocking.md).
|
||||
|
||||
## ACME DNS-01
|
||||
|
||||
```sh
|
||||
@@ -376,8 +622,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.
|
||||
+50
-5
@@ -40,14 +40,22 @@ 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
|
||||
**drift** when a file changes behind its back. It then refuses to overwrite
|
||||
the file until you say so (`--force`, or `jarl resync --force`).
|
||||
|
||||
Some of named.conf comes from Jarl itself rather than from your options:
|
||||
the include of keys.conf, the statistics channel and the logging
|
||||
statistics need, blocking's `response-policy` and policy zones, and the
|
||||
view keys at the front of each view's `match-clients`. The Server options
|
||||
page lists them under **Added by Jarl**, with why each is there and what
|
||||
turns it on or off (`jarl option generated`). Where you have a statement
|
||||
of your own (`logging`, `statistics-channels`), Jarl merges into it.
|
||||
|
||||
Comments are part of the data model: zones, records, ACLs, views, keys and
|
||||
options can all carry a comment, and Jarl writes it into the generated file.
|
||||
Comments in imported zone files are kept.
|
||||
@@ -68,7 +76,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 +169,38 @@ RFC 1982 arithmetic.
|
||||
Jarl checks them before named does. Statements that Jarl manages itself
|
||||
(`zone`, `view`, `acl`, `key`, a zone's `type`/`file`, a view's
|
||||
`match-clients`, ...) can't be set as raw options.
|
||||
Options of a block are kept in one fixed order, the same in named.conf,
|
||||
the web UI and the CLI: grouped (files, listening, access control,
|
||||
recursion and forwarding, DNSSEC, transfers and notify, then the rest),
|
||||
alphabetical within a group. So the same option is in the same place in
|
||||
every view and zone. Top-level statements stay in the order they were
|
||||
added.
|
||||
* **Address ranges.** Wherever named takes an address match list (ACLs,
|
||||
views' `match-clients` and `match-destinations`, options such as
|
||||
`allow-query` or `allow-recursion`) and in blocking's client groups, Jarl
|
||||
also takes a range of addresses: `10.0.10.101-10.0.10.199`,
|
||||
`2001:db8::100-2001:db8::1ff`, `!10.0.10.150-10.0.10.160`. BIND only
|
||||
matches addresses and prefixes, so Jarl keeps the range as you wrote it
|
||||
and writes the fewest prefixes that cover exactly that range to
|
||||
named.conf, with the range as a comment above them:
|
||||
```
|
||||
acl "dhcp" {
|
||||
// 10.0.10.101-10.0.10.199
|
||||
10.0.10.101;
|
||||
10.0.10.102/31;
|
||||
10.0.10.104/29;
|
||||
10.0.10.112/28;
|
||||
10.0.10.128/26;
|
||||
10.0.10.192/29;
|
||||
};
|
||||
```
|
||||
Nothing outside the range is matched (writing such lists by hand, it is
|
||||
easy to round to `10.0.10.96/27` and let in more than you meant). Both
|
||||
ends are full addresses of the same family, and spaces around the dash
|
||||
are fine. A range takes at most 62 prefixes for IPv4 and 254 for IPv6.
|
||||
The web UI shows what a range becomes while you type it. A range in the
|
||||
IPv4-mapped space (`::ffff:0:0/96`) gets a warning: named can see IPv4
|
||||
clients as such addresses, so it may match IPv4 clients too.
|
||||
* **Settings** are Jarl's defaults for new zones: name servers, SOA contact
|
||||
and timers, default TTL, serial policy.
|
||||
|
||||
@@ -216,6 +258,11 @@ boundary. So:
|
||||
(RFC 2317 delegation, where an ISP hands you part of a /24, is set up by
|
||||
hand with CNAMEs.)
|
||||
|
||||
Jarl suggests the reverse zones your private views lack, creates them in
|
||||
the right view filled with PTR records, and can keep PTR records in step
|
||||
with a zone's A and AAAA records: [reverse.md](reverse.md). A zone created
|
||||
inside another (a /24 inside a /8) takes the parent's records under it.
|
||||
|
||||
### Dynamic updates and TSIG keys
|
||||
|
||||
Jarl writes every zone file, so it can't let named change a zone through
|
||||
@@ -290,8 +337,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
|
||||
|
||||
+104
-5
@@ -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. |
|
||||
| `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. Easier: let Jarl obtain and renew one ([certificates.md](certificates.md), use `web`), which takes over from these. With a certificate, the `listen` ports serve HTTPS and plain HTTP side by side: plain HTTP is still served to this server itself and to `trusted_proxies`; anyone else is sent to HTTPS. |
|
||||
| `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.
|
||||
@@ -71,6 +73,36 @@ The `stats:` section turns on query statistics (off by default). See
|
||||
[stats.md](stats.md) for every setting and what it changes in named's
|
||||
configuration. Changing it takes effect when jarld restarts.
|
||||
|
||||
### Prometheus metrics
|
||||
|
||||
| Key | Default | Meaning |
|
||||
|-----|---------|---------|
|
||||
| `metrics.enabled` | `false` | Serve Prometheus metrics at `/metrics` on `listen` and the local socket ([metrics.md](metrics.md)). |
|
||||
| `metrics.listen` | `[]` | Extra `ADDRESS:PORT`s that serve only `/metrics`, over plain HTTP, e.g. `["0.0.0.0:9450"]`. |
|
||||
| `metrics.allow` | `["127.0.0.1", "::1"]` | Addresses or prefixes that may scrape without a token. |
|
||||
| `metrics.token` | `""` | A bearer token (at least 16 characters) that lets any address scrape. Keep jarl.yaml readable by root and jarld only when set. |
|
||||
|
||||
Query counters need `stats.enabled` as well. Changes take effect when jarld
|
||||
restarts.
|
||||
|
||||
### Certificates (ACME account)
|
||||
|
||||
The account Jarl obtains certificates with, and where it writes them for
|
||||
named. See [certificates.md](certificates.md). Changes take effect when
|
||||
jarld restarts.
|
||||
|
||||
| Key | Default | Meaning |
|
||||
|-----|---------|---------|
|
||||
| `acme.account.directory` | `letsencrypt` | `letsencrypt`, `letsencrypt-staging` or an ACME directory URL (`https://...`). |
|
||||
| `acme.account.email` | `""` | Contact address for the CA (optional). |
|
||||
| `acme.account.agree_tos` | `false` | You accept the CA's terms of service; no account is registered without it. |
|
||||
| `acme.account.eab_kid`, `acme.account.eab_hmac` | `""` | External Account Binding, for CAs that need it. Both or neither. Keep the file readable only by root and jarld. |
|
||||
| `acme.resolvers` | `["1.1.1.1:53", "9.9.9.9:53", "8.8.8.8:53"]` | Recursive resolvers (`address:port`) used to see domains as the CA does: their zone, public name servers, and a CNAME on `_acme-challenge`. Not this server: it may answer from an internal view. |
|
||||
| `acme.ca_bundle` | `""` | PEM file of extra CA certificates to trust for the ACME server (a private CA). |
|
||||
| `acme.check_propagation` | `true` | Wait until every name server of the zone serves a challenge before the CA is told to look (they are asked directly, on port 53). |
|
||||
| `certificates.dir` | `<bind.config_dir>/tls` | Where certificates with the `dns` use are written for named: `<dir>/<name>/fullchain.pem` and `privkey.pem`. |
|
||||
| `certificates.max_per_user` | `10` | How many certificates a user may own through `jarl cert get` ([certificates.md](certificates.md#certificates-for-other-hosts)). A user's own limit (`jarl user update NAME --cert-limit N`) replaces it. |
|
||||
|
||||
### ACME (lego httpreq endpoint)
|
||||
|
||||
An endpoint for lego's `httpreq` DNS provider, so lego and Traefik can
|
||||
@@ -91,9 +123,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 +144,46 @@ there (`ReadWritePaths`). If you change them, update both
|
||||
ReadWritePaths=/srv/bind/jarl /srv/bind/zones
|
||||
```
|
||||
|
||||
### Porkbun (registrar and DNS host)
|
||||
|
||||
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));
|
||||
- publish the DNS-01 challenges of certificates it obtains while Porkbun
|
||||
still serves the domain's DNS ([certificates.md](certificates.md#where-the-challenge-goes)):
|
||||
it only adds TXT records at `_acme-challenge` names and removes them
|
||||
again.
|
||||
|
||||
Without a key (the default) nothing about Porkbun shows up, and
|
||||
certificates for domains Porkbun serves can't be obtained (`jarl cert
|
||||
check` says so).
|
||||
|
||||
| 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, for both uses (default: any domain the key can reach). Subdomain and reverse zones never get the registrar features ([dnssec.md](dnssec.md#porkbun)). |
|
||||
|
||||
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 +199,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
|
||||
|
||||
+111
-7
@@ -10,6 +10,7 @@ internal/
|
||||
dnsname/ DNS name handling: FQDNs, relative names, check-names rules, reverse zones
|
||||
rr/ record data parsing/canonicalization (miekg/dns), TXT quoting, the record type catalog
|
||||
namedconf/ named.conf lexer, parser and printer; address match lists
|
||||
iprange/ address ranges (10.0.10.101-10.0.10.199): parsing and the exact prefix cover
|
||||
catalog/ every BIND 9.20 statement: grammar (from named.conf(5)) + ARM descriptions, embedded
|
||||
validate/ whole-configuration validation and security lints
|
||||
render/ State → named.conf, keys.conf and zone files, with a source map for error messages
|
||||
@@ -24,9 +25,19 @@ 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
|
||||
certs/ certificates: Jarl as an ACME client (lego), renewal, files for named
|
||||
encdns/ encrypted DNS (DoT/DoH) setup, status and health checks
|
||||
mailcheck/ reverse DNS check of the addresses mail is sent from
|
||||
totp/ two-factor login codes and recovery codes
|
||||
version/ what a build reports (release tag, commit, commits since a tag)
|
||||
testutil/ test helpers, chiefly finding BIND's tools (JARL_BIND_ROOT)
|
||||
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
|
||||
contrib/acme.sh/ acme.sh DNS API hook
|
||||
@@ -110,10 +121,49 @@ 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. With Docker, certificates are tested end to end against
|
||||
[Pebble](https://github.com/letsencrypt/pebble), Let's Encrypt's test CA
|
||||
(`ghcr.io/letsencrypt/pebble`, host network, ports 14000 and 15000),
|
||||
which checks the challenges against the test named: obtaining, HTTPS
|
||||
from jarld, DNS over TLS from named, renewal. `PEBBLE=0` skips that; the
|
||||
certs package's own tests use a fake DNS server and `porkbun.Fake`, and
|
||||
never a real CA. `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 +205,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 +219,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
|
||||
|
||||
+371
@@ -0,0 +1,371 @@
|
||||
# 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.
|
||||
|
||||
**Where the DS record goes** depends on the zone. The DNSSEC tab and
|
||||
`jarl zone dnssec status` say which:
|
||||
|
||||
| Zone | The DS record goes |
|
||||
|---|---|
|
||||
| a registered domain (`adbraun.lol`) | at your registrar ([Porkbun](#porkbun) can do it for you) |
|
||||
| a subdomain whose parent zone is in Jarl (`lab.adbraun.lol`) | in the parent zone, as a DS record named `lab`: one button, or the `jarl record add` command shown. The parent in the zone's own view counts first, else one in another view (a public one first). |
|
||||
| a subdomain whose parent zone is hosted elsewhere | in that parent zone, wherever it is hosted (at Porkbun: the domain's DNS records) |
|
||||
| a reverse zone | with whoever delegates it to you: your ISP, or your RIR for an address block of your own. Many ISPs don't take DS records. |
|
||||
| a private name (`home.lan`) | nowhere: nothing on the internet delegates it, so signing it has no effect |
|
||||
|
||||
**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. The
|
||||
same key also lets Jarl publish the DNS-01 challenges of its own
|
||||
certificates while Porkbun serves the domain's DNS
|
||||
([certificates.md](certificates.md#where-the-challenge-goes)).
|
||||
|
||||
**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, and to your
|
||||
server's address. The registrar features need the domain permissions;
|
||||
certificate challenges need DNS record write access too.
|
||||
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.
|
||||
|
||||
Only a zone that is a registered domain gets the registrar features
|
||||
(`adbraun.lol`, `example.co.uk`, judged by the public suffix list). Their
|
||||
parent zone delegates the others, not a registrar:
|
||||
|
||||
* a subdomain zone such as `lab.adbraun.lol`: the NS and DS records in
|
||||
`adbraun.lol`;
|
||||
* a reverse zone: whoever holds the addresses (your ISP, or the RIR);
|
||||
* a zone under a suffix that isn't public (`home.lan`, `home.arpa`):
|
||||
nobody.
|
||||
|
||||
The zone's **Name servers** tab (Check delegation) checks all of them.
|
||||
|
||||
**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.
|
||||
@@ -0,0 +1,237 @@
|
||||
# DNS over TLS and HTTPS
|
||||
|
||||
named (BIND 9.20) can answer over encrypted connections itself: DNS over
|
||||
TLS (DoT, port 853) and DNS over HTTPS (DoH, at `/dns-query`). Your
|
||||
phones, laptops and browsers then ask this server without anyone on the
|
||||
network seeing or changing their queries, and they get exactly what plain
|
||||
DNS gives them: the same views, the same records and the same blocking.
|
||||
|
||||
Jarl sets it up in one step: a certificate for the host name clients
|
||||
connect to (obtained and renewed by Jarl, [certificates.md](certificates.md)),
|
||||
named's `tls` statement and listeners on the addresses you choose, and the
|
||||
host name's address records in your private views.
|
||||
|
||||
In the web UI: **Encrypted DNS** (administrators). In the CLI: `jarl
|
||||
encrypted-dns`. In the API: `/api/v1/encrypted-dns`.
|
||||
|
||||
That is encryption between your clients and this server. For encryption
|
||||
between this server and the resolvers it forwards to (Quad9, Cloudflare),
|
||||
see [Forwarding over TLS](#forwarding-over-tls) below.
|
||||
|
||||
## Setting it up
|
||||
|
||||
```sh
|
||||
jarl encrypted-dns setup --hostname dns.adbraun.lol --address 10.0.10.5 --address 2a02:8013:7f3d:10::5
|
||||
jarl encrypted-dns setup --hostname dns.adbraun.lol --address 10.0.10.5=internal --address 203.0.113.5=external
|
||||
```
|
||||
|
||||
This:
|
||||
|
||||
1. **finds or obtains the certificate**: one Jarl manages whose names cover
|
||||
the host name (`--certificate NAME` to pick one), or a new one named
|
||||
after the host name's first label, which Jarl obtains from the CA (the
|
||||
command waits). The certificate gets the `dns` use, so it is written
|
||||
where named can read it, and named rereads it on every renewal.
|
||||
2. **checks the ports**: nothing else may listen on the chosen addresses
|
||||
on port 853 or the DoH port. Jarl looks at the host's listening sockets
|
||||
(it binds nothing) and says what is in the way, e.g. `jarld (Jarl's web
|
||||
UI and API) listens on 10.0.10.5 port 443`, and which port is free
|
||||
instead (`--doh-port 8443`).
|
||||
3. **stages named's configuration**, as one change you can review:
|
||||
|
||||
```
|
||||
tls jarl-dns { cert-file ".../tls/dns/fullchain.pem"; key-file ".../tls/dns/privkey.pem"; protocols { TLSv1.2; TLSv1.3; }; };
|
||||
options {
|
||||
listen-on port 853 tls jarl-dns { 10.0.10.5; };
|
||||
listen-on port 443 tls jarl-dns http default { 10.0.10.5; };
|
||||
listen-on-v6 port 853 tls jarl-dns { 2a02:8013:7f3d:10::5; };
|
||||
...
|
||||
```
|
||||
|
||||
named listens on port 53 on every address only while there is no
|
||||
`listen-on` statement at all. The first one, for DoT, would end that, so
|
||||
when you have none the setup adds `listen-on { any; };` (and the same
|
||||
for IPv6) to keep plain DNS where it was.
|
||||
4. **adds the host names' A and AAAA records**, each address in the views
|
||||
you give it: `--address 10.0.10.5=internal,lab`, or every address in
|
||||
the views of `--view` (default: the private views that have a zone for
|
||||
the name). A view's clients should only be given an address they can
|
||||
reach, so a private address in a public view gets a warning. The
|
||||
records are local to their view (in a linked zone the other views keep
|
||||
theirs), and a view you no longer give an address loses it; other
|
||||
records of the names stay.
|
||||
|
||||
**Several host names** (`--hostname dns.adbraun.lol --hostname
|
||||
doh.adbraun.lol`, or comma separated): each gets the records, the
|
||||
certificate must be for all of them (a new one is obtained with all of
|
||||
them), and the health check says when the certificate named serves misses
|
||||
one. Clients may use any of them; the client hints use the first.
|
||||
|
||||
Running it again replaces the earlier setup: other addresses, views, host
|
||||
names, **ports** (clients set up with another port need theirs changed
|
||||
too), DoT or DoH only with `--no-doh` / `--no-dot`. The web UI's form
|
||||
starts from the current setup, with each address's views as its records
|
||||
are now. `jarl encrypted-dns disable` takes the listeners and the `tls`
|
||||
statement out; the certificate and the records stay.
|
||||
|
||||
| Option | Default | |
|
||||
|--|--|--|
|
||||
| `--hostname` | | A name clients connect to (repeat for several); the certificate is for all of them |
|
||||
| `--address` | | This server's addresses to listen on (repeat), with the views that get its records after `=` (`10.0.10.5=internal`). Choose your own networks' addresses: a listener on a public address answers anyone who reaches it, like port 53 there |
|
||||
| `--certificate` | one for the host name, obtained if needed | A certificate Jarl manages |
|
||||
| `--no-dot`, `--no-doh` | both on | |
|
||||
| `--dot-port` | 853 | Clients expect 853 |
|
||||
| `--doh-port` | 443 | Any free port; clients take it in the URL |
|
||||
| `--view` | private views with the zone | Where every address's records go, when `--address` gives no views |
|
||||
|
||||
In the web UI the same form checks the ports as you choose, offers the
|
||||
free port, and obtains the certificate before setting up.
|
||||
|
||||
## Which view clients get
|
||||
|
||||
A DoT or DoH client gets the view its address matches, as over plain DNS:
|
||||
your phone at home gets the internal view, with its records and blocking.
|
||||
Choosing a view by key (`match-clients { key ...; }`) doesn't work over
|
||||
DoT and DoH, as clients don't sign their queries.
|
||||
|
||||
## Clients
|
||||
|
||||
`jarl encrypted-dns` (and the page) shows what to give them:
|
||||
|
||||
```
|
||||
For DoT clients: dns.adbraun.lol
|
||||
For DoH clients: https://dns.adbraun.lol/dns-query
|
||||
```
|
||||
|
||||
| Client | Where |
|
||||
|--|--|
|
||||
| Android 9+ | Settings → Network → Private DNS → Private DNS provider hostname: `dns.adbraun.lol` (DoT) |
|
||||
| Linux (systemd-resolved) | `/etc/systemd/resolved.conf`: `DNS=10.0.10.5#dns.adbraun.lol` and `DNSOverTLS=yes` (or `opportunistic`, which falls back to plain DNS) |
|
||||
| Firefox | Settings → Privacy & Security → DNS over HTTPS → Increased protection → Custom: the DoH URL |
|
||||
| Chrome, Edge | Settings → Privacy and security → Security → Use secure DNS → custom: the DoH URL |
|
||||
| Windows 11 | Settings → Network → your connection → DNS server assignment → Edit: the server's address, DNS over HTTPS on (manual template), the DoH URL |
|
||||
|
||||
Test from a machine on your network, with BIND's own `dig` (9.18 or
|
||||
later; `bind9-dnsutils` on Debian):
|
||||
|
||||
```sh
|
||||
dig +tls +tls-ca +tls-hostname=dns.adbraun.lol @10.0.10.5 example.com # DoT, port 853
|
||||
dig +https +tls-ca +tls-hostname=dns.adbraun.lol @10.0.10.5 example.com # DoH, port 443, /dns-query
|
||||
```
|
||||
|
||||
`+tls-ca` checks the certificate against the system's CAs and
|
||||
`+tls-hostname` against the host name, as clients do; add `-p PORT` for
|
||||
another port. Without `+tls-ca` the query is still encrypted, but the
|
||||
certificate isn't checked. named's DoH speaks the standard wire format
|
||||
(RFC 8484), not the JSON API some public resolvers also offer, so
|
||||
`curl ... accept: application/dns-json` doesn't work against it.
|
||||
|
||||
**Android away from home.** Private DNS in hostname mode applies on every
|
||||
network. Away from home, your server can't be reached, and Android then
|
||||
resolves nothing at all ("Private DNS server cannot be accessed"). Use a
|
||||
VPN home (WireGuard) when you're out, or switch Private DNS to
|
||||
*Automatic* when you leave. Firefox's *Increased protection* and
|
||||
systemd-resolved's `opportunistic` fall back to normal DNS by themselves.
|
||||
|
||||
Clients check the certificate against the host name, so the name must
|
||||
resolve to this server for them: the setup adds it to your private views.
|
||||
Open the ports in the server's firewall for your own networks only.
|
||||
|
||||
## Health
|
||||
|
||||
jarld checks the listeners itself, a minute after it starts and then
|
||||
every 10 minutes, the way a client would: it connects to each, checks the
|
||||
certificate named serves (for the host name, current, and the one Jarl
|
||||
last wrote, so a renewal named hasn't reread shows up) and asks for the
|
||||
host name. The Encrypted DNS page shows the result per listener (**Check
|
||||
now** checks again), the dashboard a card and the problems, Prometheus
|
||||
`jarl_encrypted_dns_up{kind,address,port}`:
|
||||
|
||||
```sh
|
||||
jarl encrypted-dns health # check now; exit 1 when a listener doesn't work
|
||||
```
|
||||
|
||||
What it can say:
|
||||
|
||||
| | |
|
||||
|--|--|
|
||||
| nothing listens on ADDR port P | named couldn't bind the port: look in named's log (ports below 1024 need named to start as root, as the Debian service does) |
|
||||
| no answer within 5s | a firewall on the server |
|
||||
| a certificate for X, not HOST | named serves another certificate |
|
||||
| an older certificate | named hasn't reread the renewed files (`rndc reconfig`) |
|
||||
| not from a CA this server trusts (warning) | a staging or test CA: clients reject it unless they trust it |
|
||||
| REFUSED (warning) | it works, but this server's own address gets a view that doesn't answer for the host name |
|
||||
|
||||
## Forwarding over TLS
|
||||
|
||||
When a view forwards its recursive queries (to Quad9, Cloudflare, your
|
||||
ISP's resolver), named can send them over TLS, so your ISP doesn't see the
|
||||
names you look up: only connections to the forwarder on port 853. It takes
|
||||
two options, a `tls` statement for each provider (the name its certificate
|
||||
must have) and the view's `forwarders` using it. Stage them in one
|
||||
changeset, so one revert undoes them:
|
||||
|
||||
```sh
|
||||
CS=$(jarl changeset create "internal: forward to Quad9 over TLS" -q)
|
||||
jarl option set tls 'quad9 { remote-hostname "dns.quad9.net"; ca-file "/etc/ssl/certs/ca-certificates.crt"; }' --toplevel -C $CS
|
||||
jarl option set forwarders '{ 9.9.9.9 port 853 tls quad9; 149.112.112.112 port 853 tls quad9; 2620:fe::fe port 853 tls quad9; 2620:fe::9 port 853 tls quad9; }' --view internal -C $CS
|
||||
jarl option set forward only --view internal -C $CS
|
||||
jarl changeset diff $CS # what named.conf gets
|
||||
jarl changeset apply $CS
|
||||
# undo: jarl changeset apply $(jarl changeset revert $CS -q)
|
||||
```
|
||||
|
||||
* **`remote-hostname` and `ca-file`** make named check the forwarder's
|
||||
certificate against that name (Debian's CA bundle at that path). Without
|
||||
them the connection is encrypted but anyone in between could answer.
|
||||
* **`forward only`** stops named falling back to plain DNS (to the root
|
||||
servers) when the forwarders don't answer: lookups then fail instead
|
||||
(SERVFAIL), which you notice, rather than quietly going unencrypted.
|
||||
* **Several providers**: a `tls` statement each, and their addresses in one
|
||||
`forwarders` list. Setting `tls cloudflare` doesn't touch `tls quad9`:
|
||||
`jarl option set` replaces only the statement of that name.
|
||||
|
||||
```sh
|
||||
jarl option set tls 'cloudflare { remote-hostname "cloudflare-dns.com"; ca-file "/etc/ssl/certs/ca-certificates.crt"; }' --toplevel
|
||||
jarl option set forwarders '{ 9.9.9.9 port 853 tls quad9; 149.112.112.112 port 853 tls quad9; 1.1.1.1 port 853 tls cloudflare; 1.0.0.1 port 853 tls cloudflare; 2606:4700:4700::1111 port 853 tls cloudflare; 2606:4700:4700::1001 port 853 tls cloudflare; }' --view internal
|
||||
```
|
||||
|
||||
named sends each query to whichever forwarder has answered fastest, not
|
||||
in order, and the providers filter differently (Quad9 blocks known
|
||||
malicious domains, Cloudflare's 1.1.1.1 doesn't; 1.1.1.2 does, with
|
||||
`remote-hostname "security.cloudflare-dns.com"`).
|
||||
* **DNS over HTTPS** can't be used for forwarding: BIND serves DoH to
|
||||
clients but forwards only over plain DNS or TLS. For a DoH upstream, run
|
||||
a local proxy (dnscrypt-proxy, cloudflared) and forward to it on
|
||||
`127.0.0.1`.
|
||||
* Providers write their DoT address as `tls://dns.quad9.net`: the name is
|
||||
`remote-hostname`, and the addresses it resolves to go in `forwarders`
|
||||
(named needs addresses there, not names).
|
||||
|
||||
**Checking it works**: Quad9 says how a query reached it.
|
||||
|
||||
```sh
|
||||
dig +short TXT proto.on.quad9.net @YOUR-SERVER # "dot": over TLS
|
||||
```
|
||||
|
||||
`do53-udp` means plain DNS. `ss -tnp | grep :853` shows named's connections
|
||||
only briefly: it closes them when idle. `tcpdump -ni any port 853` shows
|
||||
the traffic as it happens.
|
||||
|
||||
## What Jarl checks
|
||||
|
||||
* A `tls` statement whose files don't exist is an error
|
||||
(`tls_file_missing`): named won't load its configuration without them.
|
||||
* A certificate named uses can't be deleted, nor lose its `dns` use: turn
|
||||
encrypted DNS off first.
|
||||
* Listeners you wrote yourself are left alone; the page notes them.
|
||||
|
||||
## API
|
||||
|
||||
| Method | Path | Body |
|
||||
|--------|------|------|
|
||||
| GET | `/encrypted-dns` | `enabled`, `certificate`, `hostnames` (and `hostname`, the first), `dot` and `doh` (address and port), `address_views` (per address, the views whose records give a host name it), `dot_host`, `doh_url`, `listeners`; for the form: `addresses` (this server's, with `private`), `views` (with `public`, and `has_zone`), `certificates`. `?hostname=a,b` asks about other names |
|
||||
| POST | `/encrypted-dns/check` | `{"addresses", "dot", "doh", "dot_port", "doh_port"}`: `conflicts` (address, port, what) and `free_doh_port` |
|
||||
| POST | `/encrypted-dns` | `{"hostnames", "certificate", "addresses", "address_views": {"10.0.10.5": ["internal"]}, "dot", "doh", "dot_port", "doh_port"}` (`"hostname"` and `"views"`, every address in each, work too): staged or applied like any change; 409 while the certificate hasn't been obtained |
|
||||
| DELETE | `/encrypted-dns` | Turns it off |
|
||||
| POST | `/encrypted-dns/health` | Checks the listeners now: `checked`, `probes` (kind, address, port, `ok`, `problem`, `warning`, certificate names, expiry and serial, `trusted`, `rcode`, `ms`). `GET /encrypted-dns` has the last check as `health` |
|
||||
@@ -0,0 +1,247 @@
|
||||
# Internet exposure
|
||||
|
||||
When a view (or the whole server) can be reached from the internet, a few
|
||||
things matter that don't on your own network: it shouldn't help attackers
|
||||
flood others with DNS answers (amplification), recurse for strangers, or
|
||||
tell scanners its exact version. Jarl works out which views are public,
|
||||
checks them, and stages the fixes for you to review.
|
||||
|
||||
In the web UI: **Exposure** (and its card on the dashboard). In the CLI:
|
||||
`jarl exposure`. In the API: `GET /api/v1/exposure`.
|
||||
|
||||
## Which views are public
|
||||
|
||||
A view is **public** when its `match-clients` let in anyone: after ACLs
|
||||
and ranges are filled in, they end in a catch-all (`any`, `0.0.0.0/0`,
|
||||
`::/0`, or a prefix wider than /8 for IPv4 or /16 for IPv6), or they are
|
||||
empty, which named reads as "any". A typical external view
|
||||
(`match-clients { any; }` or `{ !internal; any; }`) is public.
|
||||
|
||||
A view that lists the networks it serves is **private**, whatever kind of
|
||||
addresses those are. An internal view for your global IPv6 prefix
|
||||
(`2a02:8013:7f3d:10::/64`) is as private as one for `192.168.1.0/24`:
|
||||
listing a network says "these are mine". Jarl doesn't look at whether
|
||||
addresses are private (RFC 1918, ULA) for this, because on IPv6 your own
|
||||
devices have global addresses.
|
||||
|
||||
A view whose `match-destinations` list only private addresses (it answers
|
||||
only on a private interface) is private too.
|
||||
|
||||
**A server without views** has no match list, so Jarl looks at
|
||||
`allow-query` and where named listens: it is public when anyone may query
|
||||
it (`allow-query` unset, or with a catch-all) **and** named listens on an
|
||||
address that isn't private (a public IPv4 address or a global IPv6
|
||||
address of this host). This can't see your firewall, so it is a guess.
|
||||
|
||||
**When Jarl gets it wrong**, override it per view: `public`, `private` or
|
||||
`auto`. For example, behind a NAT port forward named only ever sees
|
||||
private addresses although it is reachable from the internet.
|
||||
|
||||
```sh
|
||||
jarl view update external --exposure public
|
||||
jarl settings set --exposure private # a server without views
|
||||
```
|
||||
|
||||
The Exposure page shows each view's classification and the reason
|
||||
("match-clients ends in any", "match-clients lists only homelab, no
|
||||
catch-all"), with a selector for the override.
|
||||
|
||||
## The checks
|
||||
|
||||
They run on public views only, and are warnings (they don't stop you
|
||||
applying) unless acknowledged.
|
||||
|
||||
| Code | What | Fix Jarl can stage |
|
||||
|------|------|-------------------|
|
||||
| `exposure_rate_limit` | No response rate limiting: the view can be used to flood others with its answers (DNS amplification) | `rate-limit { responses-per-second 10; log-only yes; exempt-clients { your secondaries }; }` |
|
||||
| `exposure_rate_limit_log_only` (info) | Rate limiting only logs | The same block without `log-only`: start enforcing |
|
||||
| `security_open_resolver` | Recursion for anyone: an open resolver | `recursion no` in that view |
|
||||
| `exposure_minimal_any` | ANY queries get full answers, a favourite for amplification | `minimal-any yes` |
|
||||
| `exposure_cache_snooping` | `allow-query-cache` lets anyone see what your clients look up | `allow-query-cache { none; }` |
|
||||
| `security_open_transfer` | Zone transfers for anyone | none: allow your secondaries or a TSIG key |
|
||||
| `exposure_open_notify` | `allow-notify` accepts NOTIFY from anyone | none: allow your primaries |
|
||||
| `exposure_version` | `dig CH TXT version.bind` shows the exact BIND version | `version none` |
|
||||
| `exposure_hostname` | `dig CH TXT hostname.bind` shows the host name | `hostname none` |
|
||||
| `exposure_management` | `controls` or `statistics-channels` on an address that isn't loopback, open to anyone | none: use 127.0.0.1 or an allow list |
|
||||
|
||||
The last three are server-wide: they are checked when any view is public.
|
||||
|
||||
A **fix** goes into your pending changes like any edit (or is applied at
|
||||
once in live mode), so you see the named.conf diff before it reaches named:
|
||||
|
||||
```sh
|
||||
jarl exposure # classification, findings, fixes
|
||||
jarl exposure fix external exposure_rate_limit
|
||||
jarl exposure fix server exposure_version
|
||||
```
|
||||
|
||||
## Response rate limiting
|
||||
|
||||
Response rate limiting (RRL, BIND's `rate-limit`) counts identical
|
||||
responses sent to the same client network. Past the limit, named drops
|
||||
some and "slips" others: it sends a short reply asking the client to retry
|
||||
over TCP (or with a DNS cookie). A real client does; the spoofed victim of
|
||||
an amplification attack never sees the answers it didn't ask for. ISC
|
||||
recommends it for authoritative servers only, so Jarl only suggests it for
|
||||
public views, never for your internal resolver.
|
||||
|
||||
Jarl starts from ISC's example, 10 identical responses per second per
|
||||
client network, and exempts the servers your zones notify and allow
|
||||
transfers to (`also-notify` and `allow-transfer` addresses, through ACLs
|
||||
too): limiting your own secondaries is the classic RRL mistake.
|
||||
|
||||
**Rollout in two steps:**
|
||||
|
||||
1. The fix turns rate limiting on with `log-only yes`. named answers
|
||||
everyone as before, but logs every response it *would* have limited.
|
||||
With [query statistics](stats.md) on, Jarl reads those lines: the
|
||||
Exposure page shows how many responses would have been limited, and
|
||||
which client networks and names. Check that your secondaries,
|
||||
monitoring, or a big shared resolver aren't among them (add them to
|
||||
`exempt-clients` if they are).
|
||||
2. When you're happy, **Start enforcing** (the fix of
|
||||
`exposure_rate_limit_log_only`) removes `log-only`.
|
||||
|
||||
In log-only mode named's own counters don't move: what would be limited
|
||||
is only in its log (category `query-errors`). That is why the activity
|
||||
needs query statistics; without them, look at named's log for
|
||||
`would rate limit`. When rate limiting is configured and statistics are
|
||||
on, Jarl adds `query-errors` to its log channel (keeping named's syslog
|
||||
destination for it). `stats.rate_limit_log: false` in jarl.yaml leaves
|
||||
`query-errors` alone; the page then can't show what rate limiting does.
|
||||
The Server options page lists this, and everything else Jarl adds to
|
||||
named.conf by itself, under **Added by Jarl** (`jarl option generated`).
|
||||
|
||||
The statistics also count it: the `rrl` breakdown (`drop`, `slip`,
|
||||
`would-drop`, `would-slip`), the `ratelimited` and `ratelimited_name` top
|
||||
lists, and in Prometheus `jarl_ratelimit_logged_total{action}` next to
|
||||
named's own `jarl_ratelimit_responses_total` ([metrics.md](metrics.md)).
|
||||
|
||||
### Settings
|
||||
|
||||
You can tune the block like any option; `option set` replaces the whole
|
||||
block, so copy the current value from `jarl option list --view external`
|
||||
and edit it:
|
||||
|
||||
```sh
|
||||
jarl option set rate-limit '{ responses-per-second 20; window 15; slip 2; exempt-clients { 192.0.2.53; }; log-only yes; }' --view external
|
||||
```
|
||||
|
||||
named counts **identical responses** to the same **client network** (a
|
||||
/24 for IPv4, a /56 for IPv6) over a **window**, and limits each kind of
|
||||
response separately. These are the settings (BIND 9.20; the ARM has the
|
||||
full text):
|
||||
|
||||
| Setting | Default | What it does | When to change it |
|
||||
|---------|---------|--------------|-------------------|
|
||||
| `responses-per-second` | 0 (off) | Answers with data for the same name and type, per client network. Wildcard answers count as the zone's apex. This is the main limit; Jarl's fix sets 10 | Raise it if a legitimate network shows up in log-only mode, e.g. a large shared resolver (mobile carriers, public resolvers) asking for your popular names |
|
||||
| `nxdomains-per-second` | same as `responses-per-second` | NXDOMAIN answers (names that don't exist), counted together for a domain whatever the name asked | `0` turns NXDOMAIN limiting off, for zones that legitimately get many lookups of missing names |
|
||||
| `nodata-per-second` | same as `responses-per-second` | Empty answers (the name exists, the type doesn't), counted together whatever the type | Rarely |
|
||||
| `referrals-per-second` | same as `responses-per-second` | Referrals to child zones you delegate | Only if you delegate subdomains to other servers |
|
||||
| `errors-per-second` | same as `responses-per-second` | Other errors (REFUSED, SERVFAIL, FORMERR), counted together whatever the name | Rarely. REFUSED and SERVFAIL can't be truncated, so slipped ones still go out at the slip rate |
|
||||
| `all-per-second` | 0 (off) | All UDP answers to a client network, of any kind, like a firewall rate limit; over it, answers are only **dropped** (`slip` doesn't apply) | Usually leave off; if you use it, ISC says at least 4 times the other limits |
|
||||
| `window` | 15 seconds (1–3600) | How far back named counts. A client over the limit stays limited until its rate over the window falls again | Longer windows remember attackers longer and also punish short legitimate bursts longer |
|
||||
| `slip` | 2 (0–10) | Of the limited answers, every n-th "slips": instead of being dropped it gets a short reply asking the client to retry: truncated (TC) so it retries over TCP, or, when the query carried a DNS cookie, BADCOOKIE with a server cookie that lets it through on its retry. `0` drops all, `1` slips all | `1` if you'd rather every real client gets a retry hint; `0` drops all (spoofed victims see nothing, but real clients just time out) |
|
||||
| `ipv4-prefix-length` | 24 | The size of the client network answers are counted for (IPv4) | `32` counts each address on its own: fairer to neighbours, more memory |
|
||||
| `ipv6-prefix-length` | 56 | The same for IPv6 | `64` if one /56 holds many independent clients; `48` to treat a whole site as one |
|
||||
| `exempt-clients` | none | Address match list (addresses, prefixes, [ranges](concepts.md#model-views-zones-records-options), ACLs) that is never limited. Jarl's fix puts your secondaries here | Add monitoring, other servers of yours, and anything legitimate that log-only mode shows |
|
||||
| `log-only` | no | Count and log what would be limited, answer everyone normally | Jarl starts with `yes`; the "start enforcing" fix removes it |
|
||||
| `qps-scale` | off | When the total query rate is above this, all limits shrink in proportion (at a rate of 1000 with `qps-scale 250`, a limit of 20 becomes 5) | Only on busy servers that see attacks spread over many networks |
|
||||
| `max-table-size`, `min-table-size` | min 500 entries | The table of client networks named tracks (40–80 bytes per entry) | Only on busy servers |
|
||||
|
||||
What Jarl checked against real named: TCP answers are never limited;
|
||||
`nxdomains-per-second` falls back to `responses-per-second` and `0`
|
||||
turns it off; NXDOMAIN answers for different missing names in a zone
|
||||
share one limit; `all-per-second` only drops; with `slip 1`, clients
|
||||
without DNS cookies get truncated answers, and clients with cookies (dig
|
||||
sends them by default) get through on their retry with the server's
|
||||
cookie; `exempt-clients` is never limited; in log-only mode nothing is
|
||||
limited and only the log shows what would be.
|
||||
|
||||
Rate limiting is per view: put it in the external view (`--view
|
||||
external`). Set in the server options, it applies to every view that has
|
||||
none of its own, your internal resolver included, which RRL isn't meant
|
||||
for.
|
||||
|
||||
## Mail
|
||||
|
||||
Receivers such as Gmail and Outlook check the address mail comes from: it
|
||||
needs a PTR record (reverse DNS), and the name it gives must resolve back
|
||||
to the address. Without that, mail is rejected or marked as spam. The PTR
|
||||
records of your public addresses live in the reverse zones of whoever
|
||||
owns the addresses, your hosting provider or ISP: you set them in their
|
||||
panel, not in Jarl. A reverse zone you create in Jarl only answers your
|
||||
own clients (unless the owner delegated it to you).
|
||||
|
||||
So Jarl checks them as the internet sees them, through the public
|
||||
resolvers of `acme.resolvers`: the addresses of every public zone's MX
|
||||
hosts (receiving) and SPF `ip4:`, `ip6:`, `a` and `a:` addresses
|
||||
(sending). The Exposure page's **Mail reverse DNS** panel shows each
|
||||
address, its PTR, who runs its reverse zone (the provider, from the
|
||||
zone's name server) and what to do; jarld checks a minute after it starts
|
||||
and every 6 hours, and logs an address that goes wrong.
|
||||
|
||||
```
|
||||
$ jarl exposure mail
|
||||
ZONE ADDRESS FOR PTR STATUS
|
||||
adbraun.lol 172.235.168.106 SPF ip4, MX mail.adbraun.lol mail.adbraun.lol ok
|
||||
```
|
||||
|
||||
It warns when an address has no PTR record, or when its name doesn't
|
||||
resolve back to it; notes when the name is an alias (CNAME), or isn't the
|
||||
MX name (fine if the mail server uses it in HELO). `jarl exposure mail`
|
||||
exits 1 when an address isn't right; Prometheus has
|
||||
`jarl_mail_reverse_dns_ok{zone,address}`.
|
||||
|
||||
## Confirming a change that opens a view up
|
||||
|
||||
A change that *introduces* an open resolver, open zone transfers, cache
|
||||
snooping, open NOTIFY or an open management interface on a public view
|
||||
must be confirmed:
|
||||
|
||||
- **Web UI:** the Changes page shows the finding with a checkbox (*I
|
||||
understand: …*); **Apply** stays disabled until it is ticked.
|
||||
- **CLI:** the command stops (exit 5) and says why; run it again with
|
||||
`--accept-risk`.
|
||||
- **API:** `accept_risk: true` in the apply body, or `?accept_risk=1` on a
|
||||
change applied at once; without it the answer is 409 `confirm_risk`.
|
||||
Previews (`GET /changesets/{id}/preview`) list them as `risks`.
|
||||
|
||||
Only changes that newly open something up ask: an unrelated change to a
|
||||
view that is already open, or taking back an acknowledgement, doesn't.
|
||||
|
||||
## Acknowledging a finding
|
||||
|
||||
Some findings are deliberate: a public resolver for a club's network, a
|
||||
visible version string. Acknowledge them with a reason:
|
||||
|
||||
```sh
|
||||
jarl exposure ack external security_open_resolver --reason "public resolver for the club, monitored"
|
||||
jarl exposure ack server exposure_version --reason "version shown on purpose"
|
||||
jarl exposure unack external security_open_resolver
|
||||
```
|
||||
|
||||
The finding doesn't disappear: it stays visible as **info**, with who
|
||||
accepted it, when and why (*open resolver … (acknowledged by adam on 12
|
||||
Oct 2026: public resolver for the club, monitored)*), on the Exposure page,
|
||||
the dashboard and in `jarl check`. An acknowledgement covers that one
|
||||
finding on that one view (or the server); the same finding elsewhere
|
||||
warns as usual. Acknowledging is a configuration change like any other,
|
||||
so it is in the history and audit log, and can be reverted.
|
||||
|
||||
Jarl isn't designed to run an open resolver, and an acknowledgement makes
|
||||
one deliberate and visible, not safe: a public resolver also needs abuse
|
||||
monitoring, response size limits and usually a machine of its own.
|
||||
|
||||
## API
|
||||
|
||||
| Method | Path | Body |
|
||||
|--------|------|------|
|
||||
| GET | `/exposure` | `scopes` (per view, or the server: `public`, `override`, `reason`, `rate_limit` off/log-only/enforcing, `findings` with `fix` where Jarl has one), `server` (server-wide findings), `rate_limiting` (last 24h, with statistics: `counts`, `networks`, `names`), `rate_limit_log`, `mail` (the last mail check) |
|
||||
| POST | `/exposure/mail` | Checks the mail addresses' reverse DNS now: `checked`, `results` (`zone`, `address`, `roles`, `ptr`, `ok`, `level` ok/info/warning, `message`, `reverse_zone`, `managed_by`, `local_zone`), `errors` |
|
||||
| POST | `/exposure/fix` | `{"view": "external", "code": "exposure_rate_limit"}` (`view_id` works too; no view: the server). Staged with `?changeset=` like any change |
|
||||
| POST | `/exposure/ack` | `{"view": "external", "code": "security_open_resolver", "reason": "..."}` |
|
||||
| POST | `/exposure/unack` | `{"view": "external", "code": "security_open_resolver"}` |
|
||||
|
||||
The override is a view's `exposure` field (`PATCH /views/{id}`), or the
|
||||
settings' for a server without views (`PATCH /settings`).
|
||||
+33
-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`.
|
||||
|
||||
@@ -304,6 +317,10 @@ systemctl status jarl
|
||||
journalctl -u jarl -f
|
||||
```
|
||||
|
||||
The unit also answers to `jarld` (`Alias=jarld.service`), so
|
||||
`systemctl restart jarld` and `journalctl -u jarl` work alike once it
|
||||
is enabled.
|
||||
|
||||
Open `https://<management-address>:8053/` and log in as `admin`.
|
||||
|
||||
## 9. Set up the CLI
|
||||
@@ -322,7 +339,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 +391,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
|
||||
|
||||
+255
@@ -0,0 +1,255 @@
|
||||
# Prometheus metrics
|
||||
|
||||
jarld can serve metrics for [Prometheus](https://prometheus.io/) at
|
||||
`/metrics`:
|
||||
- **health** you can alert on: named up, validation problems, files changed
|
||||
outside Jarl, blocklists that fail to download, backup age, DNSSEC zones
|
||||
waiting for you at the parent, and API tokens about to expire,
|
||||
- **queries**, once [query statistics](stats.md) are on: named's counters
|
||||
(result codes, record types, cache, upstream times, DNSSEC validation, rate
|
||||
limiting) and Jarl's own (responses per view and zone, blocked queries per
|
||||
blocklist), plus named's memory, CPU, cache and sockets.
|
||||
|
||||
Metrics are **off by default**. Everything is set in `/etc/jarl/jarl.yaml`;
|
||||
nothing is stored in the database.
|
||||
|
||||
## Turning it on
|
||||
|
||||
Prometheus on the same server needs only:
|
||||
|
||||
```yaml
|
||||
metrics:
|
||||
enabled: true
|
||||
```
|
||||
|
||||
```sh
|
||||
sudo systemctl restart jarl
|
||||
curl -s http://127.0.0.1:8053/metrics | head
|
||||
```
|
||||
|
||||
`/metrics` is served on the addresses in `listen` (and their TLS, if set)
|
||||
and on the local socket. Query counters also need `stats.enabled: true`
|
||||
([stats.md](stats.md)); without it you get only the health metrics.
|
||||
|
||||
### Prometheus on another machine
|
||||
|
||||
Give `/metrics` an address of its own and say who may scrape it:
|
||||
|
||||
```yaml
|
||||
metrics:
|
||||
enabled: true
|
||||
listen: ["0.0.0.0:9450"] # serves only /metrics, over plain HTTP
|
||||
allow: ["192.168.1.20"] # the Prometheus server
|
||||
```
|
||||
|
||||
```yaml
|
||||
# prometheus.yml
|
||||
scrape_configs:
|
||||
- job_name: jarl
|
||||
static_configs:
|
||||
- targets: ["dns.lan:9450"]
|
||||
```
|
||||
|
||||
Or use a token instead of (or as well as) the address list. Any address
|
||||
that sends it may scrape:
|
||||
|
||||
```yaml
|
||||
metrics:
|
||||
enabled: true
|
||||
listen: ["0.0.0.0:9450"]
|
||||
token: "…" # e.g. from: openssl rand -hex 24
|
||||
```
|
||||
|
||||
```yaml
|
||||
# prometheus.yml
|
||||
scrape_configs:
|
||||
- job_name: jarl
|
||||
authorization:
|
||||
credentials: "…" # or credentials_file: /etc/prometheus/jarl.token
|
||||
static_configs:
|
||||
- targets: ["dns.lan:9450"]
|
||||
```
|
||||
|
||||
With a token in jarl.yaml, keep the file readable by root and jarld only
|
||||
(`chown root:jarl /etc/jarl/jarl.yaml; chmod 640 /etc/jarl/jarl.yaml`);
|
||||
jarld warns at start when everyone can read it. On `metrics.listen` the
|
||||
token travels unencrypted, so use it on a network you trust, or scrape the
|
||||
main listener over HTTPS instead (`scheme: https` and its port in
|
||||
Prometheus).
|
||||
|
||||
### Settings
|
||||
|
||||
| Key | Default | Meaning |
|
||||
|-----|---------|---------|
|
||||
| `metrics.enabled` | `false` | Serve `/metrics`. |
|
||||
| `metrics.listen` | `[]` | Extra `ADDRESS:PORT`s that serve only `/metrics`, over plain HTTP. |
|
||||
| `metrics.allow` | `["127.0.0.1", "::1"]` | Addresses or prefixes that may scrape without a token. Behind a reverse proxy in `trusted_proxies`, this is the client's address from `X-Forwarded-For`. `[]` with a token: the token is required everywhere. |
|
||||
| `metrics.token` | `""` | A bearer token (at least 16 characters) that lets any address scrape. |
|
||||
|
||||
A scrape is allowed when it comes from an address in `allow`, **or**
|
||||
carries the token, **or** comes over the local socket from root or jarld's
|
||||
own user. A wrong token is refused (and counts towards the per-address
|
||||
limit on failed logins). Jarl's API tokens don't work for `/metrics`.
|
||||
|
||||
The firewall must let Prometheus reach the port. `allow` is checked by
|
||||
jarld, not the kernel.
|
||||
|
||||
## What is exported
|
||||
|
||||
Everything is prefixed `jarl_`. The text format is checked with `promtool
|
||||
check metrics`.
|
||||
|
||||
### Health (always)
|
||||
|
||||
These are read at most every 15 seconds; a scrape in between gets the
|
||||
same values.
|
||||
|
||||
| Metric | Meaning |
|
||||
|--------|---------|
|
||||
| `jarl_build_info{version}` | Always 1. |
|
||||
| `jarl_start_time_seconds` | When jarld started. |
|
||||
| `jarl_metrics_state_ok` | 0 when Jarl's state couldn't be read (see jarld's log). |
|
||||
| `jarl_adopted` | 1 when named loads Jarl's configuration. |
|
||||
| `jarl_named_up` | 1 when named answers `rndc status` (missing with `bind.no_reload`). |
|
||||
| `jarl_named_recursive_clients`, `…_soft_limit`, `…_limit` | Recursive clients now, and named's limits (`rndc status`). |
|
||||
| `jarl_named_tcp_clients`, `…_limit` | TCP clients now, and the limit. |
|
||||
| `jarl_open_changesets` | Changesets with staged changes not applied yet. |
|
||||
| `jarl_last_apply_timestamp_seconds` | When the last changeset was applied. |
|
||||
| `jarl_problems{severity}` | Validation problems (`error`, `warning`, `info`) as on the dashboard, including named's own warnings. |
|
||||
| `jarl_drifted_files` | Managed files changed or removed outside Jarl. |
|
||||
| `jarl_objects{kind}` | Views, zones, records, ACLs, TSIG keys and options. |
|
||||
| `jarl_backup_last_success_timestamp_seconds`, `jarl_backup_last_size_bytes` | The newest backup by `jarld backup`. |
|
||||
| `jarl_backup_last_failure_timestamp_seconds` | A failed backup newer than the last good one. |
|
||||
| `jarl_backup_stale` | 1 when the newest backup is older than `backup.warn_after`. |
|
||||
| `jarl_blocking_paused` | 1 while blocking is paused (only with blocklists or rules). |
|
||||
| `jarl_blocking_paused_until_timestamp_seconds` | When a timed pause ends. |
|
||||
| `jarl_blocklist_enabled{list}` | 1 for lists that are switched on. |
|
||||
| `jarl_blocklist_entries{list}` | Domains in the last good download. |
|
||||
| `jarl_blocklist_failing{list}` | 1 when the last download or check of an enabled list failed. |
|
||||
| `jarl_blocklist_last_check_timestamp_seconds{list}`, `jarl_blocklist_last_change_timestamp_seconds{list}` | When the list was last checked, and last changed. |
|
||||
| `jarl_dnssec_phase{zone,view,phase}` | Always 1, one per signed zone; `phase` is one of the [DNSSEC phases](dnssec.md). `ready` means: add the DS at the parent. `removing_ds`: remove it. |
|
||||
| `jarl_token_expiry_timestamp_seconds{id,user,token}` | When each API token with an expiry date expires. |
|
||||
| `jarl_certificate_expiry_timestamp_seconds{certificate,source}` | When each certificate Jarl manages expires ([certificates.md](certificates.md)). |
|
||||
| `jarl_certificate_failing{certificate,source}` | 1 when obtaining (or reading) the certificate failed the last time, or it has expired. |
|
||||
| `jarl_certificate_renewal_failures{certificate}` | Failed attempts to obtain a certificate since its last success. |
|
||||
| `jarl_mail_reverse_dns_ok{zone,address}` | 1 when an address that sends or receives a public zone's mail has a PTR record that resolves back to it, at the last check, every 6 hours ([exposure.md](exposure.md#mail)). |
|
||||
| `jarl_encrypted_dns_up{kind,address,port}` | 1 when the DoT or DoH listener answered with the right, current certificate at the last check ([encrypted-dns.md](encrypted-dns.md#health)). |
|
||||
|
||||
### Queries (with `stats.enabled`)
|
||||
|
||||
The counters start at zero when jarld starts (Prometheus handles that).
|
||||
named's counters move once a minute, when Jarl polls named's statistics
|
||||
channel. Counters from the response log (`responses`, views, zones,
|
||||
blocking) move as lines are read and need `stats.query_log`. Clients in
|
||||
`stats.exclude_clients` are left out of those. Names and clients are not
|
||||
exported, because there would be too many of them. Use the Statistics page
|
||||
for them.
|
||||
|
||||
| Metric | Meaning |
|
||||
|--------|---------|
|
||||
| `jarl_queries_total` | Queries named received. |
|
||||
| `jarl_responses_by_rcode_total{rcode}` | Responses by result code (`NOERROR`, `NXDOMAIN`, `SERVFAIL`, `REFUSED`, …). |
|
||||
| `jarl_queries_by_type_total{type}` | Queries by record type. |
|
||||
| `jarl_queries_by_transport_total{transport}` | `udp` and `tcp`. |
|
||||
| `jarl_answers_total{source}` | `authoritative` or `recursive`. |
|
||||
| `jarl_recursion_queries_total`, `jarl_dropped_queries_total`, `jarl_duplicate_queries_total` | Queries that caused recursion, that named dropped, and duplicates. |
|
||||
| `jarl_ratelimit_responses_total{action}` | Responses rate limiting `dropped` or `slipped` (named's counters; they don't move in log-only mode). |
|
||||
| `jarl_ratelimit_logged_total{action}` | Rate limited responses from named's log: `drop`, `slip`, and in log-only mode `would-drop`, `would-slip` ([exposure.md](exposure.md#response-rate-limiting)). |
|
||||
| `jarl_upstream_responses_by_rtt_total{rtt}` | Upstream answers during recursion by round-trip time (`<10ms` … `>1600ms`). |
|
||||
| `jarl_dnssec_validations_total{result}` | `valid`, `valid-negative` and `failed`. |
|
||||
| `jarl_cache_lookups_total{result}` | `hits` and `misses`. |
|
||||
| `jarl_logged_responses_total` | Responses read from the response log. |
|
||||
| `jarl_responses_by_view_total{view}` | Logged responses per view (`_default` without views). |
|
||||
| `jarl_responses_by_zone_total{zone}` | Logged responses per local zone; `-` for names in no local zone. |
|
||||
| `jarl_blocked_queries_total` | Queries blocking answered. |
|
||||
| `jarl_blocked_by_list_total{list}` | Blocked queries per blocklist; `rules` for your own rules. |
|
||||
| `jarl_stats_poll_ok`, `jarl_stats_last_poll_timestamp_seconds` | Whether the last poll of named's statistics channel worked, and when it was. |
|
||||
| `jarl_stats_log_lines_total`, `jarl_stats_last_log_line_timestamp_seconds` | Response log lines read, and the time of the last one. |
|
||||
| `jarl_stats_warnings` | Warnings on the Statistics page (e.g. no log lines while named answers queries). |
|
||||
| `jarl_named_start_time_seconds`, `jarl_named_config_time_seconds` | When named started, and last loaded its configuration. |
|
||||
| `jarl_named_resident_memory_bytes`, `jarl_named_threads`, `jarl_named_cpu_seconds_total` | named's process (`/proc`). |
|
||||
| `jarl_named_memory_in_use_bytes` | Memory named counts as in use. |
|
||||
| `jarl_named_cache_rrsets`, `jarl_named_cache_memory_bytes` | named's caches, all views together. |
|
||||
| `jarl_named_sockets{proto}` | Open UDP and TCP sockets. |
|
||||
| `jarl_named_recursive_clients_highwater`, `jarl_named_tcp_clients_highwater` | The most clients at once since named started. |
|
||||
|
||||
Host metrics (load, memory, disks) are left to
|
||||
[node_exporter](https://github.com/prometheus/node_exporter).
|
||||
|
||||
## Example queries
|
||||
|
||||
```promql
|
||||
# Queries per second, and the share that failed
|
||||
rate(jarl_queries_total[5m])
|
||||
rate(jarl_responses_by_rcode_total{rcode="SERVFAIL"}[5m]) / ignoring(rcode) rate(jarl_queries_total[5m])
|
||||
|
||||
# Blocked share of logged responses, per list
|
||||
rate(jarl_blocked_by_list_total[1h]) / ignoring(list) group_left rate(jarl_logged_responses_total[1h])
|
||||
|
||||
# Busiest zones
|
||||
topk(5, rate(jarl_responses_by_zone_total{zone!="-"}[1h]))
|
||||
|
||||
# Cache hit rate
|
||||
rate(jarl_cache_lookups_total{result="hits"}[5m]) / ignoring(result) sum without(result) (rate(jarl_cache_lookups_total[5m]))
|
||||
```
|
||||
|
||||
## Example alerts
|
||||
|
||||
```yaml
|
||||
groups:
|
||||
- name: jarl
|
||||
rules:
|
||||
- alert: JarlDown
|
||||
expr: up{job="jarl"} == 0
|
||||
for: 5m
|
||||
- alert: NamedDown
|
||||
expr: jarl_named_up == 0
|
||||
for: 2m
|
||||
- alert: JarlConfigErrors
|
||||
expr: jarl_problems{severity="error"} > 0
|
||||
for: 15m
|
||||
- alert: JarlDrift
|
||||
expr: jarl_drifted_files > 0
|
||||
for: 15m
|
||||
- alert: BlocklistFailing
|
||||
expr: jarl_blocklist_failing == 1
|
||||
for: 6h
|
||||
- alert: BackupStale
|
||||
expr: jarl_backup_stale == 1
|
||||
- alert: DNSSECWaitingForYou
|
||||
expr: jarl_dnssec_phase{phase=~"ready|removing_ds|unsigned"} == 1
|
||||
for: 1h
|
||||
annotations:
|
||||
summary: "{{ $labels.zone }}: DNSSEC is waiting for a step at the parent ({{ $labels.phase }})"
|
||||
- alert: TokenExpiresSoon
|
||||
expr: jarl_token_expiry_timestamp_seconds - time() < 7 * 86400
|
||||
annotations:
|
||||
summary: "API token {{ $labels.token }} of {{ $labels.user }} expires within a week"
|
||||
- alert: CertificateFailing
|
||||
expr: jarl_certificate_failing == 1
|
||||
for: 12h
|
||||
annotations:
|
||||
summary: "Certificate {{ $labels.certificate }} can't be renewed (see the Certificates page)"
|
||||
- alert: CertificateExpiresSoon
|
||||
expr: jarl_certificate_expiry_timestamp_seconds - time() < 10 * 86400
|
||||
annotations:
|
||||
summary: "Certificate {{ $labels.certificate }} expires within 10 days"
|
||||
- alert: EncryptedDNSDown
|
||||
expr: jarl_encrypted_dns_up == 0
|
||||
for: 20m
|
||||
annotations:
|
||||
summary: "DNS over {{ $labels.kind }} on {{ $labels.address }} port {{ $labels.port }} isn't working (see the Encrypted DNS page)"
|
||||
- alert: MailReverseDNS
|
||||
expr: jarl_mail_reverse_dns_ok == 0
|
||||
annotations:
|
||||
summary: "{{ $labels.address }} ({{ $labels.zone }} mail) has no reverse DNS that resolves back: receivers may reject its mail"
|
||||
- alert: BlockingPausedForLong
|
||||
expr: jarl_blocking_paused == 1
|
||||
for: 24h
|
||||
- alert: NamedRecursiveClientsHigh
|
||||
expr: jarl_named_recursive_clients / jarl_named_recursive_clients_soft_limit > 0.8
|
||||
for: 5m
|
||||
- alert: StatsNotCollecting
|
||||
expr: jarl_stats_poll_ok == 0 or jarl_stats_warnings > 0
|
||||
for: 15m
|
||||
```
|
||||
+58
-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
|
||||
@@ -340,9 +360,29 @@ cp /etc/bind/named.conf.pre-jarl-<date> /etc/bind/named.conf
|
||||
rndc reconfig
|
||||
```
|
||||
|
||||
## Failed logins in the journal
|
||||
|
||||
A failed login to the web UI or the API (a wrong password, or a wrong
|
||||
two-factor code), and a refused API token, are written to the journal as
|
||||
well as the audit log, with the address first:
|
||||
|
||||
```
|
||||
level=WARN msg="login failed" ip=203.0.113.9 user=admin reason="wrong username or password"
|
||||
level=WARN msg="token refused" ip=203.0.113.9 reason="wrong token secret"
|
||||
```
|
||||
|
||||
Guard's `jarl` preset, or a fail2ban filter, can ban such addresses at
|
||||
the firewall (with Housecarl's ban lists). The address comes before the
|
||||
user name, which a caller chooses, so a pattern anchored on
|
||||
`msg="login failed" ip=` can't be fooled by a name. With `log.format:
|
||||
json` they are JSON objects with the same fields in the same order.
|
||||
|
||||
## Monitoring
|
||||
|
||||
* `GET /healthz` (no authentication) reports that jarld is up.
|
||||
* Prometheus: `/metrics` covers named up, configuration problems, drift,
|
||||
blocklist failures, backup age, DNSSEC steps waiting at the parent and
|
||||
expiring tokens, with example alerts ([metrics.md](metrics.md)).
|
||||
* On the server, a check can use the local socket and needs no token:
|
||||
`sudo jarl status -o json`.
|
||||
* `GET /api/v1/status` with a read-only token reports named reachability,
|
||||
|
||||
+113
-7
@@ -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,102 @@ 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, use the registrar (Porkbun,
|
||||
whose key can move the domain), and manage TLS certificates (whose keys
|
||||
can impersonate the server; [certificates.md](certificates.md)). The one
|
||||
exception is certificates for other hosts, below.
|
||||
|
||||
A grant is written as:
|
||||
|
||||
@@ -38,7 +129,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,7 +137,9 @@ 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) |
|
||||
| `*` | all of the above |
|
||||
| `blocking` | blocklists and block/allow rules ([blocking.md](blocking.md)); `write` also downloads lists now |
|
||||
| `certificates` | certificates for other hosts (`jarl cert get`), by the names in them: `write` gets one obtained, `read` only downloads it. Never part of `*` |
|
||||
| `*` | all of the above, except `certificates` |
|
||||
|
||||
`ACCESS` is `read` or `write` (write includes read). Filters narrow a grant.
|
||||
Each takes comma separated patterns where `*` and `?` are wildcards, and a
|
||||
@@ -55,6 +148,8 @@ missing filter matches everything:
|
||||
* `zones=adbraun.lol,*.adbraun.lol`: zone names, without the final dot
|
||||
* `views=internal`: view names (`_default` means zones outside any view)
|
||||
* `names=_acme-challenge.*`: record owner names, as FQDNs without the final dot
|
||||
(for `certificates`: the names in the certificate; `*.adbraun.lol` also
|
||||
allows the wildcard certificate `*.adbraun.lol`)
|
||||
* `types=TXT,CNAME`: record types
|
||||
|
||||
Examples:
|
||||
@@ -66,10 +161,18 @@ zones:write zones=*.lab.adbraun.lol create/delete zones be
|
||||
records:write zones=adbraun.lol views=internal only the internal copy of a split zone
|
||||
records:write names=_acme-challenge.* types=TXT ACME challenges anywhere
|
||||
records:write zones=acme.adbraun.lol types=TXT only TXT records in a dedicated ACME zone
|
||||
certificates:write names=mail.adbraun.lol,smtp.adbraun.lol certificates for a mail server (jarl cert get)
|
||||
```
|
||||
|
||||
A user who may see some records of a zone may also see that the zone exists
|
||||
(its name and type), but not its other records.
|
||||
(its name and type), but not its other records. They also see the zone's
|
||||
view in `jarl view list` (`GET /views`): its name, colour and how many
|
||||
zones they can see there, marked `limited`, but not its settings
|
||||
(`match-clients` and the rest need `views:read`).
|
||||
|
||||
A zone name that is in several views needs a view (`--view`), unless the
|
||||
user can see the zone in only one of them: a token for
|
||||
`views=external` finds the external copy without being told.
|
||||
|
||||
### Roles
|
||||
|
||||
@@ -79,9 +182,10 @@ 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` |
|
||||
| `certificates` | `certificates:write`: certificates for other hosts (`jarl cert get`) for any name in Jarl's zones. Narrow it to a host's names, e.g. `certificates:write names=web01.adbraun.lol` ([certificates.md](certificates.md#certificates-for-other-hosts)) |
|
||||
|
||||
```sh
|
||||
jarl user create alice --role dns-editor
|
||||
@@ -107,6 +211,7 @@ The audit log shows the user and the token name.
|
||||
jarl token create laptop # full rights of the user, no expiry
|
||||
jarl token create ci --grant "records:write zones=lab.adbraun.lol" --expires 90d
|
||||
jarl token create certbot --user certbot --role acme # admin: a token for another user
|
||||
jarl token create web01 --grant "certificates:write names=web01.adbraun.lol" # jarl cert get on one host
|
||||
jarl token list --all
|
||||
jarl token revoke 12
|
||||
```
|
||||
@@ -237,4 +342,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)
|
||||
```
|
||||
+204
@@ -0,0 +1,204 @@
|
||||
# Reverse zones
|
||||
|
||||
A reverse zone answers "which host has this address?" with PTR records:
|
||||
`20.10.0.10.in-addr.arpa. PTR nas.adbraun.lol.`. Your own clients use them
|
||||
for names in logs, `traceroute`, the router's client list and `ssh`
|
||||
warnings. For your private networks they only exist if this server has
|
||||
them, and they are easy to forget, so Jarl can work them out from the
|
||||
records you already have.
|
||||
|
||||
Who asks matters:
|
||||
|
||||
* **Your own clients** ask this server, so a reverse zone in a private
|
||||
view (internal) answers them.
|
||||
* **The internet** asks whoever owns the address: your ISP or hosting
|
||||
provider for public addresses. Mail servers check those PTR records, and
|
||||
you set them in the provider's panel, not in Jarl. The Exposure page
|
||||
checks them ([exposure.md](exposure.md#mail)). A reverse zone for a
|
||||
public address in a public view (external) does nothing unless the owner
|
||||
delegated it to you.
|
||||
|
||||
So reverse zones belong in private views. A view whose clients reach it
|
||||
from the internet gets no suggestions, and neither does a view you turn
|
||||
them off for: a view only a few servers use, such as one for your VPS,
|
||||
matches only their addresses and so counts as private, but its clients
|
||||
don't need reverse zones (`jarl view update vps
|
||||
--reverse-suggestions=false`, or the view's settings). Exposure doesn't
|
||||
change.
|
||||
|
||||
## Missing reverse zones
|
||||
|
||||
The Zones page lists, under **Missing reverse zones**, the reverse zones a
|
||||
private view lacks for the addresses of its A and AAAA records, in two
|
||||
groups:
|
||||
|
||||
```
|
||||
$ jarl zone suggest-reverse
|
||||
GROUP VIEW NETWORK ZONE PTRS FOR EXAMPLE
|
||||
yours internal 10.0.10.0/24 10.0.10.in-addr.arpa 14 10.0.10.1 router.adbraun.lol, ...
|
||||
yours internal 2a02:8013:7f3d::/48 d.3.f.7.3.1.0.8.2.0.a.2.ip6.arpa 9 2a02:8013:7f3d:10::5 nas.adbraun.lol, ...
|
||||
public internal 139.144.144.154/32 154.144.144.139.in-addr.arpa 1 139.144.144.154 ldn.adbraun.lol
|
||||
public internal 185.199.108.153/32 153.108.199.185.in-addr.arpa 1 185.199.108.153 blog.adbraun.lol
|
||||
|
||||
$ jarl zone suggest-reverse --create # yours, filled, in one changeset
|
||||
$ jarl zone create 139.144.144.154/32 --view internal --fill # a public one that is yours
|
||||
$ jarl zone suggest-reverse --dismiss 185.199.108.153/32 --view internal # GitHub's, not yours
|
||||
```
|
||||
|
||||
**Yours**, created by **Create all** (`--create`):
|
||||
|
||||
* the **/24** of a private IPv4 address (10/8, 172.16/12, 192.168/16,
|
||||
100.64/10);
|
||||
* the **/48** of a ULA (fd00::/8), or of a public IPv6 prefix this server
|
||||
has an address in (the /48 your ISP delegates): one zone for all its
|
||||
subnets. If you have a /56, create that instead (`jarl zone create
|
||||
2a02:db8:1:ab00::/56`).
|
||||
|
||||
**Public addresses, only if they're yours**: any other public address,
|
||||
each as a /32 (or /128) of its own. Jarl can't tell your VPS from a web
|
||||
host's server (a `blog` record pointing at GitHub Pages), and a zone for
|
||||
someone else's address would show your clients your name for it instead
|
||||
of theirs. The rest of the /24 belongs to others: a /24 zone here would
|
||||
answer NXDOMAIN to your clients for all of their addresses. Create the
|
||||
ones that are yours one by one, and **Dismiss** the others: Jarl
|
||||
remembers, per view (`--undismiss` brings one back).
|
||||
|
||||
There can be hundreds of public addresses (records pointing at a web
|
||||
host or a CDN), so the group is folded away when it is long. Opened, it
|
||||
has a search and shows 25 at a time; **Dismiss these N** dismisses every
|
||||
address the search found, in one change: search `104.16.` and dismiss
|
||||
the CDN's addresses together. From the CLI, `--dismiss` takes several
|
||||
networks (comma separated or repeated). The New reverse zone list has a
|
||||
search too.
|
||||
|
||||
Loopback and link-local addresses, wildcards and disabled records are left
|
||||
out.
|
||||
|
||||
## Creating one by hand
|
||||
|
||||
Where nothing is suggested (a view with suggestions off, a dismissed
|
||||
network, a public view) or the suggested size isn't the one you want,
|
||||
**New reverse zone** on the Zones page lists every network your records
|
||||
use that has no reverse zone, in every view, marked when it isn't
|
||||
suggested unasked. Pick one: the network and the view are filled in.
|
||||
Then the **Size** buttons change it, around the address of one of the
|
||||
records: a suggested `2a02:8013:7f3d::/48` becomes
|
||||
`2a02:8013:7f3d:10::/64` for the subnet the records are in, or `/128`
|
||||
for one address. The zone name shows as you go, and **PTR records** stays
|
||||
ticked, so the zone is created with a PTR record for each address
|
||||
(IPv6 nibble names written for you).
|
||||
|
||||
```sh
|
||||
jarl zone suggest-reverse --all --view vps # every network, hidden ones marked
|
||||
jarl zone create 2a02:8013:7f3d:10::/64 --view internal --fill
|
||||
```
|
||||
|
||||
**Records in another view.** With a view chosen (`--view internal`, or
|
||||
the dialog's View), the list also has the addresses only other views'
|
||||
records use, marked "records in external": say a VPS's AAAA record that
|
||||
only the external view has. Fill names those from the other view's
|
||||
record only when asked: `--other-views` (with `--fill` or `jarl zone
|
||||
fill-ptr`), or the dialog's **Also for addresses only other views'
|
||||
records use**, which is ticked for you when you pick a "records in"
|
||||
network. Otherwise one view's host names would turn up in another's
|
||||
reverse zone, which is what views are for keeping apart. Jarl says which
|
||||
names came from another view. Keeping PTR records in step (below) stays
|
||||
within a zone's own view and its linked views: a later change to that
|
||||
external record doesn't reach the internal reverse zone.
|
||||
|
||||
```sh
|
||||
jarl zone create 2600:3c01::f03c:93ff:fe2b:1/128 --view internal --fill --other-views
|
||||
jarl zone fill-ptr 10.0.10.0/24 --view vps --other-views
|
||||
```
|
||||
|
||||
## Creating and filling
|
||||
|
||||
```sh
|
||||
jarl zone create 10.0.10.0/24 --fill # the view whose records use 10.0.10.x
|
||||
jarl zone fill-ptr 10.0.10.0/24 # later: add what is missing
|
||||
```
|
||||
|
||||
**The view.** A reverse zone created without `--view` goes to the private
|
||||
view whose records use addresses in its network (views with suggestions
|
||||
off don't count). If none does, it goes to the only private view. If
|
||||
several do, Jarl asks you to name the view, or to create the zone in each.
|
||||
|
||||
**Inside another zone.** A zone created inside an existing one in the same
|
||||
view, a /24 inside a /8 or `lab.adbraun.lol` inside `adbraun.lol`, takes
|
||||
the parent's records under it, in the same changeset: named answers those
|
||||
names from the new zone, so left in the parent they would never be served
|
||||
again. PTR records you added by hand come along; `--fill` then adds only
|
||||
what is still missing. A delegation (NS records at the new zone's name,
|
||||
with their glue) stays in the parent. A linked parent keeps its records,
|
||||
with a warning, since its other views would lose them too.
|
||||
|
||||
**Fill** adds a PTR record for every address in the network that the
|
||||
view's A and AAAA records use. An address with several names gets one PTR:
|
||||
|
||||
1. the name of a mail or name server (an MX or NS target), else
|
||||
2. a host's name rather than the domain's own (`nas.adbraun.lol`, not
|
||||
`adbraun.lol`), else
|
||||
3. the shortest name, then the first alphabetically.
|
||||
|
||||
Jarl says which name it chose, and which it didn't, so you can change it.
|
||||
A reverse zone's page shows the addresses that still lack a PTR record and
|
||||
the name each would get (the first 25), with a button to add them. `jarl
|
||||
zone show` shows how many as `Missing PTRs`; the API lists them in
|
||||
`ptr_missing_list`. Only the zone's own view's records count: an address
|
||||
that only another view's records use isn't missing (say internal's
|
||||
`claude.adbraun.lol` and the vps view's copy of `10.0.10.in-addr.arpa`).
|
||||
Those are listed apart, with the name each would get: `Other views` in
|
||||
`jarl zone show`, `ptr_other_views` and `ptr_other_views_list` in the
|
||||
API, and a quiet note on the page with an **Add PTR records for them in
|
||||
view vps** button. Filling never adds them unasked. `jarl zone show`
|
||||
lists the missing ones too:
|
||||
|
||||
```
|
||||
Missing PTRs: 1 (addresses the view's records use here; add them: jarl zone fill-ptr 10.0.10.0/24)
|
||||
10.0.10.7 -> printer.adbraun.lol
|
||||
Other views: 2 addresses with A or AAAA records only in view internal, so not missing here (add PTRs for them in this view too: jarl zone fill-ptr 10.0.10.0/24 --other-views)
|
||||
10.0.10.26 -> gomft.adbraun.lol (view internal)
|
||||
10.0.10.101 -> claude.adbraun.lol (view internal)
|
||||
```
|
||||
|
||||
## Keeping PTR records in step
|
||||
|
||||
The **Update PTR** box on an A or AAAA record (`--ptr`) moves the PTR record
|
||||
along with that one change. To have that on every change, turn it on for
|
||||
the forward zone:
|
||||
|
||||
```sh
|
||||
jarl zone update adbraun.lol --view internal --auto-ptr # off: --auto-ptr=false
|
||||
```
|
||||
|
||||
(Zone settings → **Reverse DNS** in the web UI.) Then adding, changing,
|
||||
disabling, enabling or deleting an A or AAAA record in the zone does the
|
||||
same to its PTR record, in the reverse zone of each view that has the
|
||||
record. A whole set replaced at once (`jarl record set`, a batch) works
|
||||
too.
|
||||
|
||||
**Linked zones** ([views.md](views.md#linked-zones)). PTR records follow
|
||||
each view's own address records:
|
||||
|
||||
* A shared record gets a PTR record in every linked view that has it; a
|
||||
record local to one view gets one in that view only.
|
||||
* An address that goes from one view (deleted, changed, or overridden by
|
||||
a local record) loses its PTR record there only. Views that still have
|
||||
the address keep theirs.
|
||||
* Each zone's own **Reverse DNS** setting counts: a linked zone without
|
||||
it doesn't get PTR records from its partner's changes.
|
||||
* In a linked reverse zone, these PTR records (and those **Fill** adds)
|
||||
are local to their view. A PTR record that is shared becomes local in
|
||||
each view the first time one of them changes it, so that a change in
|
||||
one view doesn't reach the others.
|
||||
|
||||
* **One name per address.** When an address's PTR record names another
|
||||
host, it stays: a second A record for the same address doesn't add a
|
||||
second PTR record.
|
||||
* **No reverse zone, no PTR.** An address without a reverse zone in the
|
||||
view, such as a public address in the external view, is skipped without
|
||||
a warning. The suggestions above show what is missing.
|
||||
* **Wildcards** (`*.dev`) never get a PTR record.
|
||||
|
||||
The PTR changes are part of the same changeset, so you review them, and
|
||||
they are applied or undone together with the record.
|
||||
+124
-4
@@ -60,6 +60,7 @@ off again. The statistics database stays until you delete it
|
||||
stats:
|
||||
enabled: false
|
||||
query_log: true # per-query data; false = named's counters only
|
||||
rate_limit_log: true # with a rate-limit: also log what it limits (query-errors)
|
||||
database: /var/lib/jarl/stats.db
|
||||
statistics_channel: 127.0.0.1:8058 # must be a loopback address
|
||||
log_file: /var/log/named/jarl-responses.log
|
||||
@@ -69,6 +70,7 @@ stats:
|
||||
minute: 7d # per-minute counters
|
||||
hour: 90d # hourly counters and top lists
|
||||
day: 730d # daily counters and top lists
|
||||
queries: 7d # every answer, for exploring (0: none)
|
||||
top_n: 100 # names and clients kept per hour and per day
|
||||
anonymize_clients: false # store 10.10.0.0/24 instead of 10.10.0.37 (IPv6: /48)
|
||||
exclude_clients: [] # e.g. [10.10.0.250] to ignore a monitoring probe
|
||||
@@ -81,6 +83,13 @@ the response log. Logging every answer costs named some CPU and disk I/O,
|
||||
which is negligible at homelab volumes. At tens of thousands of queries per
|
||||
second, consider counters only.
|
||||
|
||||
`rate_limit_log` matters only with a `rate-limit` set: Jarl then sends
|
||||
named's `query-errors` category (where rate limiting logs what it limits,
|
||||
or would in log-only mode) to the response log too, and keeps it in
|
||||
syslog. That other query errors come along is the price; `false` stops it,
|
||||
and the Exposure page can't show rate limiting's activity then
|
||||
([exposure.md](exposure.md#response-rate-limiting)).
|
||||
|
||||
`exclude_clients` drops those clients from the log-derived figures (top
|
||||
lists, views, zones). named's own counters can't be filtered.
|
||||
|
||||
@@ -102,22 +111,59 @@ 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) |
|
||||
| `rrl:drop`, `rrl:slip`, `rrl:would-drop`, `rrl:would-slip` | response log | responses [rate limiting](exposure.md#response-rate-limiting) limited, or would have in log-only mode (named's own `ratelimit:*` counters don't move then) |
|
||||
|
||||
**Server** (sampled every minute; stored as gauges, `g:KEY` the sum of
|
||||
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**:
|
||||
|
||||
@@ -131,23 +177,97 @@ jarl stats top refused # who asks for what they may not have
|
||||
jarl stats timeline --since 6h # sparkline; --table for every bucket
|
||||
jarl stats timeline --since 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 breakdown qtype | rcode | view | zone | answer | rtt | dnssec | cache | transport | ratelimit | rrl
|
||||
jarl stats top ratelimited # client networks rate limiting limits (or would)
|
||||
jarl stats server # memory, CPU, cache, clients, host, disks now
|
||||
jarl stats timeline --since 7d --key g:named:rss --table # named's memory over a week
|
||||
jarl stats summary --since 30d -o json # export (also -o yaml)
|
||||
```
|
||||
|
||||
**API**: see [api.md](api.md#query-statistics).
|
||||
|
||||
**Prometheus**: with `metrics.enabled`, the counters are also served at
|
||||
`/metrics` ([metrics.md](metrics.md)).
|
||||
|
||||
**Permissions**: the `stats` resource (`stats:read`). The `readonly` and
|
||||
`operator` roles include it, `dns-editor` doesn't. Statistics show client
|
||||
addresses and what they look up, so grant it with care.
|
||||
|
||||
## Exploring
|
||||
|
||||
The counters and top lists say how much, not who asked for what: "100
|
||||
NXDOMAIN answers in the external view" can't tell you which clients asked.
|
||||
For that, jarld also keeps every answer for a while, the **query log**: time,
|
||||
client, view, name, record type, result, and the blocklist (or your rules)
|
||||
that blocked it. It is in the statistics database, from the response log
|
||||
named writes anyway, and answers older than `retention.queries` (default
|
||||
7 days) are deleted every hour; `0` keeps none.
|
||||
|
||||
With it, every client, name, view, record type, result and blocklist on the
|
||||
Statistics page is a link to the **explore** page (`#/stats/explore`),
|
||||
which shows, for what you clicked:
|
||||
|
||||
* the answers over time (and how many were blocked);
|
||||
* the top clients, names, views, record types, results and blocklists
|
||||
among them, each a link that narrows the filter further;
|
||||
* the latest 100 answers, every cell a link.
|
||||
|
||||
So "external, NXDOMAIN" leads to the clients asking, a client to everything
|
||||
it asks, one of its names to who else asks for it. The page also opens
|
||||
from **Explore the answers** at the top of the Statistics page, and has a
|
||||
filter bar to type filters in: clients, names, views, record types,
|
||||
results, blocked or not; it suggests values from what is shown.
|
||||
|
||||
Each kind of filter takes several values, comma separated, and matches
|
||||
any of them: `10.0.0.5, 10.0.0.7` is either client. `!value` leaves one
|
||||
out (`!10.0.0.250` hides a monitoring probe), and `*.example.com` is the
|
||||
name and every name below it, so `!*.apple.com` drops a chatty device's
|
||||
noise. Kinds combine: this client, in this view, NXDOMAIN, not that name.
|
||||
In the lists, clicking a value adds it and **hide** leaves it out; every
|
||||
value has a chip with a ✕ to take it out again. Filters combine (this
|
||||
client, in this view, blocked only) and each has a ✕ to take it out. A name
|
||||
can be widened to the names below it (`domain=`). The Statistics page's
|
||||
view switches apply here too.
|
||||
|
||||
`anonymize_clients` and `exclude_clients` apply to the query log as to
|
||||
everything else. Its size: each answer takes about 150 to 200 bytes with
|
||||
its indexes, so 50,000 answers a day for 7 days is about 60 MB, 200,000 a
|
||||
day about 250 MB. The Statistics page's server section shows the
|
||||
statistics database's size and how many answers the query log holds, next
|
||||
to the disks (with a bar of how full each is); the dashboard's Storage
|
||||
panel has all of Jarl's databases.
|
||||
|
||||
## Views
|
||||
|
||||
With views, the Statistics page has a toggle per view (and **All** and
|
||||
**None**): switch some off to see only the others, for example your
|
||||
internal and VPS views without the internet's noise on the external one,
|
||||
or several linked views together. The choice is remembered in your
|
||||
browser. The CLI takes `--view` on every `jarl stats` command (repeat it,
|
||||
or separate views with commas), and the API `?views=internal,vps`.
|
||||
|
||||
Only what named's response log says can be split by view: answers (shown
|
||||
as *answers* instead of queries), result codes, record types, zones,
|
||||
blocking, rate limiting and the top lists. named's own counters (answer
|
||||
sources, transport, upstream response times, DNSSEC, the cache and the
|
||||
server) are for the whole server, and the page says so; asking for them
|
||||
per view (`jarl stats breakdown answer --view internal`) is refused.
|
||||
|
||||
Per-view figures are kept from the version that added them (Jarl 0.4.1);
|
||||
the page says since when. They take some more room in the statistics
|
||||
database, roughly in proportion to the number of views.
|
||||
|
||||
## How data is kept
|
||||
|
||||
- Minute counters are kept for `retention.minute`, hourly counters and top
|
||||
lists for `retention.hour`, daily ones for `retention.day`. Older data is
|
||||
deleted every hour.
|
||||
- Charts use the finest resolution that covers the range: minutes up to 48
|
||||
hours, hours up to 90 days, days beyond.
|
||||
hours (and `retention.minute`), hours up to `retention.hour`, days beyond.
|
||||
- The Statistics page offers only the ranges the data is kept for: up to 90
|
||||
days with the defaults, plus a year and 2 years when `retention.day` (or
|
||||
`retention.hour`) is that long. The Explore page offers the ranges up to
|
||||
`retention.queries`.
|
||||
- Top lists are exact within an hour (up to 200,000 distinct names or
|
||||
clients per hour, beyond which the rarest are dropped). Daily lists are
|
||||
built from the hourly top lists, so for very long tails they are
|
||||
|
||||
+90
-5
@@ -30,10 +30,11 @@ 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 |
|
||||
| `cname_chain` | warning | CNAME to a CNAME: works, but slows resolution |
|
||||
| `cname_chain` | info | a chain of three or more CNAMEs (one CNAME to another says nothing): works, but each is another lookup for some resolvers |
|
||||
| `cname_dangling` | warning | CNAME to a name that doesn't exist in the zone |
|
||||
| `dname_multiple`, `below_dname` | error | One DNAME per name, and nothing below a DNAME (RFC 6672) |
|
||||
| `apex_ns_missing` | error | A zone needs NS records at its apex |
|
||||
@@ -56,6 +57,8 @@ showing them.
|
||||
| `zone_missing_option` | error | Secondary, stub and mirror zones need `primaries` |
|
||||
| `zone_file_required` / `zone_file_invalid` / `zone_file_managed` | error | Hint zones need a file. Forward zones have none. Primary zone files are Jarl's |
|
||||
| `zone_inview_invalid`, `zone_inview_option` | error | `in-view` zones only inside views, and only with `forward`/`forwarders` |
|
||||
| `zone_inview_disabled` | error | An `in-view` zone can't share a disabled zone |
|
||||
| `zone_disabled_signed` | error | A signed zone can't be disabled: turn signing off first |
|
||||
| `zone_records_unmanaged` | error | Only primary zones can have records in Jarl |
|
||||
| `zone_serial_policy` | error | `date`, `increment` or `unixtime` |
|
||||
| `zone_no_forwarders`, `zone_hint_name`, `zone_name_slash` | info / warning | Unusual but legal setups |
|
||||
@@ -64,28 +67,103 @@ showing them.
|
||||
|
||||
| Code | Severity | Rule |
|
||||
|------|----------|------|
|
||||
| `acl_element_invalid` | error | Address match elements: addresses, prefixes (host bits must be zero, like BIND requires), `!negation`, `key NAME`, ACL names, `{ nested lists }`, `any`/`none`/`localhost`/`localnets` |
|
||||
| `acl_element_invalid` | error | Address match elements: addresses, prefixes (host bits must be zero, like BIND requires), ranges (`10.0.10.101-10.0.10.199`: both ends full addresses of one family, start not after end), `!negation`, `key NAME`, ACL names, `{ nested lists }`, `any`/`none`/`localhost`/`localnets`. Ranges in options are checked when they're entered, and only options with address match lists take them (not `forwarders`, say) |
|
||||
| `range_mapped4` | warning | An IPv6 range that reaches into `::ffff:0:0/96`: named can see IPv4 clients as such addresses, so it may match IPv4 clients too |
|
||||
| `acl_ref_missing`, `key_ref_missing` | error | Every ACL or TSIG key that is referenced must exist |
|
||||
| `acl_cycle` | error | ACLs that include each other |
|
||||
| `acl_duplicate`, `acl_name_invalid`, `acl_empty`, `acl_shadowed` | error / warning | Unique names, built-in names reserved, elements that can never match |
|
||||
| `key_*` | error / warning | Valid key names, a supported algorithm, a base64 secret of a sensible length. hmac-md5/sha1 are flagged as weak |
|
||||
| `acl_duplicate`, `acl_name_invalid`, `acl_empty`, `acl_shadowed` | error / warning | Unique names, built-in names reserved, names that look like addresses or ranges, elements that can never match (a prefix or range an earlier one already covers) |
|
||||
| `key_*` | error / warning | Valid key names, a supported algorithm, a base64 secret of a sensible length. hmac-md5/sha1 are flagged as weak. Names starting with `jarl-view-` are reserved for the view keys (`key_name_reserved`) |
|
||||
| `view_duplicate`, `view_name_invalid`, `view_acl_invalid` | error | Valid, unique views and match lists |
|
||||
| `view_unreachable` | warning | A view that comes after one matching every client is never used |
|
||||
| `option_unknown`, `option_scope_invalid` | error | The option must exist in BIND 9.20 and be allowed where it is set (server, view, zone of this type, top level) |
|
||||
| `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) |
|
||||
|
||||
## TLS
|
||||
|
||||
| Code | Severity | Check |
|
||||
|------|----------|-------|
|
||||
| `tls_file_missing` | error | The files a top-level `tls` statement names (certificate, key, CA) exist: named doesn't load its configuration without them |
|
||||
|
||||
## Linked zones
|
||||
|
||||
See [views.md](views.md#linked-zones). Jarl keeps linked zones in step
|
||||
itself, so these only show when something got past it (a change staged
|
||||
before the zones were linked, or the database edited by hand).
|
||||
|
||||
| Code | Severity | Check |
|
||||
|------|----------|-------|
|
||||
| `linked_zone_type` | error | Only primary zones can be linked |
|
||||
| `linked_zone_name` | error | Linked zones have the same name |
|
||||
| `linked_mixed` | error | A name and type in a zone is either shared or local, not both |
|
||||
| `linked_out_of_sync` | error | Every view that doesn't override a shared name and type has the same records for it. Link the zones again (`jarl zone link`) to work out what is shared |
|
||||
| `linked_zone_alone` | info | A zone linked with no other zone |
|
||||
|
||||
## Blocking
|
||||
|
||||
From [blocking.md](blocking.md).
|
||||
|
||||
| Code | Severity | Rule |
|
||||
|------|----------|------|
|
||||
| `blocking_rule_duplicate` | error | One rule per domain and view |
|
||||
| `blocking_response_policy` | error | No `response-policy` option where Jarl's blocking writes that statement (the view, or the server) |
|
||||
| `zone_name_reserved` | error | Zone names under `jarl.invalid` are reserved for blocking's policy zones |
|
||||
| `blocking_view_missing` | error | A list or rule names a view that doesn't exist |
|
||||
| `blocking_no_view` | warning | A list or rule applies to no view: none offers recursion, and blocking only changes recursive answers |
|
||||
| `blocking_no_recursion` | warning | A list or rule names a view that doesn't offer recursion, where it has no effect |
|
||||
| `blocking_step_missing` | error | A list or client group is at a step that doesn't exist (a list can't be at `off`) |
|
||||
| `client_group_member_invalid` | error | Client group members are addresses, networks, or ACLs made only of those |
|
||||
| `client_group_overlap` | error | The same address or network is in two client groups of a view |
|
||||
| `client_group_empty` | warning | A client group has no members |
|
||||
| `blocklist_step_unused` | warning | A list is at a step that no client group reaches, so it blocks nothing |
|
||||
|
||||
## Security lints
|
||||
|
||||
These and the exposure checks below only look at views that are
|
||||
reachable from the internet ([exposure.md](exposure.md)); without views,
|
||||
the open resolver and open transfer checks look at the server. Any of them
|
||||
can be acknowledged (it then shows as info, with who accepted it and why),
|
||||
and a change that newly introduces an open resolver, open transfers, cache
|
||||
snooping, open NOTIFY or an open management interface on a public view
|
||||
must be confirmed (`--accept-risk`).
|
||||
|
||||
| Code | Severity | What it catches |
|
||||
|------|----------|-----------------|
|
||||
| `security_open_resolver` | warning | A view (or the server) where recursion is on and reachable by any client. This includes the BIND trap where `allow-recursion` is unset and inherits `allow-query { any; }`. Open resolvers are abused for DDoS amplification. Set `allow-recursion` to your own networks |
|
||||
| `security_open_transfer` | warning | `allow-transfer` open to anyone. Restrict it to your secondaries or a TSIG key |
|
||||
| `dynamic_update_unsupported` | error | `allow-update` / `update-policy` on a Jarl zone. named would write to files that Jarl regenerates, losing the updates. Use the API (e.g. `jarl acme`) instead |
|
||||
| `exposure_rate_limit` | warning | A public view without response rate limiting (`rate-limit`) |
|
||||
| `exposure_rate_limit_log_only` | info | Rate limiting in log-only mode: check what it would limit, then enforce |
|
||||
| `exposure_minimal_any` | warning | A public view answering ANY queries in full (`minimal-any` off) |
|
||||
| `exposure_cache_snooping` | warning | A public view without recursion whose cache anyone may query (`allow-query-cache`) |
|
||||
| `exposure_open_notify` | warning | A public view accepting NOTIFY from anyone (`allow-notify`) |
|
||||
| `exposure_version`, `exposure_hostname` | warning | The server is public and shows its BIND version or host name to anyone (`version none`, `hostname none`) |
|
||||
| `exposure_management` | warning | `controls` or `statistics-channels` on an address that isn't loopback, open to anyone, while the server is public |
|
||||
|
||||
## What only BIND checks
|
||||
|
||||
@@ -95,3 +173,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.
|
||||
+194
-10
@@ -81,21 +81,34 @@ avoids named-checkconf's warning that `allow-recursion` is still active in a
|
||||
view with recursion off. Jarl warns (`security_open_resolver`) if a view
|
||||
ever offers recursion to everyone.
|
||||
|
||||
Jarl counts the external view as reachable from the internet (its
|
||||
match-clients end in `any`) and checks it: rate limiting, ANY queries,
|
||||
the version string and more. See the **Exposure** page or `jarl exposure`,
|
||||
and [exposure.md](exposure.md) to set up response rate limiting.
|
||||
|
||||
### 4. Give the external view its zones
|
||||
|
||||
**Different data per view (split DNS).** Copy the zone, then change what
|
||||
should differ:
|
||||
**Mostly the same, a few differences (split DNS).** Create the external
|
||||
copy *linked* ([below](#linked-zones)): Jarl keeps the shared records the
|
||||
same in both views, and each view can have records of its own:
|
||||
|
||||
```sh
|
||||
jarl zone create adbraun.lol --view external --link-with internal
|
||||
jarl record set adbraun.lol www A 203.0.113.20 --view external --local # public address, external only
|
||||
jarl record delete adbraun.lol nas-internal A --view external # gone from both: it was shared
|
||||
```
|
||||
|
||||
Delete records you don't want outside before you link, or make them local
|
||||
to the internal view first (`jarl record update ID --local`): a shared
|
||||
record is in both views.
|
||||
|
||||
**Two separate copies.** `--clone-from` copies the zone once; from then on
|
||||
the two are separate zones, and a record added to one isn't in the other:
|
||||
|
||||
```sh
|
||||
jarl zone create adbraun.lol --view external --clone-from adbraun.lol --clone-view internal
|
||||
jarl record list adbraun.lol --view external
|
||||
jarl record update <id> --data 203.0.113.20 # public address instead of 10.10.0.20
|
||||
jarl record delete adbraun.lol nas-internal A --view external
|
||||
```
|
||||
|
||||
From then on the two copies are separate zones. A record added to one isn't
|
||||
in the other, and the serials are separate.
|
||||
|
||||
**The same data in both views.** Use an `in-view` zone. The external view
|
||||
then serves the internal view's zone, so there is one copy to maintain:
|
||||
|
||||
@@ -116,7 +129,168 @@ 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; }`).
|
||||
|
||||
**Moving between views.** A zone's page lists the other views that have a
|
||||
zone of the same name ("also in external", in each view's colour). Each
|
||||
one links straight to that zone, on the tab you are on.
|
||||
|
||||
## Linked zones
|
||||
|
||||
Most split-horizon zones are the same in every view apart from a few
|
||||
names. Linking zones of the same name in different views lets Jarl keep
|
||||
the shared records in step, while each view can still have records of its
|
||||
own and its own zone options.
|
||||
|
||||
* **Shared records** are in every linked view. Adding, changing,
|
||||
disabling or deleting one in any view does the same in the others, in
|
||||
the same changeset: the diff shows every view, and history, undo and
|
||||
`jarl check` treat them together.
|
||||
* **Local records** are in one view only (`--local`, or **Only here** in
|
||||
the web UI). A local record overrides the shared records of the same name
|
||||
and type in that view: `www A 10.0.10.5` local to internal, while the
|
||||
external view keeps the shared `www A 203.0.113.20`. Delete the local
|
||||
records and the name and type are gone from that view only: the other
|
||||
views keep their records, each local to its view now. To give the view
|
||||
the shared version back instead, **Share** it from a view that has it.
|
||||
* A new record joins the set of the same name and type: local if that set
|
||||
is local in its view, shared otherwise.
|
||||
* **Sharing a version** (`jarl record update ID --shared`, **Share** in the
|
||||
web UI) makes that view's records of that name and type the shared
|
||||
version: the other views' records of that name and type are replaced.
|
||||
* Zones stay separate in named, so each keeps its own serial, options
|
||||
(allow-transfer, DNSSEC signing, ...) and file, and the views keep their
|
||||
own `match-clients`, recursion and order.
|
||||
|
||||
```sh
|
||||
jarl zone link adbraun.lol --view internal --with external # link two existing zones
|
||||
jarl zone create adbraun.lol --view vps --link-with external # a new zone, linked from the start
|
||||
jarl record list adbraun.lol --view internal # LINK column: shared or local
|
||||
jarl record add adbraun.lol nas A 10.0.10.20 --view internal --local
|
||||
jarl record update 1234 --local # this name and type: only this view
|
||||
jarl record update 1234 --shared # this view's version, everywhere
|
||||
jarl zone unlink adbraun.lol --view vps # records stay, all local
|
||||
```
|
||||
|
||||
**Linking never changes what named answers.** A name and type with the
|
||||
same records in every view becomes shared; one that differs stays local to
|
||||
each view (Jarl lists them). Share the right version afterwards. Unlinking
|
||||
keeps every record, all local; a link left with one zone is dissolved, as
|
||||
when the other zone is deleted.
|
||||
|
||||
`--local` also works on a zone that isn't linked (its records are local
|
||||
anyway), so scripts don't need to know; `--shared` there is refused.
|
||||
|
||||
**Permissions** are checked in every view a change reaches. A token with
|
||||
a grant for one view only (`views=internal`) can change that view's local
|
||||
records and make overrides, but not a shared record, since that would
|
||||
change the other views too: Jarl says so and suggests a local record. A
|
||||
new name and type it adds is local to its view without being asked (an
|
||||
ACME token for `views=external` adds its `_acme-challenge` TXT records to
|
||||
the external view only). A grant without a view (the usual
|
||||
`zones=adbraun.lol`) covers every view.
|
||||
|
||||
**Importing a zone file** into a linked zone (`jarl zone import`) adds
|
||||
the records like any others: shared, unless the name and type is local
|
||||
there. With `--replace`, shared records missing from the file are deleted
|
||||
in every linked view.
|
||||
|
||||
**ACME challenges** are written once per link, and so reach every linked
|
||||
view; `jarl acme`, lego's httpreq and certificates Jarl obtains all do
|
||||
this.
|
||||
|
||||
**Compared with BIND's `in-view`**, where one view serves another's zone
|
||||
as it is: linked zones can differ per view, keep their own options, and
|
||||
don't depend on the view order (`in-view` can only use a view defined
|
||||
before it).
|
||||
|
||||
## Comparing views
|
||||
|
||||
A zone's **Compare views** tab, or `jarl zone diff`, compares it with the
|
||||
same zone in another view (or all of them), from the view you're on.
|
||||
The tab starts with a view the zone is linked to, if any, else the
|
||||
first other view; `jarl zone diff` compares with every other view unless
|
||||
`--with` names some:
|
||||
|
||||
```
|
||||
$ jarl zone diff adbraun.lol --view internal
|
||||
adbraun.lol in internal, compared with external
|
||||
linked: a difference is a view's own records, there on purpose
|
||||
external: 1 answered differently, 24 only in internal, 2 only in external, 31 the same
|
||||
|
||||
ANSWERED DIFFERENTLY (1)
|
||||
nas A
|
||||
internal 10.0.10.20 [local, id 812]
|
||||
external 203.0.113.20 [local, id 790]
|
||||
|
||||
ONLY IN INTERNAL (24)
|
||||
printer A 10.0.10.30
|
||||
router A 10.0.10.1
|
||||
...
|
||||
|
||||
ONLY IN EXTERNAL (2)
|
||||
_acme-challenge TXT "…"
|
||||
...
|
||||
|
||||
31 names and types are the same (--all lists them)
|
||||
```
|
||||
|
||||
* **Answered differently**: names and types more than one view has, with
|
||||
different records. In linked zones that is a view's own (local)
|
||||
records, there on purpose; between zones that aren't linked, it is a
|
||||
difference that crept in. **Use this everywhere** gives every view one
|
||||
view's records (in linked zones by sharing them: `jarl record update ID
|
||||
--shared`).
|
||||
* **Only in …**: names and types some views lack. **Add to external**
|
||||
gives that view the same records, its own.
|
||||
* **The same**: how many; `--all` (or "List them") shows them.
|
||||
|
||||
One line per record: the CLI cuts long values to fit the terminal
|
||||
(`--wide` shows them whole); the web UI shows the whole value on hover.
|
||||
The apex NS records are left out, since views often list their name
|
||||
servers differently on purpose.
|
||||
|
||||
**One name.** `jarl zone diff adbraun.lol nas` (and a type: `... @ TXT`)
|
||||
shows just that name in every view, the same records included. In the
|
||||
web UI, a record's **Compare** button does the same. `--with external`
|
||||
(repeat it) picks the views to compare with.
|
||||
|
||||
## Living with views
|
||||
|
||||
@@ -125,10 +299,20 @@ On the server, `dig -b <source address>` picks which view you test.
|
||||
guess.
|
||||
* `jarl acme` writes challenge records into **every** view that has the
|
||||
zone, so Let's Encrypt (external) and internal clients see the same
|
||||
thing. Use `--view` to limit it.
|
||||
thing (once per link, for linked zones). Use `--view` to limit it.
|
||||
* `--view all` (or a list) on record commands changes each linked zone
|
||||
once: the change reaches the others through the link. With `--local` it
|
||||
goes to each view named.
|
||||
* `jarl view order internal external` changes the match order, and
|
||||
`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`.
|
||||
@@ -4,6 +4,7 @@ go 1.27.1
|
||||
|
||||
require (
|
||||
github.com/aymanbagabas/go-udiff v0.4.1
|
||||
github.com/go-acme/lego/v4 v4.35.2
|
||||
github.com/miekg/dns v1.1.73
|
||||
github.com/spf13/cobra v1.10.2
|
||||
go.yaml.in/yaml/v3 v3.0.5
|
||||
@@ -11,10 +12,13 @@ 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 (
|
||||
github.com/cenkalti/backoff/v5 v5.0.3 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/go-jose/go-jose/v4 v4.1.4 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.24 // indirect
|
||||
|
||||
@@ -1,8 +1,16 @@
|
||||
github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o=
|
||||
github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w=
|
||||
github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM=
|
||||
github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/go-acme/lego/v4 v4.35.2 h1:uVQg+KC/yj9R2g7Q9W5wDqhvQvxV5SMu5eqFVoN5xZU=
|
||||
github.com/go-acme/lego/v4 v4.35.2/go.mod h1:pX2jN5n8OphMGY1IaMjYm5DAEzguBaKRt8AvJAgJXpc=
|
||||
github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA=
|
||||
github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo=
|
||||
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
@@ -24,6 +32,8 @@ github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
|
||||
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
|
||||
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
|
||||
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
|
||||
go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
|
||||
@@ -72,3 +82,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=
|
||||
+17
-1
@@ -21,6 +21,10 @@ const Comment = "ACME DNS-01 challenge"
|
||||
type Zone struct {
|
||||
Name string // as Jarl stores it, e.g. "adbraun.lol."
|
||||
View string // "" when views are not used
|
||||
// Link is the zone's link group (0: not linked). A record written
|
||||
// into one zone of a link reaches the others (docs/views.md#linked-zones),
|
||||
// so only one of them is written to.
|
||||
Link int64
|
||||
}
|
||||
|
||||
// Zones returns the zones that hold name: the longest matching zone, in
|
||||
@@ -46,7 +50,19 @@ func Zones(name string, zones []Zone, view string) []Zone {
|
||||
out = append(out, z)
|
||||
}
|
||||
}
|
||||
return out
|
||||
// One zone per link: Jarl writes the shared record into the others.
|
||||
var dedup []Zone
|
||||
links := map[int64]bool{}
|
||||
for _, z := range out {
|
||||
if z.Link != 0 {
|
||||
if links[z.Link] {
|
||||
continue
|
||||
}
|
||||
links[z.Link] = true
|
||||
}
|
||||
dedup = append(dedup, z)
|
||||
}
|
||||
return dedup
|
||||
}
|
||||
|
||||
// Requests returns the changes that set (or, with set false, remove) the
|
||||
|
||||
+410
-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,41 @@ 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"`
|
||||
// LinkedWith are the views of the zones this one is linked with
|
||||
// (docs/views.md#linked-zones).
|
||||
LinkedWith []string `json:"linked_with,omitempty"`
|
||||
// Links are the zones this one is linked with, with their views.
|
||||
Links []ZoneLink `json:"links,omitempty"`
|
||||
// InViews are the zones of this name in the other views (any type),
|
||||
// in view order: for going straight to one.
|
||||
InViews []ZoneLink `json:"in_views,omitempty"`
|
||||
// OtherViews are the other views with a primary zone of this name
|
||||
// (linked or not): what GET /zones/{zone}/compare compares.
|
||||
OtherViews []string `json:"other_views,omitempty"`
|
||||
// Prefix is the network a reverse zone answers for; PTRMissing how
|
||||
// many of its view's A and AAAA addresses in it have no PTR record
|
||||
// (POST /zones/{zone}/fill-ptr adds them).
|
||||
Prefix string `json:"prefix,omitempty"`
|
||||
PTRMissing int `json:"ptr_missing,omitempty"`
|
||||
// PTRMissingList are the first 25 of them: the PTR records adding
|
||||
// them would create.
|
||||
PTRMissingList []PTRFill `json:"ptr_missing_list,omitempty"`
|
||||
// PTROtherViews counts the addresses in it, without a PTR record,
|
||||
// that only other views' records use: not missing, but fill-ptr with
|
||||
// other_views=1 adds them, naming them from there. PTROtherViewsList
|
||||
// are the first 25 (From says which view).
|
||||
PTROtherViews int `json:"ptr_other_views,omitempty"`
|
||||
PTROtherViewsList []PTRFill `json:"ptr_other_views_list,omitempty"`
|
||||
// Pending is set when listing with a changeset: add, change or delete.
|
||||
Pending string `json:"pending,omitempty"`
|
||||
}
|
||||
@@ -32,16 +65,40 @@ type Record struct {
|
||||
View string `json:"view,omitempty"`
|
||||
RelativeName string `json:"relative_name"`
|
||||
EffectiveTTL uint32 `json:"effective_ttl"`
|
||||
// Linked is "shared" or "local" for a record of a linked zone ("" otherwise).
|
||||
Linked string `json:"linked,omitempty"`
|
||||
// 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
|
||||
// Limited is set for a view listed only because the principal can see
|
||||
// zones in it (no views:read): only its name, colour and the zones
|
||||
// they can see are given.
|
||||
Limited bool `json:"limited,omitempty"`
|
||||
}
|
||||
|
||||
// PTRFill is a PTR record a reverse zone lacks: Name (the PTR owner) for
|
||||
// Address would give Target. Others are its other names; From the view
|
||||
// whose record names it, when the zone's own view has none.
|
||||
type PTRFill struct {
|
||||
Address string `json:"address"`
|
||||
Name string `json:"name"`
|
||||
Target string `json:"target"`
|
||||
Others []string `json:"others,omitempty"`
|
||||
From string `json:"from,omitempty"`
|
||||
}
|
||||
|
||||
// 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 +119,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 +150,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.
|
||||
@@ -123,6 +250,9 @@ type BatchRequest struct {
|
||||
Ops []ChangeRequest `json:"ops"`
|
||||
DryRun bool `json:"dry_run,omitempty"`
|
||||
Force bool `json:"force,omitempty"`
|
||||
// AcceptRisk confirms a change that opens the server up
|
||||
// (docs/exposure.md).
|
||||
AcceptRisk bool `json:"accept_risk,omitempty"`
|
||||
// Apply (for POST /changesets) applies the new changeset at once.
|
||||
Apply bool `json:"apply,omitempty"`
|
||||
}
|
||||
@@ -150,6 +280,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 +289,93 @@ 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"`
|
||||
// Certificates sums up the TLS certificates Jarl manages
|
||||
// (administrators only, when there are any).
|
||||
Certificates *CertSummary `json:"certificates,omitempty"`
|
||||
// EncryptedDNS is the last check of DNS over TLS and HTTPS, when it
|
||||
// is set up (for those who may see options).
|
||||
EncryptedDNS *EncDNSSummary `json:"encrypted_dns,omitempty"`
|
||||
// Storage is the size of Jarl's databases and how full the disks Jarl
|
||||
// uses are (administrators only).
|
||||
Storage *Storage `json:"storage,omitempty"`
|
||||
}
|
||||
|
||||
// Storage is the size of Jarl's databases and the disks Jarl uses.
|
||||
type Storage struct {
|
||||
Databases []Database `json:"databases"`
|
||||
Total int64 `json:"total"` // the databases' bytes
|
||||
Disks []Disk `json:"disks"`
|
||||
}
|
||||
|
||||
// Database is one of Jarl's databases: its file with its -wal and -shm.
|
||||
type Database struct {
|
||||
Label string `json:"label"`
|
||||
Path string `json:"path"`
|
||||
Bytes int64 `json:"bytes"`
|
||||
// Rows is how many answers the query log holds (statistics only),
|
||||
// and QueryLogRetention how long it keeps them ("7d").
|
||||
QueryLogRows *int64 `json:"query_log_rows,omitempty"`
|
||||
QueryLogRetention string `json:"query_log_retention,omitempty"`
|
||||
}
|
||||
|
||||
// Disk is a file system Jarl uses: what of Jarl's it holds, its size,
|
||||
// what is free, and how much of it the databases take.
|
||||
type Disk struct {
|
||||
Path string `json:"path"`
|
||||
Holds []string `json:"holds"`
|
||||
Total int64 `json:"total"`
|
||||
Free int64 `json:"free"`
|
||||
Databases int64 `json:"databases"`
|
||||
}
|
||||
|
||||
// CertSummary is what the dashboard reports about certificates.
|
||||
type CertSummary struct {
|
||||
Total int `json:"total"`
|
||||
// Problems are the certificates that aren't fine: failing, expiring,
|
||||
// expired (not ones still being obtained for the first time).
|
||||
Problems []CertProblem `json:"problems"`
|
||||
// Next is the certificate that expires first, and when.
|
||||
Next string `json:"next,omitempty"`
|
||||
NextAfter *time.Time `json:"next_after,omitempty"`
|
||||
// Serving is the certificate jarld serves HTTPS with ("" for none or tls.cert_file).
|
||||
Serving string `json:"serving,omitempty"`
|
||||
}
|
||||
|
||||
// CertProblem is a certificate that needs attention.
|
||||
type CertProblem struct {
|
||||
Name string `json:"name"`
|
||||
State string `json:"state"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// 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
|
||||
@@ -208,6 +426,9 @@ type UserInput struct {
|
||||
Role *string `json:"role,omitempty"` // a role template name
|
||||
Disabled *bool `json:"disabled,omitempty"`
|
||||
Comment *string `json:"comment,omitempty"`
|
||||
// CertLimit is how many certificates the user may own (jarl cert
|
||||
// get); a negative number goes back to certificates.max_per_user.
|
||||
CertLimit *int `json:"cert_limit,omitempty"`
|
||||
}
|
||||
|
||||
// TokenInput creates a token.
|
||||
@@ -267,3 +488,185 @@ 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
|
||||
// DSAt says where the zone's DS record goes: "parent_zone" (a primary
|
||||
// zone in Jarl, below), "registrar" (the zone is a registered domain),
|
||||
// "parent" (a subdomain whose parent zone is hosted elsewhere),
|
||||
// "reverse" (whoever delegates the addresses) or "none" (nothing on
|
||||
// the internet delegates the zone, so it can't have one).
|
||||
DSAt string `json:"ds_at"`
|
||||
// DSPlace completes "add the DS record …" ("at your registrar"); ""
|
||||
// for "none".
|
||||
DSPlace string `json:"ds_place,omitempty"`
|
||||
// DSNote explains DSAt "none".
|
||||
DSNote string `json:"ds_note,omitempty"`
|
||||
// ParentZone is set when the parent zone is a primary zone in Jarl
|
||||
// (in the zone's own view, else preferably a public one): 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"`
|
||||
// Step is the name of the step the list starts at ("normal" or one
|
||||
// above it).
|
||||
Step string `json:"step"`
|
||||
}
|
||||
|
||||
// BlockStep is a blocking step above normal as returned by the API, with
|
||||
// what uses it.
|
||||
type BlockStep struct {
|
||||
model.BlockStep
|
||||
Lists []string `json:"lists"`
|
||||
Groups []string `json:"groups"`
|
||||
Pending string `json:"pending,omitempty"`
|
||||
}
|
||||
|
||||
// ClientGroup is a client group as returned by the API.
|
||||
type ClientGroup struct {
|
||||
model.ClientGroup
|
||||
Step string `json:"step"`
|
||||
Views []string `json:"views"`
|
||||
AutoViews bool `json:"auto_views,omitempty"`
|
||||
Pending string `json:"pending,omitempty"`
|
||||
}
|
||||
|
||||
// BlockRule is a block or allow rule as returned by the API.
|
||||
type BlockRule struct {
|
||||
model.BlockRule
|
||||
Views []string `json:"views"`
|
||||
AutoViews bool `json:"auto_views,omitempty"`
|
||||
Pending string `json:"pending,omitempty"`
|
||||
}
|
||||
|
||||
// ZoneLink is a zone a zone is linked with.
|
||||
type ZoneLink struct {
|
||||
ID int64 `json:"id"`
|
||||
View string `json:"view"`
|
||||
// Type and Linked are set in Zone.InViews.
|
||||
Type string `json:"type,omitempty"`
|
||||
Linked bool `json:"linked,omitempty"`
|
||||
}
|
||||
|
||||
// EncDNSSummary is what the dashboard says about encrypted DNS.
|
||||
type EncDNSSummary struct {
|
||||
Checked time.Time `json:"checked"`
|
||||
OK bool `json:"ok"`
|
||||
Problems []string `json:"problems"`
|
||||
Warnings []string `json:"warnings"`
|
||||
}
|
||||
@@ -134,3 +134,33 @@ func TestRoleTemplatesValid(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCertificatesRole(t *testing.T) {
|
||||
p := &Principal{User: &model.User{Grants: RoleTemplates["certificates"]}}
|
||||
if !p.Can(Action{Resource: ResCerts, Write: true, Name: "web01.adbraun.lol"}) {
|
||||
t.Error("the certificates role can't get a certificate")
|
||||
}
|
||||
if p.Can(Action{Resource: ResRecords, Write: true, Zone: "adbraun.lol."}) || p.Can(Action{Resource: ResZones, Zone: "adbraun.lol."}) {
|
||||
t.Error("the certificates role reaches beyond certificates")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCertificateGrants(t *testing.T) {
|
||||
g, err := ParseGrant("certificates:write names=*.adbraun.lol")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !GrantAllows(g, Action{Resource: ResCerts, Write: true, Name: "mail.adbraun.lol"}) || GrantAllows(g, Action{Resource: ResCerts, Write: true, Name: "mail.example.org"}) {
|
||||
t.Error("names filter")
|
||||
}
|
||||
if _, err := ParseGrant("certificates:read types=TXT"); err == nil {
|
||||
t.Error("types on certificates accepted")
|
||||
}
|
||||
if _, err := ParseGrant("certificates:read zones=adbraun.lol"); err == nil {
|
||||
t.Error("zones on certificates accepted")
|
||||
}
|
||||
// "*" never covers certificates: they hand out private keys.
|
||||
if GrantAllows(model.Grant{Resource: ResAll, Access: Write}, Action{Resource: ResCerts, Name: "mail.adbraun.lol"}) {
|
||||
t.Error("* covers certificates")
|
||||
}
|
||||
}
|
||||
+26
-12
@@ -12,19 +12,23 @@ 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
|
||||
// ResCerts is certificates for other hosts (jarl cert get), by the
|
||||
// names in them. It is never part of "*": it hands out private keys.
|
||||
ResCerts = "certificates"
|
||||
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, ResCerts}
|
||||
|
||||
// Access levels.
|
||||
const (
|
||||
@@ -73,8 +77,11 @@ func ValidateGrant(g *model.Grant) error {
|
||||
for i, t := range g.Types {
|
||||
g.Types[i] = strings.ToUpper(t)
|
||||
}
|
||||
if (len(g.Names) > 0 || len(g.Types) > 0) && g.Resource != ResRecords {
|
||||
return fmt.Errorf("names and types filters only apply to the records resource")
|
||||
if len(g.Types) > 0 && g.Resource != ResRecords {
|
||||
return fmt.Errorf("the types filter only applies to the records resource")
|
||||
}
|
||||
if len(g.Names) > 0 && g.Resource != ResRecords && g.Resource != ResCerts {
|
||||
return fmt.Errorf("the names filter only applies to the records and certificates resources")
|
||||
}
|
||||
if (len(g.Zones) > 0 || len(g.Views) > 0) && g.Resource != ResRecords && g.Resource != ResZones && g.Resource != ResViews {
|
||||
return fmt.Errorf("zone and view filters only apply to the zones, records and views resources")
|
||||
@@ -227,6 +234,9 @@ func GrantAllows(g model.Grant, a Action) bool {
|
||||
if g.Resource != ResAll && g.Resource != a.Resource {
|
||||
return false
|
||||
}
|
||||
if g.Resource == ResAll && a.Resource == ResCerts {
|
||||
return false // only an explicit certificates grant
|
||||
}
|
||||
if a.Write && g.Access != Write {
|
||||
return false
|
||||
}
|
||||
@@ -377,7 +387,11 @@ 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"}}},
|
||||
// certificates for other hosts (jarl cert get): narrow it with names=
|
||||
// to the host's own.
|
||||
"certificates": {{Resource: ResCerts, Access: Write}},
|
||||
}
|
||||
+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,180 @@
|
||||
package blocking
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"net/netip"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Client steps (docs/blocking.md): named checks policy zones in order and
|
||||
// the first match decides, so a "gate" zone that lets some clients through
|
||||
// (rpz-passthru, matched on the client's address) keeps them from every
|
||||
// zone after it. Jarl puts a gate in front of the lists of each step,
|
||||
// letting through the clients whose step is below it.
|
||||
|
||||
// GateSuffix follows a gate's name: off.gate.jarl.invalid,
|
||||
// strict.gate.jarl.invalid.
|
||||
const GateSuffix = ".gate.jarl.invalid"
|
||||
|
||||
// v4Mapped holds the IPv4-mapped IPv6 addresses.
|
||||
var v4Mapped = netip.MustParsePrefix("::ffff:0:0/96")
|
||||
|
||||
// GateZone returns the policy zone name of the gate in front of a step's
|
||||
// lists ("off" for the gate in front of the normal lists).
|
||||
func GateZone(step string) string { return step + GateSuffix }
|
||||
|
||||
// Assignment gives the addresses in Prefix a step rank (-1 off, 0 normal,
|
||||
// 1, 2, ... the steps above it).
|
||||
type Assignment struct {
|
||||
Prefix netip.Prefix
|
||||
Rank int
|
||||
}
|
||||
|
||||
// PassBelow returns the fewest prefixes that cover exactly the addresses whose
|
||||
// rank is below k. An address has the rank of the most specific
|
||||
// assignment containing it, and rank 0 (normal) when none does. When two
|
||||
// assignments have the same prefix, the higher rank counts (validation
|
||||
// refuses that anyway).
|
||||
func PassBelow(assigns []Assignment, k int) []netip.Prefix {
|
||||
var out []netip.Prefix
|
||||
for _, root := range []netip.Prefix{netip.MustParsePrefix("0.0.0.0/0"), netip.MustParsePrefix("::/0")} {
|
||||
var mine []Assignment
|
||||
if !root.Addr().Is4() {
|
||||
// named matches IPv4 clients as IPv4-mapped IPv6 addresses:
|
||||
// keep IPv6 prefixes clear of them, so only the IPv4 ones
|
||||
// decide for IPv4 clients.
|
||||
mine = append(mine, Assignment{v4Mapped, math.MaxInt})
|
||||
}
|
||||
rank := 0
|
||||
for _, a := range assigns {
|
||||
p := a.Prefix.Masked()
|
||||
if p.Addr().Is4() != root.Addr().Is4() {
|
||||
continue
|
||||
}
|
||||
if p.Bits() == 0 {
|
||||
rank = max(rank, a.Rank)
|
||||
continue
|
||||
}
|
||||
mine = append(mine, Assignment{p, a.Rank})
|
||||
}
|
||||
for _, p := range cover(root, rank, mine, k) {
|
||||
if p.Bits() == 0 {
|
||||
// A trigger needs at least one bit: use both halves.
|
||||
lo, hi := halves(p)
|
||||
out = append(out, lo, hi)
|
||||
continue
|
||||
}
|
||||
out = append(out, p)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// cover returns the prefixes inside p whose addresses have a rank below k.
|
||||
// rank is p's own; inside are the assignments strictly inside p.
|
||||
func cover(p netip.Prefix, rank int, inside []Assignment, k int) []netip.Prefix {
|
||||
if len(inside) == 0 {
|
||||
if rank < k {
|
||||
return []netip.Prefix{p}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
var out []netip.Prefix
|
||||
lo, hi := halves(p)
|
||||
full := 0
|
||||
for _, h := range []netip.Prefix{lo, hi} {
|
||||
var sub []Assignment
|
||||
for _, a := range inside {
|
||||
if a.Prefix.Bits() > h.Bits() && h.Contains(a.Prefix.Addr()) {
|
||||
sub = append(sub, a)
|
||||
}
|
||||
}
|
||||
c := cover(h, ownRank(inside, h, rank), sub, k)
|
||||
if len(c) == 1 && c[0] == h {
|
||||
full++
|
||||
}
|
||||
out = append(out, c...)
|
||||
}
|
||||
if full == 2 {
|
||||
return []netip.Prefix{p}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ownRank is the rank of h: its own assignments' (the highest), or the
|
||||
// inherited one without any.
|
||||
func ownRank(inside []Assignment, h netip.Prefix, inherited int) int {
|
||||
r, found := 0, false
|
||||
for _, a := range inside {
|
||||
if a.Prefix == h {
|
||||
if !found || a.Rank > r {
|
||||
r = a.Rank
|
||||
}
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return inherited
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
// halves splits a prefix into its two halves.
|
||||
func halves(p netip.Prefix) (netip.Prefix, netip.Prefix) {
|
||||
bits := p.Bits() + 1
|
||||
lo := netip.PrefixFrom(p.Addr(), bits).Masked()
|
||||
b := lo.Addr().AsSlice()
|
||||
i := (bits - 1) / 8
|
||||
b[i] |= 0x80 >> ((bits - 1) % 8)
|
||||
a, _ := netip.AddrFromSlice(b)
|
||||
return lo, netip.PrefixFrom(a, bits)
|
||||
}
|
||||
|
||||
// ClientIPOwner returns the policy zone owner name that matches clients
|
||||
// in p (an rpz-client-ip trigger): 32.37.0.10.10.rpz-client-ip for
|
||||
// 10.10.0.37/32, 64.zz.3d7f.8013.2a02.rpz-client-ip for 2a02:8013:3d7f::/64.
|
||||
func ClientIPOwner(p netip.Prefix) string {
|
||||
p = p.Masked()
|
||||
a := p.Addr()
|
||||
var labels []string
|
||||
if a.Is4() {
|
||||
b := a.As4()
|
||||
labels = []string{fmt.Sprint(b[3]), fmt.Sprint(b[2]), fmt.Sprint(b[1]), fmt.Sprint(b[0])}
|
||||
} else {
|
||||
// RFC 5952 text, with "zz" for the "::" and the words reversed.
|
||||
s := a.String()
|
||||
var words []string
|
||||
if left, right, ok := strings.Cut(s, "::"); ok {
|
||||
if left != "" {
|
||||
words = append(words, strings.Split(left, ":")...)
|
||||
}
|
||||
words = append(words, "zz")
|
||||
if right != "" {
|
||||
words = append(words, strings.Split(right, ":")...)
|
||||
}
|
||||
} else {
|
||||
words = strings.Split(s, ":")
|
||||
}
|
||||
for i := len(words) - 1; i >= 0; i-- {
|
||||
labels = append(labels, words[i])
|
||||
}
|
||||
}
|
||||
return fmt.Sprint(p.Bits()) + "." + strings.Join(labels, ".") + ".rpz-client-ip"
|
||||
}
|
||||
|
||||
// GateZoneFile renders a gate: every client in prefixes passes.
|
||||
func GateZoneFile(title string, prefixes []netip.Prefix) string {
|
||||
owners := make([]string, 0, len(prefixes))
|
||||
for _, p := range prefixes {
|
||||
owners = append(owners, ClientIPOwner(p))
|
||||
}
|
||||
sort.Strings(owners)
|
||||
var sb strings.Builder
|
||||
sb.WriteString(header(title, 1))
|
||||
for _, o := range owners {
|
||||
sb.WriteString(o + "\tCNAME\trpz-passthru.\n")
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package blocking
|
||||
|
||||
import (
|
||||
"net/netip"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func pfx(s string) netip.Prefix { return netip.MustParsePrefix(s) }
|
||||
|
||||
// rankOf is the rank PassBelow's model gives an address.
|
||||
func rankOf(assigns []Assignment, a netip.Addr) int {
|
||||
best, bits := 0, -1
|
||||
for _, x := range assigns {
|
||||
if x.Prefix.Contains(a) && x.Prefix.Bits() >= bits {
|
||||
if x.Prefix.Bits() > bits || x.Rank > best {
|
||||
best = x.Rank
|
||||
}
|
||||
bits = x.Prefix.Bits()
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
func covered(ps []netip.Prefix, a netip.Addr) bool {
|
||||
for _, p := range ps {
|
||||
if p.Contains(a) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func TestPassBelow(t *testing.T) {
|
||||
assigns := []Assignment{
|
||||
{pfx("10.10.0.20/32"), -1}, // work laptop: off
|
||||
{pfx("10.10.0.0/24"), 2}, // the kids' network: very strict
|
||||
{pfx("10.10.0.50/32"), 1}, // except one tablet: strict
|
||||
{pfx("2a02:db8::/64"), 1}, // an IPv6 network: strict
|
||||
{pfx("2a02:db8::99/128"), 0}, // one device in it: normal
|
||||
}
|
||||
probe := []string{"10.10.0.20", "10.10.0.21", "10.10.0.50", "10.10.0.51", "10.10.1.1", "192.168.1.1", "0.0.0.1", "255.255.255.255",
|
||||
"2a02:db8::1", "2a02:db8::99", "2a02:db8:0:1::1", "::1", "ffff::1"}
|
||||
for k := 0; k <= 3; k++ {
|
||||
gate := PassBelow(assigns, k)
|
||||
for _, s := range probe {
|
||||
a := netip.MustParseAddr(s)
|
||||
if want := rankOf(assigns, a) < k; covered(gate, a) != want {
|
||||
t.Errorf("k=%d %s: passes=%v, want %v (rank %d)", k, s, !want, want, rankOf(assigns, a))
|
||||
}
|
||||
}
|
||||
for _, p := range gate {
|
||||
if p.Bits() == 0 {
|
||||
t.Errorf("k=%d: a /0 trigger", k)
|
||||
}
|
||||
// named matches IPv4 clients as ::ffff:a.b.c.d: no IPv6
|
||||
// prefix may reach them.
|
||||
if p.Addr().Is6() && p.Overlaps(v4Mapped) {
|
||||
t.Errorf("k=%d: %s covers IPv4-mapped addresses", k, p)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Off clients only, before the normal lists.
|
||||
if got := PassBelow(assigns, 0); !slices.Equal(got, []netip.Prefix{pfx("10.10.0.20/32")}) {
|
||||
t.Errorf("off gate = %v", got)
|
||||
}
|
||||
// Nobody above normal: everyone passes the gate of step 1: two IPv4
|
||||
// halves, and IPv6 around the IPv4-mapped /96 (96 prefixes).
|
||||
if got := PassBelow(nil, 1); len(got) != 2+96 {
|
||||
t.Errorf("empty step-1 gate = %v", got)
|
||||
}
|
||||
// The complement of one IPv4 address is 32 prefixes (plus IPv6's 96).
|
||||
if got := PassBelow([]Assignment{{pfx("10.10.0.50/32"), 1}}, 1); len(got) != 32+96 {
|
||||
t.Errorf("complement of one address: %d prefixes", len(got))
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientIPOwner(t *testing.T) {
|
||||
for p, want := range map[string]string{
|
||||
"10.10.0.37/32": "32.37.0.10.10.rpz-client-ip",
|
||||
"10.10.0.0/24": "24.0.0.10.10.rpz-client-ip",
|
||||
"2a02:db8::/64": "64.zz.db8.2a02.rpz-client-ip",
|
||||
"2a02:db8::1/128": "128.1.zz.db8.2a02.rpz-client-ip",
|
||||
"::/1": "1.zz.rpz-client-ip",
|
||||
"2001:db8:1:2:3:4:5:6/128": "128.6.5.4.3.2.1.db8.2001.rpz-client-ip",
|
||||
} {
|
||||
if got := ClientIPOwner(pfx(p)); got != want {
|
||||
t.Errorf("%s: %s, want %s", p, got, want)
|
||||
}
|
||||
}
|
||||
z := GateZoneFile("t", []netip.Prefix{pfx("10.10.0.20/32")})
|
||||
if !strings.Contains(z, "32.20.0.10.10.rpz-client-ip\tCNAME\trpz-passthru.\n") {
|
||||
t.Errorf("gate zone:\n%s", z)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
// Package blocking turns blocklists and block/allow rules into BIND
|
||||
// response policy zones (RPZ), and answers which rule or list matches a
|
||||
// name.
|
||||
//
|
||||
// A policy zone holds one rule per owner name: "ads.example CNAME ."
|
||||
// answers NXDOMAIN for ads.example, "*.ads.example CNAME ." for every name
|
||||
// below it, and "CNAME rpz-passthru." answers normally (an exception).
|
||||
// named looks names up in a policy zone like in any zone, so wildcards
|
||||
// follow DNS rules: a wildcard doesn't apply below a name that exists in
|
||||
// the zone. The writers here add the rules that keep "the most specific
|
||||
// rule wins" true despite that (see RulesZone).
|
||||
package blocking
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/netip"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"jarl/internal/dnsname"
|
||||
)
|
||||
|
||||
// Flags say what a list entry covers.
|
||||
const (
|
||||
Exact uint8 = 1 << iota // the name itself
|
||||
Below // every name below it
|
||||
)
|
||||
|
||||
// List is a parsed blocklist.
|
||||
type List struct {
|
||||
// Domains maps names (lowercase, without the final dot) to flags.
|
||||
Domains map[string]uint8
|
||||
// Lines is the number of lines with an entry (not comments or blank
|
||||
// lines); Skipped how many of them were not usable.
|
||||
Lines, Skipped int
|
||||
}
|
||||
|
||||
// Limits bound what Parse accepts.
|
||||
type Limits struct {
|
||||
MaxEntries int // distinct domains (0: no limit)
|
||||
MaxNameLen int // longest name in characters (0: 253)
|
||||
}
|
||||
|
||||
// ErrTooManyEntries is returned when a list has more than MaxEntries
|
||||
// domains.
|
||||
var ErrTooManyEntries = errors.New("too many entries")
|
||||
|
||||
// Parse reads a blocklist. Each line is one of:
|
||||
//
|
||||
// ||ads.example^ Adblock style: the name and everything below it
|
||||
// *.ads.example the name and everything below it
|
||||
// 0.0.0.0 ads.example hosts file (any address; several names allowed)
|
||||
// ads.example just this name
|
||||
//
|
||||
// Comments (#, !), Adblock headers ([Adblock Plus]) and blank lines are
|
||||
// ignored. Lines that can't be used for DNS blocking (exceptions,
|
||||
// cosmetic and URL rules, addresses, single-label names, invalid names)
|
||||
// are counted as skipped.
|
||||
func Parse(r io.Reader, lim Limits) (*List, error) {
|
||||
maxLen := lim.MaxNameLen
|
||||
if maxLen <= 0 || maxLen > 253 {
|
||||
maxLen = 253
|
||||
}
|
||||
l := &List{Domains: map[string]uint8{}}
|
||||
full := false // a new name was refused because of MaxEntries
|
||||
add := func(name string, flags uint8) bool {
|
||||
name, ok := normalize(name, maxLen)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
if _, seen := l.Domains[name]; !seen && lim.MaxEntries > 0 && len(l.Domains) >= lim.MaxEntries {
|
||||
full = true
|
||||
return false
|
||||
}
|
||||
l.Domains[name] |= flags
|
||||
return true
|
||||
}
|
||||
sc := bufio.NewScanner(r)
|
||||
sc.Buffer(make([]byte, 64*1024), 1024*1024)
|
||||
first := true
|
||||
for sc.Scan() {
|
||||
line := sc.Text()
|
||||
if first {
|
||||
line = strings.TrimPrefix(line, "\uFEFF")
|
||||
first = false
|
||||
}
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" || line[0] == '#' || line[0] == '!' || line[0] == '[' {
|
||||
continue
|
||||
}
|
||||
l.Lines++
|
||||
ok := false
|
||||
switch {
|
||||
case strings.HasPrefix(line, "||"):
|
||||
ok = add(adblockDomain(line[2:]), Exact|Below)
|
||||
case strings.ContainsAny(line, "|/$^@") || strings.Contains(line, "##"):
|
||||
// Exceptions (@@), URL and cosmetic rules: not for DNS.
|
||||
default:
|
||||
if i := strings.Index(line, "#"); i >= 0 {
|
||||
line = strings.TrimSpace(line[:i])
|
||||
}
|
||||
fields := strings.Fields(line)
|
||||
switch {
|
||||
case len(fields) == 1 && strings.HasPrefix(fields[0], "*."):
|
||||
ok = add(fields[0][2:], Exact|Below)
|
||||
case len(fields) == 1:
|
||||
ok = add(fields[0], Exact)
|
||||
case len(fields) > 1 && isAddr(fields[0]):
|
||||
for _, f := range fields[1:] {
|
||||
if add(f, Exact) {
|
||||
ok = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if full {
|
||||
return l, fmt.Errorf("%w: the list has more than %d domains", ErrTooManyEntries, lim.MaxEntries)
|
||||
}
|
||||
if !ok {
|
||||
l.Skipped++
|
||||
}
|
||||
}
|
||||
if err := sc.Err(); err != nil {
|
||||
return l, err
|
||||
}
|
||||
return l, nil
|
||||
}
|
||||
|
||||
// adblockDomain returns the domain of an Adblock rule's body
|
||||
// ("ads.example^" or "ads.example^$important"), or "" when the rule
|
||||
// does more than block a domain (other modifiers, paths, wildcards).
|
||||
func adblockDomain(s string) string {
|
||||
d, rest, _ := strings.Cut(s, "^")
|
||||
if rest != "" && rest != "$important" {
|
||||
return ""
|
||||
}
|
||||
if strings.ContainsAny(d, "*/:|$") {
|
||||
return ""
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
func isAddr(s string) bool {
|
||||
_, err := netip.ParseAddr(s)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// localNames are hosts-file names that aren't blocklist entries.
|
||||
var localNames = map[string]bool{"localhost.localdomain": true, "ip6-localhost": true, "ip6-loopback": true}
|
||||
|
||||
// normalize lowercases a name, removes the final dot, converts
|
||||
// international names to their ASCII form, and checks it is usable: at
|
||||
// least two labels of letters, digits, hyphens and underscores, and not an
|
||||
// address.
|
||||
func normalize(name string, maxLen int) (string, bool) {
|
||||
name = strings.TrimSuffix(strings.ToLower(strings.TrimSpace(name)), ".")
|
||||
if name == "" || localNames[name] || isAddr(name) {
|
||||
return "", false
|
||||
}
|
||||
for i := 0; i < len(name); i++ {
|
||||
if name[i] >= utf8.RuneSelf {
|
||||
a, err := dnsname.ToASCII(name)
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
name = strings.TrimSuffix(strings.ToLower(a), ".")
|
||||
break
|
||||
}
|
||||
}
|
||||
if len(name) > maxLen || !strings.Contains(name, ".") {
|
||||
return "", false
|
||||
}
|
||||
for _, label := range strings.Split(name, ".") {
|
||||
if label == "" || len(label) > 63 {
|
||||
return "", false
|
||||
}
|
||||
for i := 0; i < len(label); i++ {
|
||||
c := label[i]
|
||||
if !(c >= 'a' && c <= 'z' || c >= '0' && c <= '9' || c == '-' || c == '_') {
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
}
|
||||
return name, true
|
||||
}
|
||||
|
||||
// NormalizeName checks and normalizes a domain the way list entries are
|
||||
// (for rules entered by hand). The name must have at least two labels.
|
||||
func NormalizeName(name string) (string, error) {
|
||||
n, ok := normalize(name, 253-len(RulesZone)-2)
|
||||
if !ok {
|
||||
return "", fmt.Errorf("%q is not a domain name that can be blocked (it needs at least two labels of letters, digits, hyphens or underscores, like ads.example.com)", name)
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
@@ -0,0 +1,316 @@
|
||||
package blocking
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Policy zone names. They are under .invalid (RFC 6761), so they can
|
||||
// never clash with a real zone, and named only uses them internally.
|
||||
const (
|
||||
// RulesZone holds the rules entered in Jarl (one copy per view).
|
||||
RulesZone = "rules.jarl.invalid"
|
||||
// listSuffix follows a list's name: hagezi.blocklist.jarl.invalid.
|
||||
listSuffix = ".blocklist.jarl.invalid"
|
||||
)
|
||||
|
||||
// ListZone returns the policy zone name of a blocklist.
|
||||
func ListZone(name string) string { return name + listSuffix }
|
||||
|
||||
// ListName returns the blocklist name of a policy zone ("" for the rules
|
||||
// zone and zones that aren't Jarl's).
|
||||
func ListName(zone string) string {
|
||||
zone = strings.TrimSuffix(strings.ToLower(zone), ".")
|
||||
if n, ok := strings.CutSuffix(zone, listSuffix); ok && !strings.Contains(n, ".") {
|
||||
return n
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// Match says what blocking does with a name in one view.
|
||||
type Match struct {
|
||||
View string `json:"view,omitempty"`
|
||||
Result string `json:"result"` // blocked, allowed or none
|
||||
Entry string `json:"entry,omitempty"` // the matching entry: the name, or *.parent
|
||||
RuleID int64 `json:"rule_id,omitempty"`
|
||||
List string `json:"list,omitempty"` // the blocklist, when a list matched
|
||||
// Step is the list's step when it is above normal: only clients at
|
||||
// that step or above get this answer (others: none).
|
||||
Step string `json:"step,omitempty"`
|
||||
}
|
||||
|
||||
// Actions of rules.
|
||||
const (
|
||||
ActionBlock = "block"
|
||||
ActionAllow = "allow"
|
||||
)
|
||||
|
||||
// cname is the CNAME target of each action: "." answers NXDOMAIN,
|
||||
// rpz-passthru. answers as if there were no rule.
|
||||
var cname = map[string]string{ActionBlock: ".", ActionAllow: "rpz-passthru."}
|
||||
|
||||
// header starts a policy zone file. There is no $ORIGIN: names are
|
||||
// relative to the zone, so a list can be renamed without rewriting its
|
||||
// file. The short TTLs make a change reach clients' caches quickly.
|
||||
func header(title string, serial uint32) string {
|
||||
return fmt.Sprintf("; %s\n; Generated by Jarl. DO NOT EDIT.\n$TTL 60\n@\tSOA\tlocalhost. hostmaster.localhost. %d 3600 600 86400 60\n\tNS\tlocalhost.\n", title, serial)
|
||||
}
|
||||
|
||||
// Placeholder is the content of a list's zone file before the list has
|
||||
// been downloaded: a valid, empty policy zone.
|
||||
func Placeholder() string {
|
||||
return header("Blocklist not downloaded yet.", 1)
|
||||
}
|
||||
|
||||
// Rule is a block or allow rule entered by hand.
|
||||
type Rule struct {
|
||||
Name string // lowercase, without the final dot
|
||||
Action string // ActionBlock or ActionAllow
|
||||
Exact bool // only the name itself, not the names below it
|
||||
}
|
||||
|
||||
// RuleEntries returns the policy zone owners for rules (relative names,
|
||||
// "*." for wildcards) and their actions, with the entries that make the
|
||||
// most specific rule win added: named won't apply a wildcard below a name
|
||||
// that exists in the zone, so every name below a wildcard that exists
|
||||
// (because a more specific rule mentions it) gets the wildcard's action
|
||||
// for itself and for the names below it, unless a rule says otherwise.
|
||||
// Callers make sure no two rules have the same name and scope.
|
||||
func RuleEntries(rules []Rule) map[string]string {
|
||||
e := map[string]string{}
|
||||
for _, r := range rules {
|
||||
e[r.Name] = r.Action
|
||||
if !r.Exact {
|
||||
e["*."+r.Name] = r.Action
|
||||
}
|
||||
}
|
||||
// Every name that exists in the zone: owners and their ancestors.
|
||||
nodes := map[string]bool{}
|
||||
for owner := range e {
|
||||
n := strings.TrimPrefix(owner, "*.")
|
||||
for {
|
||||
nodes[n] = true
|
||||
i := strings.IndexByte(n, '.')
|
||||
if i < 0 {
|
||||
break
|
||||
}
|
||||
n = n[i+1:]
|
||||
}
|
||||
}
|
||||
ordered := make([]string, 0, len(nodes))
|
||||
for n := range nodes {
|
||||
ordered = append(ordered, n)
|
||||
}
|
||||
// Shallow names first, so a filled-in wildcard is inherited further
|
||||
// down.
|
||||
sort.Slice(ordered, func(i, j int) bool {
|
||||
di, dj := strings.Count(ordered[i], "."), strings.Count(ordered[j], ".")
|
||||
if di != dj {
|
||||
return di < dj
|
||||
}
|
||||
return ordered[i] < ordered[j]
|
||||
})
|
||||
for _, n := range ordered {
|
||||
inherited := ""
|
||||
for a := n; ; {
|
||||
i := strings.IndexByte(a, '.')
|
||||
if i < 0 {
|
||||
break
|
||||
}
|
||||
a = a[i+1:]
|
||||
if act, ok := e["*."+a]; ok {
|
||||
inherited = act
|
||||
break
|
||||
}
|
||||
}
|
||||
if inherited == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := e[n]; !ok {
|
||||
e[n] = inherited
|
||||
}
|
||||
if _, ok := e["*."+n]; !ok {
|
||||
e["*."+n] = inherited
|
||||
}
|
||||
}
|
||||
return e
|
||||
}
|
||||
|
||||
// PausedZoneFile renders a rules zone that lets every name through, for
|
||||
// while blocking is paused: the rules zone comes first, and its match
|
||||
// stops named from looking at the lists.
|
||||
func PausedZoneFile(title string) string {
|
||||
return header(title+" (blocking is paused)", 1) + "*\tCNAME\trpz-passthru.\n"
|
||||
}
|
||||
|
||||
// RulesZoneFile renders the rules zone of a view.
|
||||
func RulesZoneFile(title string, rules []Rule) string {
|
||||
e := RuleEntries(rules)
|
||||
owners := make([]string, 0, len(e))
|
||||
for o := range e {
|
||||
owners = append(owners, o)
|
||||
}
|
||||
sort.Slice(owners, func(i, j int) bool { return ownerLess(owners[i], owners[j]) })
|
||||
var sb strings.Builder
|
||||
sb.WriteString(header(title, 1))
|
||||
for _, o := range owners {
|
||||
fmt.Fprintf(&sb, "%s\tCNAME\t%s\n", o, cname[e[o]])
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// ownerLess sorts names by their labels from the right (example,
|
||||
// ads.example, *.ads.example, cdn.ads.example), so related rules are next
|
||||
// to each other.
|
||||
func ownerLess(a, b string) bool {
|
||||
ka, kb := reverseLabels(a), reverseLabels(b)
|
||||
return ka < kb
|
||||
}
|
||||
|
||||
func reverseLabels(n string) string {
|
||||
labels := strings.Split(n, ".")
|
||||
for i, j := 0, len(labels)-1; i < j; i, j = i+1, j-1 {
|
||||
labels[i], labels[j] = labels[j], labels[i]
|
||||
}
|
||||
// A wildcard sorts right after the name it belongs to.
|
||||
return strings.ReplaceAll(strings.Join(labels, "\x00"), "*", "\x01")
|
||||
}
|
||||
|
||||
// MatchRules finds the rule entry named would apply to name (lowercase,
|
||||
// without the final dot) in a rules zone with entries e: the name itself,
|
||||
// or else the wildcard of its closest existing ancestor. ok is false when
|
||||
// no entry applies.
|
||||
func MatchRules(e map[string]string, name string) (owner, action string, ok bool) {
|
||||
if act, found := e[name]; found {
|
||||
return name, act, true
|
||||
}
|
||||
nodes := map[string]bool{}
|
||||
for o := range e {
|
||||
n := strings.TrimPrefix(o, "*.")
|
||||
for {
|
||||
nodes[n] = true
|
||||
i := strings.IndexByte(n, '.')
|
||||
if i < 0 {
|
||||
break
|
||||
}
|
||||
n = n[i+1:]
|
||||
}
|
||||
}
|
||||
if nodes[name] {
|
||||
return "", "", false // the name exists without a rule of its own
|
||||
}
|
||||
for a := name; ; {
|
||||
i := strings.IndexByte(a, '.')
|
||||
if i < 0 {
|
||||
return "", "", false
|
||||
}
|
||||
a = a[i+1:]
|
||||
if nodes[a] {
|
||||
if act, found := e["*."+a]; found {
|
||||
return "*." + a, act, true
|
||||
}
|
||||
return "", "", false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Compact drops entries another entry already covers (a name below a
|
||||
// name whose subdomains are blocked). That keeps the zone small, and it
|
||||
// matters for correctness: no name exists below a wildcard, so every
|
||||
// wildcard applies to all names below it.
|
||||
func Compact(l *List) {
|
||||
for n := range l.Domains {
|
||||
for a := n; ; {
|
||||
i := strings.IndexByte(a, '.')
|
||||
if i < 0 {
|
||||
break
|
||||
}
|
||||
a = a[i+1:]
|
||||
if l.Domains[a]&Below != 0 {
|
||||
delete(l.Domains, n)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// FeedZone writes a list's policy zone to w and returns the number of
|
||||
// records and a hash of them (the serial isn't part of the hash, so it
|
||||
// tells whether the content changed). l must be compacted.
|
||||
func FeedZone(w io.Writer, title string, serial uint32, l *List) (records int, hash string, err error) {
|
||||
names := make([]string, 0, len(l.Domains))
|
||||
for n := range l.Domains {
|
||||
names = append(names, n)
|
||||
}
|
||||
sort.Strings(names)
|
||||
bw := bufio.NewWriterSize(w, 256*1024)
|
||||
h := sha256.New()
|
||||
if _, err := bw.WriteString(header(title, serial)); err != nil {
|
||||
return 0, "", err
|
||||
}
|
||||
write := func(owner string) error {
|
||||
line := owner + "\tCNAME\t.\n"
|
||||
h.Write([]byte(line))
|
||||
records++
|
||||
_, err := bw.WriteString(line)
|
||||
return err
|
||||
}
|
||||
for _, n := range names {
|
||||
f := l.Domains[n]
|
||||
if f&Exact != 0 {
|
||||
if err := write(n); err != nil {
|
||||
return 0, "", err
|
||||
}
|
||||
}
|
||||
if f&Below != 0 {
|
||||
if err := write("*." + n); err != nil {
|
||||
return 0, "", err
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := bw.Flush(); err != nil {
|
||||
return 0, "", err
|
||||
}
|
||||
return records, hex.EncodeToString(h.Sum(nil)), nil
|
||||
}
|
||||
|
||||
// MatchFeed reports which entry of a list's zone file blocks name: the
|
||||
// name itself, or a wildcard of one of its ancestors (the closest).
|
||||
// Lists are compacted, so no name exists below a wildcard and the
|
||||
// closest wildcard ancestor is the one named uses.
|
||||
func MatchFeed(path, name string) (owner string, ok bool, err error) {
|
||||
want := map[string]int{name: 0}
|
||||
depth := 1
|
||||
for a := name; ; depth++ {
|
||||
i := strings.IndexByte(a, '.')
|
||||
if i < 0 {
|
||||
break
|
||||
}
|
||||
a = a[i+1:]
|
||||
want["*."+a] = depth
|
||||
}
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
defer f.Close()
|
||||
best := -1
|
||||
sc := bufio.NewScanner(f)
|
||||
for sc.Scan() {
|
||||
line := sc.Text()
|
||||
o, _, found := strings.Cut(line, "\t")
|
||||
if !found {
|
||||
continue
|
||||
}
|
||||
if d, hit := want[o]; hit && (best < 0 || d < best) {
|
||||
best, owner = d, o
|
||||
}
|
||||
}
|
||||
return owner, best >= 0, sc.Err()
|
||||
}
|
||||
@@ -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,87 @@ func classify(name, grammar string) (string, []string) {
|
||||
}
|
||||
return KindRaw, nil
|
||||
}
|
||||
|
||||
// listParam matches an optional parameter before a statement's list, such
|
||||
// as "[ port <integer> ]" in allow-transfer's grammar.
|
||||
var listParam = regexp.MustCompile(`\[ ([a-z0-9-]+) `)
|
||||
|
||||
// WrapList adds the braces to a list value given without them, for
|
||||
// statements that end in a list ("allow-transfer [ port <integer> ] {
|
||||
// <address_match_element>; ... }"): "10.0.0.53" becomes "{ 10.0.0.53; }",
|
||||
// "port 5353 10.0.0.53; key xfer" becomes "port 5353 { 10.0.0.53; key
|
||||
// xfer; }". Elements are separated by ";", "," or new lines, or by spaces
|
||||
// when they are all addresses. Values with a brace, and values of other
|
||||
// statements, are returned as they are.
|
||||
func (st *Statement) WrapList(v string) string {
|
||||
g, _, _ := strings.Cut(st.Grammar, "\n")
|
||||
open := strings.Index(g, " {")
|
||||
if strings.Contains(v, "{") || open < 0 || !strings.HasSuffix(g, "; ... };") {
|
||||
return v
|
||||
}
|
||||
params := map[string]bool{}
|
||||
for _, m := range listParam.FindAllStringSubmatch(g[:open], -1) {
|
||||
params[m[1]] = true
|
||||
}
|
||||
fields := strings.Fields(v)
|
||||
i := 0
|
||||
for i+1 < len(fields) && params[strings.ToLower(fields[i])] {
|
||||
i += 2
|
||||
}
|
||||
head := strings.Join(fields[:i], " ")
|
||||
rest := strings.Join(fields[i:], " ")
|
||||
var elems []string
|
||||
if !strings.ContainsAny(rest, ";,\n") {
|
||||
elems = spaceElements(fields[i:])
|
||||
} else {
|
||||
for _, e := range strings.FieldsFunc(rest, func(r rune) bool { return r == ';' || r == ',' || r == '\n' }) {
|
||||
if e = strings.TrimSpace(e); e != "" {
|
||||
elems = append(elems, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(elems) == 0 {
|
||||
return v
|
||||
}
|
||||
list := "{ " + strings.Join(elems, "; ") + "; }"
|
||||
if head != "" {
|
||||
return head + " " + list
|
||||
}
|
||||
return list
|
||||
}
|
||||
|
||||
// spaceElements splits list elements separated by spaces: "key NAME"
|
||||
// is one element, and "port N" and "tls NAME" belong to the address
|
||||
// before them (forwarders { 192.0.2.1 port 53; }).
|
||||
func spaceElements(fields []string) []string {
|
||||
var out []string
|
||||
for i := 0; i < len(fields); i++ {
|
||||
f := fields[i]
|
||||
switch strings.ToLower(f) {
|
||||
case "key", "!key", "!":
|
||||
if i+1 < len(fields) {
|
||||
f += " " + fields[i+1]
|
||||
i++
|
||||
}
|
||||
if f[0] == '!' && strings.HasPrefix(strings.ToLower(f), "! key") && i+1 < len(fields) {
|
||||
f += " " + fields[i+1]
|
||||
i++
|
||||
}
|
||||
case "-":
|
||||
// A range with spaces around the dash: 10.0.0.1 - 10.0.0.9.
|
||||
if len(out) > 0 && i+1 < len(fields) {
|
||||
out[len(out)-1] += " - " + fields[i+1]
|
||||
i++
|
||||
continue
|
||||
}
|
||||
case "port", "tls":
|
||||
if len(out) > 0 && i+1 < len(fields) {
|
||||
out[len(out)-1] += " " + f + " " + fields[i+1]
|
||||
i++
|
||||
continue
|
||||
}
|
||||
}
|
||||
out = append(out, f)
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,406 @@
|
||||
package certs
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto"
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/go-acme/lego/v4/certcrypto"
|
||||
"github.com/go-acme/lego/v4/certificate"
|
||||
"github.com/go-acme/lego/v4/challenge/dns01"
|
||||
"github.com/go-acme/lego/v4/lego"
|
||||
"github.com/go-acme/lego/v4/registration"
|
||||
|
||||
"jarl/internal/acme"
|
||||
"jarl/internal/auth"
|
||||
"jarl/internal/engine"
|
||||
"jarl/internal/model"
|
||||
)
|
||||
|
||||
// Account is the ACME account certificates are obtained with. Its key is
|
||||
// kept in Jarl's database (and so in backups), like TSIG secrets.
|
||||
type Account struct {
|
||||
Directory string `json:"directory"`
|
||||
Email string `json:"email,omitempty"`
|
||||
URI string `json:"uri,omitempty"` // the account at the CA
|
||||
RegisteredAt time.Time `json:"registered_at"`
|
||||
KeyPEM string `json:"key_pem,omitempty"`
|
||||
}
|
||||
|
||||
const metaAccount = "acme.account"
|
||||
|
||||
// account returns the stored account (nil when there is none).
|
||||
func (m *Manager) account(ctx context.Context) *Account {
|
||||
raw, err := m.store.GetMeta(ctx, metaAccount)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
var a Account
|
||||
if json.Unmarshal([]byte(raw), &a) != nil {
|
||||
return nil
|
||||
}
|
||||
return &a
|
||||
}
|
||||
|
||||
type legoUser struct {
|
||||
email string
|
||||
reg *registration.Resource
|
||||
key crypto.PrivateKey
|
||||
}
|
||||
|
||||
func (u *legoUser) GetEmail() string { return u.email }
|
||||
func (u *legoUser) GetRegistration() *registration.Resource { return u.reg }
|
||||
func (u *legoUser) GetPrivateKey() crypto.PrivateKey { return u.key }
|
||||
|
||||
// ErrTermsNotAgreed means acme.account.agree_tos isn't set.
|
||||
var ErrTermsNotAgreed = errors.New("acme.account.agree_tos isn't set")
|
||||
|
||||
func (m *Manager) httpClient() (*http.Client, error) {
|
||||
tr := http.DefaultTransport.(*http.Transport).Clone()
|
||||
tr.TLSClientConfig = &tls.Config{MinVersion: tls.VersionTLS12}
|
||||
if m.cfg.CABundle != "" {
|
||||
pem, err := os.ReadFile(m.cfg.CABundle)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("acme.ca_bundle: %w", err)
|
||||
}
|
||||
pool, err := x509.SystemCertPool()
|
||||
if err != nil || pool == nil {
|
||||
pool = x509.NewCertPool()
|
||||
}
|
||||
if !pool.AppendCertsFromPEM(pem) {
|
||||
return nil, fmt.Errorf("acme.ca_bundle: no certificates in %s", m.cfg.CABundle)
|
||||
}
|
||||
tr.TLSClientConfig.RootCAs = pool
|
||||
}
|
||||
return &http.Client{Timeout: 2 * time.Minute, Transport: tr}, nil
|
||||
}
|
||||
|
||||
// client returns a lego client for the account (registering it when
|
||||
// needed) that makes keys of keyType.
|
||||
func (m *Manager) client(ctx context.Context, keyType string) (*lego.Client, error) {
|
||||
dir := m.cfg.Directory
|
||||
hc, err := m.httpClient()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
a := m.account(ctx)
|
||||
user := &legoUser{email: m.cfg.Email}
|
||||
if a != nil && a.Directory == dir && a.KeyPEM != "" {
|
||||
if user.key, err = parseKey(a.KeyPEM); err != nil {
|
||||
return nil, fmt.Errorf("the stored ACME account key: %w", err)
|
||||
}
|
||||
if a.URI != "" {
|
||||
user.reg = ®istration.Resource{URI: a.URI}
|
||||
}
|
||||
} else {
|
||||
k, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
user.key = k
|
||||
}
|
||||
cfg := lego.NewConfig(user)
|
||||
cfg.CADirURL, cfg.HTTPClient, cfg.UserAgent = dir, hc, "jarl/"+m.cfg.Version
|
||||
cfg.Certificate.KeyType = legoKeyType(keyType)
|
||||
cfg.Certificate.Timeout = 90 * time.Second
|
||||
c, err := lego.NewClient(cfg)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ACME server %s: %w", dir, err)
|
||||
}
|
||||
if user.reg != nil && (a.Email == m.cfg.Email) {
|
||||
return c, nil
|
||||
}
|
||||
if user.reg != nil {
|
||||
// Only the email changed: update the account.
|
||||
if reg, err := c.Registration.UpdateRegistration(registration.RegisterOptions{TermsOfServiceAgreed: true}); err == nil {
|
||||
user.reg = reg
|
||||
return c, m.saveAccount(ctx, user, dir)
|
||||
}
|
||||
}
|
||||
if !m.cfg.AgreeTOS {
|
||||
tos := c.GetToSURL()
|
||||
if tos == "" {
|
||||
tos = "on the CA's website"
|
||||
}
|
||||
return nil, fmt.Errorf("%w: Jarl registers an account with %s only after you accept its terms of service (%s): set acme.account.agree_tos: true in jarl.yaml and restart jarld", ErrTermsNotAgreed, dir, tos)
|
||||
}
|
||||
var reg *registration.Resource
|
||||
if m.cfg.EABKeyID != "" {
|
||||
reg, err = c.Registration.RegisterWithExternalAccountBinding(registration.RegisterEABOptions{TermsOfServiceAgreed: true, Kid: m.cfg.EABKeyID, HmacEncoded: m.cfg.EABHMAC})
|
||||
} else {
|
||||
if c.GetExternalAccountRequired() {
|
||||
return nil, fmt.Errorf("%s needs External Account Binding: set acme.account.eab_kid and eab_hmac in jarl.yaml (from the CA's dashboard)", dir)
|
||||
}
|
||||
reg, err = c.Registration.Register(registration.RegisterOptions{TermsOfServiceAgreed: true})
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("registering the ACME account: %w", err)
|
||||
}
|
||||
user.reg = reg
|
||||
m.log.Info("ACME account registered", "directory", dir, "account", reg.URI)
|
||||
return c, m.saveAccount(ctx, user, dir)
|
||||
}
|
||||
|
||||
func (m *Manager) saveAccount(ctx context.Context, u *legoUser, dir string) error {
|
||||
der, err := x509.MarshalPKCS8PrivateKey(u.key)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
a := Account{Directory: dir, Email: u.email, URI: u.reg.URI, RegisteredAt: time.Now().UTC(),
|
||||
KeyPEM: string(pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: der}))}
|
||||
b, _ := json.Marshal(a)
|
||||
return m.store.SetMeta(ctx, metaAccount, string(b))
|
||||
}
|
||||
|
||||
func parseKey(s string) (crypto.PrivateKey, error) {
|
||||
b, _ := pem.Decode([]byte(s))
|
||||
if b == nil {
|
||||
return nil, errors.New("not PEM")
|
||||
}
|
||||
return x509.ParsePKCS8PrivateKey(b.Bytes)
|
||||
}
|
||||
|
||||
func legoKeyType(t string) certcrypto.KeyType {
|
||||
switch t {
|
||||
case "ec384":
|
||||
return certcrypto.EC384
|
||||
case "rsa2048":
|
||||
return certcrypto.RSA2048
|
||||
case "rsa3072":
|
||||
return certcrypto.RSA3072
|
||||
case "rsa4096":
|
||||
return certcrypto.RSA4096
|
||||
}
|
||||
return certcrypto.EC256
|
||||
}
|
||||
|
||||
// KeyTypes are the key types a certificate Jarl obtains can have.
|
||||
var KeyTypes = []string{"ec256", "ec384", "rsa2048", "rsa3072", "rsa4096"}
|
||||
|
||||
// provider answers DNS-01 challenges for one issue: in a zone Jarl serves,
|
||||
// or at Porkbun, as route decides for each name.
|
||||
type provider struct {
|
||||
m *Manager
|
||||
ctx context.Context
|
||||
cert *model.Certificate
|
||||
mu sync.Mutex
|
||||
cleanups map[string]func() error
|
||||
routes []Route
|
||||
}
|
||||
|
||||
func (p *provider) Present(domain, token, keyAuth string) error {
|
||||
info := dns01.GetChallengeInfo(domain, keyAuth)
|
||||
st, err := p.m.store.LoadState(p.ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
r := p.m.route(p.ctx, st, p.cert.Challenge, domain, info.EffectiveFQDN)
|
||||
p.mu.Lock()
|
||||
p.routes = append(p.routes, r)
|
||||
p.mu.Unlock()
|
||||
if r.Error != "" {
|
||||
return errors.New(r.Error)
|
||||
}
|
||||
key := info.EffectiveFQDN + " " + info.Value
|
||||
var cleanup func() error
|
||||
switch r.Route {
|
||||
case model.ChallengeJarl:
|
||||
if err := p.m.publish(p.ctx, p.cert, info.EffectiveFQDN, info.Value, true); err != nil {
|
||||
return err
|
||||
}
|
||||
cleanup = func() error {
|
||||
return p.m.publish(context.WithoutCancel(p.ctx), p.cert, info.EffectiveFQDN, info.Value, false)
|
||||
}
|
||||
case model.ChallengePorkbun:
|
||||
zone := strings.TrimSuffix(r.Zone, ".")
|
||||
id, err := p.m.cfg.Porkbun.CreateTXT(p.ctx, zone, strings.TrimSuffix(info.EffectiveFQDN, "."), info.Value)
|
||||
if err != nil {
|
||||
return fmt.Errorf("publishing the challenge for %s at Porkbun: %w", domain, err)
|
||||
}
|
||||
cleanup = func() error { return p.m.cfg.Porkbun.DeleteRecord(context.WithoutCancel(p.ctx), zone, id) }
|
||||
}
|
||||
p.mu.Lock()
|
||||
p.cleanups[key] = cleanup
|
||||
p.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *provider) CleanUp(domain, token, keyAuth string) error {
|
||||
info := dns01.GetChallengeInfo(domain, keyAuth)
|
||||
key := info.EffectiveFQDN + " " + info.Value
|
||||
p.mu.Lock()
|
||||
f := p.cleanups[key]
|
||||
delete(p.cleanups, key)
|
||||
p.mu.Unlock()
|
||||
if f == nil {
|
||||
return nil
|
||||
}
|
||||
if err := f(); err != nil {
|
||||
p.m.log.Warn("removing an ACME challenge record failed", "certificate", p.cert.Name, "record", info.EffectiveFQDN, "err", err)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Timeout is how long lego waits for the record to reach every name
|
||||
// server of the zone: Porkbun's take a few minutes.
|
||||
func (p *provider) Timeout() (timeout, interval time.Duration) {
|
||||
if p.m.cfg.PropagationTimeout > 0 {
|
||||
return p.m.cfg.PropagationTimeout, 2 * time.Second
|
||||
}
|
||||
return 10 * time.Minute, 10 * time.Second
|
||||
}
|
||||
|
||||
// publish writes (or removes) a challenge TXT record in the Jarl zone that
|
||||
// holds name, in every view that has it, and returns once named serves it.
|
||||
func (m *Manager) publish(ctx context.Context, c *model.Certificate, name, value string, set bool) error {
|
||||
st, err := m.store.LoadState(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
zones := acme.Zones(name, primaryZones(st), "")
|
||||
if len(zones) == 0 {
|
||||
return fmt.Errorf("no primary zone in Jarl contains %s", strings.TrimSuffix(name, "."))
|
||||
}
|
||||
var reqs []engine.Request
|
||||
for _, r := range acme.Requests(name, zones, []string{value}, set, acme.DefaultTTL) {
|
||||
reqs = append(reqs, engine.Request{Op: r.Op, ID: r.ID, BaseVersion: r.BaseVersion, Data: r.Data})
|
||||
}
|
||||
desc := acme.Description(name, set) + " (certificate " + c.Name + ")"
|
||||
if _, err := m.eng.Submit(ctx, reqs, auth.System, "acme", desc, engine.ApplyOptions{}); err != nil {
|
||||
return fmt.Errorf("writing the challenge record %s: %w", strings.TrimSuffix(name, "."), err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// obtain gets a new certificate for c from the CA. old is the certificate
|
||||
// it replaces (nil for the first), so the CA can link them (ARI).
|
||||
func (m *Manager) obtain(ctx context.Context, c *model.Certificate, old *x509.Certificate) (*certificate.Resource, []Route, error) {
|
||||
m.legoMu.Lock()
|
||||
defer m.legoMu.Unlock()
|
||||
client, err := m.client(ctx, c.KeyType)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
p := &provider{m: m, ctx: ctx, cert: c, cleanups: map[string]func() error{}}
|
||||
opts := []dns01.ChallengeOption{dns01.AddRecursiveNameservers(m.cfg.Resolvers), dns01.AddDNSTimeout(10 * time.Second)}
|
||||
if m.cfg.SkipPropagationCheck {
|
||||
opts = append(opts, dns01.DisableAuthoritativeNssPropagationRequirement())
|
||||
}
|
||||
if err := client.Challenge.SetDNS01Provider(p, opts...); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
req := certificate.ObtainRequest{Domains: c.Domains, Bundle: true}
|
||||
if old != nil {
|
||||
if id, err := certificate.MakeARICertID(old); err == nil {
|
||||
req.ReplacesCertID = id
|
||||
}
|
||||
}
|
||||
res, err := client.Certificate.Obtain(req)
|
||||
if err != nil && req.ReplacesCertID != "" && len(p.routes) == 0 && ctx.Err() == nil {
|
||||
// The CA refused the order itself, before any challenge: it may
|
||||
// not link the new certificate to the old one (another CA, one
|
||||
// already replaced, or its own bug). Ask for a plain new one.
|
||||
m.log.Info("the CA refused to link the renewal to the old certificate; asking for a new one", "certificate", c.Name, "err", cleanLegoError(err))
|
||||
req.ReplacesCertID = ""
|
||||
res, err = client.Certificate.Obtain(req)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, p.routes, cleanLegoError(err)
|
||||
}
|
||||
return res, p.routes, nil
|
||||
}
|
||||
|
||||
// renewalInfo asks the CA when to renew (ARI, RFC 9773). nil, nil when
|
||||
// it doesn't say.
|
||||
func (m *Manager) renewalInfo(ctx context.Context, c *model.Certificate, leaf *x509.Certificate) (*time.Time, error) {
|
||||
m.legoMu.Lock()
|
||||
defer m.legoMu.Unlock()
|
||||
client, err := m.client(ctx, c.KeyType)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ri, err := client.Certificate.GetRenewalInfo(certificate.RenewalInfoRequest{Cert: leaf})
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "does not advertise a renewal info endpoint") {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
// A time within the CA's window (now if it has started); never later
|
||||
// than two thirds into the lifetime, in case the window is odd.
|
||||
at := ri.ShouldRenewAt(time.Now(), 10*365*24*time.Hour)
|
||||
if at == nil {
|
||||
return nil, nil
|
||||
}
|
||||
t := at.UTC().Truncate(time.Second)
|
||||
if limit := twoThirds(leaf.NotBefore, leaf.NotAfter); t.After(limit) {
|
||||
t = limit
|
||||
}
|
||||
return &t, nil
|
||||
}
|
||||
|
||||
func twoThirds(nb, na time.Time) time.Time {
|
||||
return nb.Add(na.Sub(nb) * 2 / 3).UTC().Truncate(time.Second)
|
||||
}
|
||||
|
||||
// cleanLegoError shortens lego's errors, which wrap each name's problem in
|
||||
// several prefixes and the whole ACME problem document:
|
||||
//
|
||||
// error: one or more domains had a problem:
|
||||
// [a.example.com] [a.example.com] acme: error presenting token: MSG
|
||||
// [b.example.com] acme: error: 403 :: urn:ietf:params:acme:error:unauthorized :: MSG
|
||||
//
|
||||
// becomes "a.example.com: MSG; b.example.com: the CA refused (unauthorized): MSG".
|
||||
func cleanLegoError(err error) error {
|
||||
msg := strings.TrimSpace(strings.Replace(err.Error(), "error: one or more domains had a problem:", "", 1))
|
||||
var out []string
|
||||
for _, line := range strings.Split(msg, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
domain := ""
|
||||
for strings.HasPrefix(line, "[") {
|
||||
end := strings.Index(line, "] ")
|
||||
if end < 0 {
|
||||
break
|
||||
}
|
||||
if domain == "" {
|
||||
domain = line[1:end]
|
||||
}
|
||||
line = line[end+2:]
|
||||
}
|
||||
for _, p := range []string{"acme: error presenting token: ", "acme: error: ", "acme: "} {
|
||||
line = strings.TrimPrefix(line, p)
|
||||
}
|
||||
if m := acmeProblemRe.FindStringSubmatch(line); m != nil {
|
||||
line = "the CA refused (" + m[2] + "): " + m[3]
|
||||
}
|
||||
if domain != "" {
|
||||
line = domain + ": " + line
|
||||
}
|
||||
out = append(out, line)
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return err
|
||||
}
|
||||
return errors.New(strings.Join(out, "; "))
|
||||
}
|
||||
|
||||
// acmeProblemRe matches "403 :: urn:ietf:params:acme:error:unauthorized :: detail".
|
||||
var acmeProblemRe = regexp.MustCompile(`^(\d+) :: urn:ietf:params:acme:error:(\w+) :: (.*)$`)
|
||||
@@ -0,0 +1,522 @@
|
||||
package certs
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"math/big"
|
||||
"net"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/go-acme/lego/v4/challenge/dns01"
|
||||
"github.com/miekg/dns"
|
||||
|
||||
"jarl/internal/auth"
|
||||
"jarl/internal/encdns"
|
||||
"jarl/internal/engine"
|
||||
"jarl/internal/model"
|
||||
"jarl/internal/porkbun"
|
||||
"jarl/internal/render"
|
||||
"jarl/internal/store"
|
||||
)
|
||||
|
||||
// fakeDNS answers like the internet would: zones with their public name
|
||||
// servers, CNAME records, and TXT records (Porkbun's, from the fake API).
|
||||
type fakeDNS struct {
|
||||
mu sync.Mutex
|
||||
zones map[string][]string // zone -> NS
|
||||
cnames map[string]string
|
||||
txt func(name string) []string
|
||||
addr string
|
||||
}
|
||||
|
||||
func newFakeDNS(t *testing.T) *fakeDNS {
|
||||
f := &fakeDNS{zones: map[string][]string{}, cnames: map[string]string{}, txt: func(string) []string { return nil }}
|
||||
pc, err := net.ListenPacket("udp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
srv := &dns.Server{PacketConn: pc, Handler: f}
|
||||
go srv.ActivateAndServe()
|
||||
t.Cleanup(func() { srv.Shutdown() })
|
||||
f.addr = pc.LocalAddr().String()
|
||||
return f
|
||||
}
|
||||
|
||||
func (f *fakeDNS) ServeDNS(w dns.ResponseWriter, r *dns.Msg) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
m := new(dns.Msg)
|
||||
m.SetReply(r)
|
||||
q := r.Question[0]
|
||||
name := strings.ToLower(q.Name)
|
||||
zone := ""
|
||||
for z := range f.zones {
|
||||
if dns.IsSubDomain(z, name) && len(z) > len(zone) {
|
||||
zone = z
|
||||
}
|
||||
}
|
||||
if zone == "" {
|
||||
m.Rcode = dns.RcodeNameError
|
||||
w.WriteMsg(m)
|
||||
return
|
||||
}
|
||||
soa := &dns.SOA{Hdr: dns.RR_Header{Name: zone, Rrtype: dns.TypeSOA, Class: dns.ClassINET, Ttl: 60}, Ns: f.zones[zone][0], Mbox: "h." + zone, Serial: 1, Refresh: 1, Retry: 1, Expire: 1, Minttl: 60}
|
||||
switch {
|
||||
case q.Qtype == dns.TypeSOA && name == zone:
|
||||
m.Answer = append(m.Answer, soa)
|
||||
case q.Qtype == dns.TypeNS && name == zone:
|
||||
for _, ns := range f.zones[zone] {
|
||||
m.Answer = append(m.Answer, &dns.NS{Hdr: dns.RR_Header{Name: zone, Rrtype: dns.TypeNS, Class: dns.ClassINET, Ttl: 60}, Ns: ns})
|
||||
}
|
||||
case f.cnames[name] != "":
|
||||
m.Answer = append(m.Answer, &dns.CNAME{Hdr: dns.RR_Header{Name: name, Rrtype: dns.TypeCNAME, Class: dns.ClassINET, Ttl: 60}, Target: f.cnames[name]})
|
||||
case q.Qtype == dns.TypeTXT && len(f.txt(name)) > 0:
|
||||
for _, v := range f.txt(name) {
|
||||
m.Answer = append(m.Answer, &dns.TXT{Hdr: dns.RR_Header{Name: name, Rrtype: dns.TypeTXT, Class: dns.ClassINET, Ttl: 60}, Txt: []string{v}})
|
||||
}
|
||||
default:
|
||||
m.Ns = append(m.Ns, soa)
|
||||
}
|
||||
w.WriteMsg(m)
|
||||
}
|
||||
|
||||
type env struct {
|
||||
t *testing.T
|
||||
m *Manager
|
||||
st *store.Store
|
||||
eng *engine.Engine
|
||||
dns *fakeDNS
|
||||
pb *porkbun.Fake
|
||||
dir string
|
||||
}
|
||||
|
||||
// newEnv sets up Jarl with zone example.com (name server
|
||||
// ns1.example.com, which the internet also uses), a fake internet and a
|
||||
// fake Porkbun account with pb.test.
|
||||
func newEnv(t *testing.T, withPorkbun bool) *env {
|
||||
dir := t.TempDir()
|
||||
st, err := store.Open(filepath.Join(dir, "jarl.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { st.Close() })
|
||||
quiet := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
eng := engine.New(st, nil, engine.Config{
|
||||
Paths: render.Paths{ConfigDir: filepath.Join(dir, "etc"), ZoneDir: filepath.Join(dir, "zones"), CacheDir: filepath.Join(dir, "cache")},
|
||||
StagingDir: filepath.Join(dir, "staging"), NoReload: true, SkipCheck: true,
|
||||
}, quiet)
|
||||
e := &env{t: t, st: st, eng: eng, dns: newFakeDNS(t), dir: dir}
|
||||
e.submit(engine.NewRequest(engine.OpSettingsUpdate, 0, engine.SettingsInput{Nameservers: &[]string{"ns1.example.net."}}),
|
||||
engine.NewRequest(engine.OpZoneCreate, 0, engine.ZoneInput{Name: model.Ptr("example.com")}))
|
||||
e.dns.mu.Lock()
|
||||
e.dns.zones["example.com."] = []string{"ns1.example.net."}
|
||||
e.dns.zones["pb.test."] = []string{"curitiba.ns.porkbun.com.", "fortaleza.ns.porkbun.com."}
|
||||
e.dns.zones["other.test."] = []string{"a.ns.cloudflare.com."}
|
||||
e.dns.mu.Unlock()
|
||||
cfg := Config{Resolvers: []string{e.dns.addr}, Dir: filepath.Join(dir, "tls"), Directory: "https://acme.invalid/dir"}
|
||||
if withPorkbun {
|
||||
e.pb = porkbun.NewFake("pk1_test", "sk1_test", "pb.test")
|
||||
ts := httptest.NewServer(e.pb)
|
||||
t.Cleanup(ts.Close)
|
||||
cfg.Porkbun = &porkbun.Client{BaseURL: ts.URL + "/api/json/v3", APIKey: "pk1_test", Secret: "sk1_test"}
|
||||
e.dns.mu.Lock()
|
||||
e.dns.txt = func(name string) []string { return e.pb.TXT(name) }
|
||||
e.dns.mu.Unlock()
|
||||
}
|
||||
e.m = New(cfg, st, eng, quiet)
|
||||
// lego's own lookups (CNAME following) use the fake internet too.
|
||||
dns01.AddRecursiveNameservers([]string{e.dns.addr})(nil)
|
||||
return e
|
||||
}
|
||||
|
||||
func (e *env) submit(reqs ...engine.Request) {
|
||||
e.t.Helper()
|
||||
if _, err := e.eng.Submit(context.Background(), reqs, auth.System, "test", "", engine.ApplyOptions{}); err != nil {
|
||||
e.t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func (e *env) state() *model.State {
|
||||
st, err := e.st.LoadState(context.Background())
|
||||
if err != nil {
|
||||
e.t.Fatal(err)
|
||||
}
|
||||
return st
|
||||
}
|
||||
|
||||
func TestRoute(t *testing.T) {
|
||||
e := newEnv(t, true)
|
||||
ctx := context.Background()
|
||||
e.dns.mu.Lock()
|
||||
e.dns.cnames["_acme-challenge.www.other.test."] = "www-other.acme.example.com."
|
||||
e.dns.mu.Unlock()
|
||||
cases := []struct {
|
||||
challenge, domain string
|
||||
route string // "" for an error
|
||||
target string
|
||||
errHas string
|
||||
}{
|
||||
{"auto", "www.example.com", "jarl", "_acme-challenge.www.example.com.", ""},
|
||||
{"auto", "*.example.com", "jarl", "_acme-challenge.example.com.", ""},
|
||||
{"auto", "ns1.pb.test", "porkbun", "_acme-challenge.ns1.pb.test.", ""},
|
||||
{"auto", "www.other.test", "jarl", "www-other.acme.example.com.", ""},
|
||||
{"auto", "mail.other.test", "", "", "Point _acme-challenge.mail.other.test with a CNAME"},
|
||||
{"porkbun", "www.example.com", "", "", "not Porkbun"},
|
||||
{"jarl", "ns1.pb.test", "", "", "no primary zone in Jarl is pb.test"},
|
||||
{"jarl", "www.example.com", "jarl", "_acme-challenge.www.example.com.", ""},
|
||||
}
|
||||
for _, c := range cases {
|
||||
r := e.m.route(ctx, e.state(), c.challenge, c.domain, "")
|
||||
if r.Route != c.route || c.target != "" && r.Target != c.target || c.errHas != "" && !strings.Contains(r.Error, c.errHas) {
|
||||
t.Errorf("%s %s: route %q target %q error %q", c.challenge, c.domain, r.Route, r.Target, r.Error)
|
||||
}
|
||||
}
|
||||
|
||||
// The move from Porkbun to Jarl: while the internet still asks
|
||||
// Porkbun, auto goes there even though Jarl has the zone; once the
|
||||
// name servers change, it comes to Jarl.
|
||||
e.submit(engine.NewRequest(engine.OpZoneCreate, 0, engine.ZoneInput{Name: model.Ptr("pb.test")}))
|
||||
if r := e.m.route(ctx, e.state(), "auto", "ns1.pb.test", ""); r.Route != "porkbun" {
|
||||
t.Errorf("before the move: %+v", r)
|
||||
}
|
||||
if r := e.m.route(ctx, e.state(), "jarl", "ns1.pb.test", ""); !strings.Contains(r.Error, "not by the name servers in Jarl's zone (ns1.example.net)") {
|
||||
t.Errorf("jarl before the move: %+v", r)
|
||||
}
|
||||
e.dns.mu.Lock()
|
||||
e.dns.zones["pb.test."] = []string{"ns1.example.net."}
|
||||
e.dns.mu.Unlock()
|
||||
if r := e.m.route(ctx, e.state(), "auto", "ns1.pb.test", ""); r.Route != "jarl" {
|
||||
t.Errorf("after the move: %+v", r)
|
||||
}
|
||||
if r := e.m.route(ctx, e.state(), "porkbun", "ns1.pb.test", ""); !strings.Contains(r.Error, "not Porkbun") {
|
||||
t.Errorf("porkbun after the move: %+v", r)
|
||||
}
|
||||
|
||||
// Without a Porkbun key, a Porkbun domain says what is missing.
|
||||
e2 := newEnv(t, false)
|
||||
if r := e2.m.route(ctx, e2.state(), "auto", "ns1.pb.test", ""); !strings.Contains(r.Error, "no porkbun.api_key") {
|
||||
t.Errorf("without a key: %+v", r)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProvider(t *testing.T) {
|
||||
e := newEnv(t, true)
|
||||
ctx := context.Background()
|
||||
c := &model.Certificate{Name: "t", Challenge: "auto"}
|
||||
p := &provider{m: e.m, ctx: ctx, cert: c, cleanups: map[string]func() error{}}
|
||||
|
||||
// Porkbun: the record goes in through the API and is removed again.
|
||||
info := dns01.GetChallengeInfo("ns1.pb.test", "key-auth-1")
|
||||
if err := p.Present("ns1.pb.test", "tok", "key-auth-1"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := e.pb.TXT("_acme-challenge.ns1.pb.test"); len(got) != 1 || got[0] != info.Value {
|
||||
t.Fatalf("Porkbun TXT = %v, want %s", got, info.Value)
|
||||
}
|
||||
if err := p.CleanUp("ns1.pb.test", "tok", "key-auth-1"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := e.pb.TXT("_acme-challenge.ns1.pb.test"); len(got) != 0 {
|
||||
t.Fatalf("Porkbun TXT after cleanup = %v", got)
|
||||
}
|
||||
|
||||
// Jarl: the record goes into the zone, in a changeset by the system.
|
||||
txt := func() []string {
|
||||
var out []string
|
||||
st := e.state()
|
||||
for _, r := range st.Records {
|
||||
if r.Type == "TXT" && r.Name == "_acme-challenge.www.example.com." {
|
||||
out = append(out, r.Data)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
info = dns01.GetChallengeInfo("www.example.com", "key-auth-2")
|
||||
if err := p.Present("www.example.com", "tok", "key-auth-2"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := txt(); len(got) != 1 || !strings.Contains(got[0], info.Value) {
|
||||
t.Fatalf("Jarl TXT = %v, want %s", got, info.Value)
|
||||
}
|
||||
if err := p.CleanUp("www.example.com", "tok", "key-auth-2"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := txt(); len(got) != 0 {
|
||||
t.Fatalf("Jarl TXT after cleanup = %v", got)
|
||||
}
|
||||
if len(p.routes) != 2 || p.routes[0].Route != "porkbun" || p.routes[1].Route != "jarl" {
|
||||
t.Errorf("routes = %+v", p.routes)
|
||||
}
|
||||
|
||||
// A name nobody can answer for fails before anything is published.
|
||||
if err := p.Present("mail.other.test", "tok", "k"); err == nil || !strings.Contains(err.Error(), "CNAME") {
|
||||
t.Errorf("other.test: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// makeCert returns a certificate for names (signed by a CA unless
|
||||
// selfSigned) and its key, as PEM; withCA appends the CA certificate.
|
||||
func makeCert(t *testing.T, names []string, notBefore, notAfter time.Time, withCA bool) (certPEM, keyPEM []byte) {
|
||||
t.Helper()
|
||||
caKey, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
ca := &x509.Certificate{SerialNumber: big.NewInt(1), Subject: pkix.Name{CommonName: "Test CA", Organization: []string{"Jarl"}},
|
||||
NotBefore: notBefore, NotAfter: notAfter.Add(time.Hour), IsCA: true, BasicConstraintsValid: true, KeyUsage: x509.KeyUsageCertSign}
|
||||
caDER, _ := x509.CreateCertificate(rand.Reader, ca, ca, &caKey.PublicKey, caKey)
|
||||
caCert, _ := x509.ParseCertificate(caDER)
|
||||
key, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
leaf := &x509.Certificate{SerialNumber: big.NewInt(time.Now().UnixNano()), Subject: pkix.Name{CommonName: names[0]}, DNSNames: names,
|
||||
NotBefore: notBefore, NotAfter: notAfter, ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}}
|
||||
der, err := x509.CreateCertificate(rand.Reader, leaf, caCert, &key.PublicKey, caKey)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
certPEM = pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})
|
||||
if withCA {
|
||||
certPEM = append(certPEM, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: caDER})...)
|
||||
}
|
||||
kd, _ := x509.MarshalPKCS8PrivateKey(key)
|
||||
return certPEM, pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: kd})
|
||||
}
|
||||
|
||||
func TestInspect(t *testing.T) {
|
||||
now := time.Now()
|
||||
cert, key := makeCert(t, []string{"ns1.example.com", "*.example.com"}, now, now.Add(90*24*time.Hour), true)
|
||||
in, pair, err := Inspect(cert, key)
|
||||
if err != nil || pair == nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Join(in.Names, " ") != "ns1.example.com *.example.com" || in.Issuer != "Jarl Test CA" || in.KeyType != "ec256" || len(in.Warnings) != 0 {
|
||||
t.Errorf("info = %+v", in)
|
||||
}
|
||||
leafOnly, _ := makeCert(t, []string{"a.example.com"}, now, now.Add(time.Hour), false)
|
||||
_, otherKey := makeCert(t, []string{"b.example.com"}, now, now.Add(time.Hour), false)
|
||||
if _, _, err := Inspect(cert, otherKey); err == nil || !strings.Contains(err.Error(), "doesn't belong") {
|
||||
t.Errorf("mismatched key: %v", err)
|
||||
}
|
||||
leafCert, leafKey := makeCert(t, []string{"a.example.com"}, now, now.Add(time.Hour), false)
|
||||
if in, _, err := Inspect(leafCert, leafKey); err != nil || len(in.Warnings) != 1 || !strings.Contains(in.Warnings[0], "full chain") {
|
||||
t.Errorf("leaf only: %+v %v", in, err)
|
||||
}
|
||||
_ = leafOnly
|
||||
if !Covers([]string{"*.example.com"}, []string{"www.example.com"}) || Covers([]string{"*.example.com"}, []string{"a.b.example.com"}) || Covers([]string{"example.com"}, []string{"www.example.com"}) {
|
||||
t.Error("Covers")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDueAndStatus(t *testing.T) {
|
||||
m := &Manager{now: time.Now}
|
||||
now := time.Now().UTC().Truncate(time.Second)
|
||||
nb, na := now.Add(-80*24*time.Hour), now.Add(10*24*time.Hour)
|
||||
c := &model.Certificate{Source: model.CertSourceACME, CreatedAt: now.Add(-time.Hour)}
|
||||
if got := m.Due(c); !got.Equal(c.CreatedAt) {
|
||||
t.Errorf("never obtained: due %v", got)
|
||||
}
|
||||
if s := m.StatusOf(c); s.State != "pending" {
|
||||
t.Errorf("pending: %+v", s)
|
||||
}
|
||||
c.CertPEM, c.NotBefore, c.NotAfter = "x", &nb, &na
|
||||
if got, want := m.Due(c), twoThirds(nb, na); !got.Equal(want) {
|
||||
t.Errorf("due %v, want two thirds %v", got, want)
|
||||
}
|
||||
ari := now.Add(time.Hour)
|
||||
c.RenewAt = &ari
|
||||
if got := m.Due(c); !got.Equal(ari) {
|
||||
t.Errorf("due %v, want the CA's %v", got, ari)
|
||||
}
|
||||
// 10 days left of 90 is less than a sixth: expiring.
|
||||
if s := m.StatusOf(c); s.State != "expiring" {
|
||||
t.Errorf("status %+v", s)
|
||||
}
|
||||
// After three failures it waits four hours from the last attempt.
|
||||
last := now.Add(-time.Hour)
|
||||
c.Failures, c.LastAttempt, c.LastError = 3, &last, "boom"
|
||||
if got := m.Due(c); !got.Equal(last.Add(4 * time.Hour)) {
|
||||
t.Errorf("backoff: due %v", got)
|
||||
}
|
||||
if s := m.StatusOf(c); s.State != "failing" || !strings.Contains(s.Message, "boom") {
|
||||
t.Errorf("failing: %+v", s)
|
||||
}
|
||||
c.Failures = 30
|
||||
if got := m.Due(c); !got.Equal(last.Add(24 * time.Hour)) {
|
||||
t.Errorf("backoff is at most a day: due %v", got)
|
||||
}
|
||||
old := now.Add(-time.Hour)
|
||||
c.NotAfter = &old
|
||||
if s := m.StatusOf(c); s.State != "expired" {
|
||||
t.Errorf("expired: %+v", s)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInput(t *testing.T) {
|
||||
e := newEnv(t, false)
|
||||
ctx := context.Background()
|
||||
admin := &auth.Principal{User: &model.User{Username: "admin", Admin: true}}
|
||||
bad := []Input{
|
||||
{Name: model.Ptr("Bad Name"), Domains: &[]string{"a.example.com"}},
|
||||
{Name: model.Ptr("x"), Domains: &[]string{"localhost"}},
|
||||
{Name: model.Ptr("x"), Domains: &[]string{"a.*.example.com"}},
|
||||
{Name: model.Ptr("x"), Domains: &[]string{}},
|
||||
{Name: model.Ptr("x"), Domains: &[]string{"a.example.com"}, Uses: &[]string{"mail"}},
|
||||
{Name: model.Ptr("x"), Domains: &[]string{"a.example.com"}, Challenge: model.Ptr("http")},
|
||||
{Name: model.Ptr("x"), Domains: &[]string{"a.example.com"}, KeyType: model.Ptr("rsa1024")},
|
||||
{Name: model.Ptr("x"), Source: model.Ptr("file"), CertFile: model.Ptr("rel.pem"), KeyFile: model.Ptr("/k.pem")},
|
||||
{Name: model.Ptr("x"), Domains: &[]string{"a.example.com"}, CertFile: model.Ptr("/c.pem")},
|
||||
}
|
||||
for _, in := range bad {
|
||||
if _, err := e.m.Create(ctx, in, admin); err == nil {
|
||||
t.Errorf("accepted %+v", in)
|
||||
}
|
||||
}
|
||||
c, err := e.m.Create(ctx, Input{Name: model.Ptr("web"), Domains: &[]string{"WWW.Example.com.", "www.example.com", "*.example.com"}, Uses: &[]string{"web", "dns", "web"}}, admin)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Join(c.Domains, " ") != "www.example.com *.example.com" || strings.Join(c.Uses, ",") != "dns,web" || c.Challenge != "auto" || c.KeyType != "ec256" {
|
||||
t.Errorf("normalized: %+v", c)
|
||||
}
|
||||
// Only one certificate serves jarld's HTTPS.
|
||||
if _, err := e.m.Create(ctx, Input{Name: model.Ptr("web2"), Domains: &[]string{"a.example.com"}, Uses: &[]string{"web"}}, admin); err == nil || !strings.Contains(err.Error(), "already used for jarld's HTTPS") {
|
||||
t.Errorf("second web certificate: %v", err)
|
||||
}
|
||||
if _, err := e.m.Create(ctx, Input{Name: model.Ptr("web"), Domains: &[]string{"a.example.com"}}, admin); err != ErrConflict {
|
||||
t.Errorf("duplicate name: %v", err)
|
||||
}
|
||||
if _, err := e.m.Update(ctx, "web", Input{Name: model.Ptr("renamed")}, admin); err == nil {
|
||||
t.Error("rename accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileSource(t *testing.T) {
|
||||
e := newEnv(t, false)
|
||||
ctx := context.Background()
|
||||
admin := &auth.Principal{User: &model.User{Username: "admin", Admin: true}}
|
||||
now := time.Now()
|
||||
certFile, keyFile := filepath.Join(e.dir, "fullchain.pem"), filepath.Join(e.dir, "privkey.pem")
|
||||
cert, key := makeCert(t, []string{"ns1.example.com"}, now.Add(-time.Hour), now.Add(60*24*time.Hour), true)
|
||||
os.WriteFile(certFile, cert, 0o600)
|
||||
os.WriteFile(keyFile, key, 0o600)
|
||||
|
||||
c, err := e.m.Create(ctx, Input{Name: model.Ptr("own"), Source: model.Ptr("file"), CertFile: &certFile, KeyFile: &keyFile, Uses: &[]string{"web", "dns"}}, admin)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c, _ = e.m.Get(ctx, "own")
|
||||
if strings.Join(c.Domains, " ") != "ns1.example.com" || c.NotAfter == nil || c.CertPEM != "" {
|
||||
t.Errorf("read: %+v", c)
|
||||
}
|
||||
if e.m.WebCertificate() == nil || e.m.WebName() != "own" {
|
||||
t.Error("not served for HTTPS")
|
||||
}
|
||||
chain := filepath.Join(e.m.FilesDir("own"), ChainFile)
|
||||
if b, err := os.ReadFile(chain); err != nil || string(b) != string(cert) {
|
||||
t.Fatalf("files for named: %v", err)
|
||||
}
|
||||
if fi, err := os.Stat(filepath.Join(e.m.FilesDir("own"), KeyFile)); err != nil || fi.Mode().Perm() != 0o640 {
|
||||
t.Errorf("key file: %v %v", fi, err)
|
||||
}
|
||||
|
||||
// Renewed by its owner: the loop notices and puts the new one in place.
|
||||
cert2, key2 := makeCert(t, []string{"ns1.example.com", "dns.example.com"}, now, now.Add(90*24*time.Hour), true)
|
||||
os.WriteFile(certFile, cert2, 0o600)
|
||||
os.WriteFile(keyFile, key2, 0o600)
|
||||
future := now.Add(time.Minute)
|
||||
os.Chtimes(certFile, future, future)
|
||||
e.m.Tick(ctx)
|
||||
c, _ = e.m.Get(ctx, "own")
|
||||
if strings.Join(c.Domains, " ") != "ns1.example.com dns.example.com" {
|
||||
t.Errorf("not reread: %v", c.Domains)
|
||||
}
|
||||
if b, _ := os.ReadFile(chain); string(b) != string(cert2) {
|
||||
t.Error("files for named not updated")
|
||||
}
|
||||
if got := e.m.WebCertificate(); got == nil || len(got.Leaf.DNSNames) != 2 {
|
||||
t.Error("HTTPS still serves the old certificate")
|
||||
}
|
||||
|
||||
// A broken renewal keeps the last good certificate in place.
|
||||
os.WriteFile(keyFile, key, 0o600)
|
||||
os.Chtimes(keyFile, future.Add(time.Minute), future.Add(time.Minute))
|
||||
e.m.Tick(ctx)
|
||||
c, _ = e.m.Get(ctx, "own")
|
||||
if !strings.Contains(c.LastError, "doesn't belong") || e.m.StatusOf(c).State != "failing" {
|
||||
t.Errorf("mismatch not reported: %+v", c)
|
||||
}
|
||||
if b, _ := os.ReadFile(chain); string(b) != string(cert2) {
|
||||
t.Error("files for named replaced by a broken pair")
|
||||
}
|
||||
|
||||
// Without the dns use the files go; deleting stops HTTPS.
|
||||
if _, err := e.m.Update(ctx, "own", Input{Uses: &[]string{"web"}}, admin); err != nil {
|
||||
// The pair is broken now; put the good key back first.
|
||||
t.Log(err)
|
||||
}
|
||||
os.WriteFile(keyFile, key2, 0o600)
|
||||
if _, err := e.m.Update(ctx, "own", Input{Uses: &[]string{"web"}}, admin); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := os.Stat(e.m.FilesDir("own")); !os.IsNotExist(err) {
|
||||
t.Errorf("files for named left behind: %v", err)
|
||||
}
|
||||
if err := e.m.Delete(ctx, "own", admin); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if e.m.WebCertificate() != nil {
|
||||
t.Error("still serving a deleted certificate")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCleanLegoError(t *testing.T) {
|
||||
in := errors.New("error: one or more domains had a problem:\n[www.example.org] [www.example.org] acme: error presenting token: example.org is served by x, which Jarl can't write to\n[a.example.com] acme: error: 403 :: urn:ietf:params:acme:error:unauthorized :: Incorrect TXT record \"abc\" found at _acme-challenge.a.example.com\n")
|
||||
want := `www.example.org: example.org is served by x, which Jarl can't write to; a.example.com: the CA refused (unauthorized): Incorrect TXT record "abc" found at _acme-challenge.a.example.com`
|
||||
if got := cleanLegoError(in).Error(); got != want {
|
||||
t.Errorf("got %s\nwant %s", got, want)
|
||||
}
|
||||
if got := cleanLegoError(errors.New("acme: error: 429 :: urn:ietf:params:acme:error:rateLimited :: too many")).Error(); got != "the CA refused (rateLimited): too many" {
|
||||
t.Errorf("rate limit: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
// When certificates.dir changes, Start writes the certificates to the new
|
||||
// place and points encrypted DNS's tls statement there.
|
||||
func TestDirChangeFollowed(t *testing.T) {
|
||||
e := newEnv(t, false)
|
||||
ctx := context.Background()
|
||||
admin := &auth.Principal{User: &model.User{Username: "admin", Admin: true}}
|
||||
now := time.Now()
|
||||
certFile, keyFile := filepath.Join(e.dir, "c.pem"), filepath.Join(e.dir, "k.pem")
|
||||
cert, key := makeCert(t, []string{"dns.example.com"}, now.Add(-time.Hour), now.Add(60*24*time.Hour), true)
|
||||
os.WriteFile(certFile, cert, 0o600)
|
||||
os.WriteFile(keyFile, key, 0o600)
|
||||
if _, err := e.m.Create(ctx, Input{Name: model.Ptr("dns"), Source: model.Ptr("file"), CertFile: &certFile, KeyFile: &keyFile, Uses: &[]string{"dns"}}, admin); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
e.submit(engine.NewRequest(engine.OpOptionCreate, 0, engine.OptionInput{Scope: model.Ptr(model.ScopeTopLevel), Name: model.Ptr("tls"), Value: model.Ptr(encdns.TLSValue("dns", e.m.cfg.Dir))}))
|
||||
|
||||
moved := filepath.Join(e.dir, "tls-moved")
|
||||
cfg := e.m.cfg
|
||||
cfg.Dir = moved
|
||||
m2 := New(cfg, e.st, e.eng, slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||
m2.Start(ctx)
|
||||
if _, err := os.Stat(filepath.Join(moved, "dns", ChainFile)); err != nil {
|
||||
t.Fatalf("not written to the new place: %v", err)
|
||||
}
|
||||
for _, o := range e.state().ScopedOptions(model.ScopeTopLevel, 0) {
|
||||
if o.Name == "tls" && !strings.Contains(o.Value, moved+"/dns/fullchain.pem") {
|
||||
t.Errorf("tls statement still points elsewhere: %s", o.Value)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
// Package certs manages TLS certificates (docs/certificates.md): ones Jarl
|
||||
// obtains and renews itself over ACME, answering the DNS-01 challenges in
|
||||
// its own zones or at Porkbun, and ones you renew yourself that Jarl only
|
||||
// reads. A certificate is used for jarld's HTTPS ("web") and/or written
|
||||
// where named can read it ("dns", for DNS over TLS and HTTPS).
|
||||
package certs
|
||||
|
||||
import (
|
||||
"crypto/ecdsa"
|
||||
"crypto/ed25519"
|
||||
"crypto/rsa"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Info is what a certificate says about itself.
|
||||
type Info struct {
|
||||
Names []string
|
||||
NotBefore time.Time
|
||||
NotAfter time.Time
|
||||
Issuer string
|
||||
Serial string
|
||||
KeyType string // ec256, ec384, rsa2048, ...
|
||||
Leaf *x509.Certificate
|
||||
// Warnings are problems that don't stop it working everywhere (a
|
||||
// missing intermediate certificate).
|
||||
Warnings []string
|
||||
}
|
||||
|
||||
// Inspect checks that certPEM (the certificate, then its intermediates)
|
||||
// and keyPEM belong together and returns what the certificate says, and
|
||||
// the pair ready to serve.
|
||||
func Inspect(certPEM, keyPEM []byte) (*Info, *tls.Certificate, error) {
|
||||
pair, err := tls.X509KeyPair(certPEM, keyPEM)
|
||||
if err != nil {
|
||||
msg := err.Error()
|
||||
switch {
|
||||
case strings.Contains(msg, "private key does not match public key"):
|
||||
return nil, nil, errors.New("the key doesn't belong to the certificate")
|
||||
case strings.Contains(msg, "failed to find any PEM data in certificate"):
|
||||
return nil, nil, errors.New("no certificate found (expected PEM: -----BEGIN CERTIFICATE-----)")
|
||||
case strings.Contains(msg, "failed to find any PEM data in key"), strings.Contains(msg, "failed to find PEM block with type ending in \"PRIVATE KEY\""):
|
||||
return nil, nil, errors.New("no private key found (expected PEM: -----BEGIN ... PRIVATE KEY-----)")
|
||||
}
|
||||
return nil, nil, err
|
||||
}
|
||||
leaf := pair.Leaf
|
||||
if leaf == nil {
|
||||
if leaf, err = x509.ParseCertificate(pair.Certificate[0]); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
pair.Leaf = leaf
|
||||
}
|
||||
in := &Info{NotBefore: leaf.NotBefore, NotAfter: leaf.NotAfter, Serial: fmt.Sprintf("%x", leaf.SerialNumber), Leaf: leaf, KeyType: keyType(leaf)}
|
||||
in.Names = append(in.Names, leaf.DNSNames...)
|
||||
for _, ip := range leaf.IPAddresses {
|
||||
in.Names = append(in.Names, ip.String())
|
||||
}
|
||||
if len(in.Names) == 0 && leaf.Subject.CommonName != "" {
|
||||
in.Names = []string{leaf.Subject.CommonName}
|
||||
}
|
||||
in.Issuer = leaf.Issuer.CommonName
|
||||
if len(leaf.Issuer.Organization) > 0 {
|
||||
if in.Issuer == "" {
|
||||
in.Issuer = leaf.Issuer.Organization[0]
|
||||
} else {
|
||||
in.Issuer = leaf.Issuer.Organization[0] + " " + in.Issuer
|
||||
}
|
||||
}
|
||||
selfSigned := leaf.Issuer.String() == leaf.Subject.String()
|
||||
if len(pair.Certificate) == 1 && !selfSigned {
|
||||
in.Warnings = append(in.Warnings, "the file holds only the certificate, without its intermediate certificate: many clients will reject it; use the full chain (fullchain.pem)")
|
||||
}
|
||||
return in, &pair, nil
|
||||
}
|
||||
|
||||
func keyType(c *x509.Certificate) string {
|
||||
switch k := c.PublicKey.(type) {
|
||||
case *ecdsa.PublicKey:
|
||||
return fmt.Sprintf("ec%d", k.Curve.Params().BitSize)
|
||||
case *rsa.PublicKey:
|
||||
return fmt.Sprintf("rsa%d", k.N.BitLen())
|
||||
case ed25519.PublicKey:
|
||||
return "ed25519"
|
||||
}
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
// Covers reports whether the certificate names cover every name in want
|
||||
// (a wildcard covers one label).
|
||||
func Covers(names, want []string) bool {
|
||||
for _, w := range want {
|
||||
if !covered(names, w) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func covered(names []string, want string) bool {
|
||||
want = strings.ToLower(strings.TrimSuffix(want, "."))
|
||||
for _, n := range names {
|
||||
n = strings.ToLower(n)
|
||||
if n == want {
|
||||
return true
|
||||
}
|
||||
if base, ok := strings.CutPrefix(n, "*."); ok && !strings.HasPrefix(want, "*.") {
|
||||
if _, rest, ok := strings.Cut(want, "."); ok && rest == base {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
package certs
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/miekg/dns"
|
||||
)
|
||||
|
||||
// resolver asks recursive resolvers (acme.resolvers: public ones by
|
||||
// default), to see a domain as the CA does rather than as this server's
|
||||
// own views answer it.
|
||||
type resolver struct {
|
||||
servers []string
|
||||
timeout time.Duration
|
||||
}
|
||||
|
||||
func (r resolver) query(ctx context.Context, name string, qtype uint16) (*dns.Msg, error) {
|
||||
if len(r.servers) == 0 {
|
||||
return nil, errors.New("no resolvers configured (acme.resolvers)")
|
||||
}
|
||||
m := new(dns.Msg)
|
||||
m.SetQuestion(dns.Fqdn(name), qtype)
|
||||
m.SetEdns0(1232, false)
|
||||
m.RecursionDesired = true
|
||||
timeout := r.timeout
|
||||
if timeout == 0 {
|
||||
timeout = 5 * time.Second
|
||||
}
|
||||
var errs []error
|
||||
for _, s := range r.servers {
|
||||
c := &dns.Client{Timeout: timeout}
|
||||
in, _, err := c.ExchangeContext(ctx, m, s)
|
||||
if err == nil && in.Truncated {
|
||||
c.Net = "tcp"
|
||||
in, _, err = c.ExchangeContext(ctx, m, s)
|
||||
}
|
||||
if err != nil {
|
||||
errs = append(errs, fmt.Errorf("%s: %w", s, err))
|
||||
continue
|
||||
}
|
||||
if in.Rcode == dns.RcodeServerFailure || in.Rcode == dns.RcodeRefused {
|
||||
errs = append(errs, fmt.Errorf("%s answered %s", s, dns.RcodeToString[in.Rcode]))
|
||||
continue
|
||||
}
|
||||
return in, nil
|
||||
}
|
||||
return nil, fmt.Errorf("looking up %s %s: %w", strings.TrimSuffix(name, "."), dns.TypeToString[qtype], errors.Join(errs...))
|
||||
}
|
||||
|
||||
// zoneOf returns the zone that name is in on the internet (the owner of
|
||||
// the SOA record that answers for it).
|
||||
func (r resolver) zoneOf(ctx context.Context, name string) (string, error) {
|
||||
name = dns.Fqdn(strings.ToLower(name))
|
||||
for cand := name; ; {
|
||||
in, err := r.query(ctx, cand, dns.TypeSOA)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
for _, sec := range [][]dns.RR{in.Answer, in.Ns} {
|
||||
for _, rr := range sec {
|
||||
if soa, ok := rr.(*dns.SOA); ok && dns.IsSubDomain(soa.Hdr.Name, name) {
|
||||
return strings.ToLower(soa.Hdr.Name), nil
|
||||
}
|
||||
}
|
||||
}
|
||||
i, end := dns.NextLabel(cand, 0)
|
||||
if end {
|
||||
return "", fmt.Errorf("no zone found for %s", strings.TrimSuffix(name, "."))
|
||||
}
|
||||
cand = cand[i:]
|
||||
}
|
||||
}
|
||||
|
||||
// nameServers returns the zone's name servers on the internet (lowercase,
|
||||
// without trailing dots, sorted).
|
||||
func (r resolver) nameServers(ctx context.Context, zone string) ([]string, error) {
|
||||
in, err := r.query(ctx, zone, dns.TypeNS)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var out []string
|
||||
for _, rr := range in.Answer {
|
||||
if ns, ok := rr.(*dns.NS); ok && strings.EqualFold(ns.Hdr.Name, dns.Fqdn(zone)) {
|
||||
out = append(out, normName(ns.Ns))
|
||||
}
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil, fmt.Errorf("%s has no name servers on the internet (%s)", strings.TrimSuffix(zone, "."), dns.RcodeToString[in.Rcode])
|
||||
}
|
||||
slices.Sort(out)
|
||||
return slices.Compact(out), nil
|
||||
}
|
||||
|
||||
// follow returns where name leads after its CNAME records (name itself
|
||||
// when it has none).
|
||||
func (r resolver) follow(ctx context.Context, name string) (string, error) {
|
||||
name = dns.Fqdn(strings.ToLower(name))
|
||||
for range 10 {
|
||||
in, err := r.query(ctx, name, dns.TypeCNAME)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
next := ""
|
||||
for _, rr := range in.Answer {
|
||||
if c, ok := rr.(*dns.CNAME); ok && strings.EqualFold(c.Hdr.Name, name) {
|
||||
next = strings.ToLower(c.Target)
|
||||
}
|
||||
}
|
||||
if next == "" {
|
||||
return name, nil
|
||||
}
|
||||
name = next
|
||||
}
|
||||
return "", fmt.Errorf("%s: more than 10 CNAME records in a row", name)
|
||||
}
|
||||
|
||||
func normName(n string) string { return strings.ToLower(strings.TrimSuffix(n, ".")) }
|
||||
|
||||
// challengeName is the DNS-01 record name for a certificate name (a
|
||||
// wildcard shares its base name's).
|
||||
func challengeName(domain string) string {
|
||||
return "_acme-challenge." + dns.Fqdn(strings.TrimPrefix(strings.ToLower(domain), "*."))
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,146 @@
|
||||
package certs
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"jarl/internal/acme"
|
||||
"jarl/internal/model"
|
||||
)
|
||||
|
||||
// Route says where the DNS-01 challenge for one certificate name goes,
|
||||
// and why.
|
||||
type Route struct {
|
||||
Domain string `json:"domain"` // the name on the certificate
|
||||
Record string `json:"record"` // _acme-challenge.<domain>
|
||||
// Target is where the TXT record is written: Record, or where its
|
||||
// CNAME points.
|
||||
Target string `json:"target"`
|
||||
Route string `json:"route,omitempty"` // jarl or porkbun; "" when there is none
|
||||
Zone string `json:"zone,omitempty"` // the zone on the internet that holds Target
|
||||
PublicNS []string `json:"public_ns,omitempty"`
|
||||
Why string `json:"why"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
func (r Route) String() string {
|
||||
s := r.Domain + ": " + r.Route
|
||||
if r.Target != r.Record {
|
||||
s += " via CNAME " + strings.TrimSuffix(r.Target, ".")
|
||||
}
|
||||
return s + " (zone " + strings.TrimSuffix(r.Zone, ".") + ")"
|
||||
}
|
||||
|
||||
func isPorkbunNS(ns []string) bool {
|
||||
for _, n := range ns {
|
||||
if strings.HasSuffix(n, ".porkbun.com") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// route decides where the challenge for domain goes. challenge is the
|
||||
// certificate's setting: auto picks from the zone's public name servers.
|
||||
// target is the record name after CNAMEs ("" to look it up).
|
||||
func (m *Manager) route(ctx context.Context, st *model.State, challenge, domain, target string) Route {
|
||||
r := Route{Domain: domain, Record: challengeName(domain)}
|
||||
fail := func(format string, a ...any) Route {
|
||||
r.Error = fmt.Sprintf(format, a...)
|
||||
return r
|
||||
}
|
||||
if target == "" {
|
||||
t, err := m.res.follow(ctx, r.Record)
|
||||
if err != nil {
|
||||
return fail("%v", err)
|
||||
}
|
||||
target = t
|
||||
}
|
||||
r.Target = target
|
||||
zone, err := m.res.zoneOf(ctx, target)
|
||||
if err != nil {
|
||||
return fail("%v", err)
|
||||
}
|
||||
r.Zone = zone
|
||||
ns, err := m.res.nameServers(ctx, zone)
|
||||
if err != nil {
|
||||
return fail("%v", err)
|
||||
}
|
||||
r.PublicNS = ns
|
||||
zoneName, nsList := strings.TrimSuffix(zone, "."), strings.Join(ns, ", ")
|
||||
|
||||
// Jarl can answer when it has the zone and the internet asks the name
|
||||
// servers listed in it.
|
||||
var jarlNS []string
|
||||
jarlHas := false
|
||||
for _, z := range st.SortedZones() {
|
||||
if z.Type != model.ZonePrimary || z.Disabled || normName(z.Name) != normName(zone) {
|
||||
continue
|
||||
}
|
||||
jarlHas = true
|
||||
for _, rec := range st.ZoneRecords(z.ID) {
|
||||
if rec.Type == "NS" && !rec.Disabled && normName(rec.Name) == normName(zone) {
|
||||
jarlNS = append(jarlNS, normName(rec.Data))
|
||||
}
|
||||
}
|
||||
}
|
||||
slices.Sort(jarlNS)
|
||||
jarlNS = slices.Compact(jarlNS)
|
||||
servedByJarl := jarlHas && slices.ContainsFunc(ns, func(n string) bool { return slices.Contains(jarlNS, n) })
|
||||
porkbunOK := m.cfg.Porkbun != nil && (m.cfg.PorkbunAllows == nil || m.cfg.PorkbunAllows(zoneName))
|
||||
|
||||
porkbun := func() Route {
|
||||
r.Route, r.Why = model.ChallengePorkbun, fmt.Sprintf("%s is served by Porkbun (%s), so the record goes in through Porkbun's API", zoneName, nsList)
|
||||
return r
|
||||
}
|
||||
jarl := func() Route {
|
||||
r.Route, r.Why = model.ChallengeJarl, fmt.Sprintf("%s is served by this server's name servers (%s), so Jarl writes the record itself", zoneName, nsList)
|
||||
return r
|
||||
}
|
||||
switch challenge {
|
||||
case model.ChallengePorkbun:
|
||||
switch {
|
||||
case m.cfg.Porkbun == nil:
|
||||
return fail("the challenge is set to porkbun, but jarl.yaml has no porkbun.api_key")
|
||||
case !porkbunOK:
|
||||
return fail("the challenge is set to porkbun, but %s isn't in porkbun.domains in jarl.yaml", zoneName)
|
||||
case !isPorkbunNS(ns):
|
||||
return fail("the challenge is set to porkbun, but on the internet %s is served by %s, not Porkbun: the CA would never see the record (set the challenge to auto)", zoneName, nsList)
|
||||
}
|
||||
return porkbun()
|
||||
case model.ChallengeJarl:
|
||||
switch {
|
||||
case !jarlHas:
|
||||
return fail("the challenge is set to jarl, but no primary zone in Jarl is %s (where %s is on the internet)", zoneName, strings.TrimSuffix(target, "."))
|
||||
case !servedByJarl:
|
||||
return fail("the challenge is set to jarl, but on the internet %s is served by %s, not by the name servers in Jarl's zone (%s): the CA would never see the record (set the challenge to auto)", zoneName, nsList, strings.Join(jarlNS, ", "))
|
||||
}
|
||||
return jarl()
|
||||
}
|
||||
switch {
|
||||
case isPorkbunNS(ns) && porkbunOK:
|
||||
return porkbun()
|
||||
case servedByJarl:
|
||||
return jarl()
|
||||
case isPorkbunNS(ns) && m.cfg.Porkbun == nil:
|
||||
return fail("%s is served by Porkbun (%s) and jarl.yaml has no porkbun.api_key. Add the key, or point %s with a CNAME at a name in a zone this server serves on the internet", zoneName, nsList, strings.TrimSuffix(r.Record, "."))
|
||||
case isPorkbunNS(ns):
|
||||
return fail("%s is served by Porkbun (%s) but isn't in porkbun.domains in jarl.yaml", zoneName, nsList)
|
||||
case jarlHas:
|
||||
return fail("on the internet %s is served by %s, not by the name servers in Jarl's zone (%s), so the CA would never see a record Jarl writes. Delegate the zone to this server, or point %s with a CNAME at a name in a zone it serves", zoneName, nsList, strings.Join(jarlNS, ", "), strings.TrimSuffix(r.Record, "."))
|
||||
}
|
||||
return fail("%s is served by %s, which Jarl can't write to. Point %s with a CNAME at a name in a zone this server serves on the internet (for example %s.acme.<your domain>, in a zone acme.<your domain> delegated here): Jarl follows the CNAME and answers there", zoneName, nsList, strings.TrimSuffix(r.Record, "."), strings.ReplaceAll(strings.TrimSuffix(strings.TrimPrefix(r.Record, "_acme-challenge."), "."), ".", "-"))
|
||||
}
|
||||
|
||||
// primaryZones lists the zones Jarl can write challenge records into.
|
||||
func primaryZones(st *model.State) []acme.Zone {
|
||||
var out []acme.Zone
|
||||
for _, z := range st.SortedZones() {
|
||||
if z.Type == model.ZonePrimary && !z.Disabled {
|
||||
out = append(out, acme.Zone{Name: z.Name, View: st.ViewName(z), Link: z.LinkGroup})
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -30,8 +30,17 @@ type Client struct {
|
||||
// Changeset, if set, stages changes into this changeset instead of
|
||||
// applying them.
|
||||
Changeset int64
|
||||
DryRun bool
|
||||
Force bool
|
||||
// CheckChangeset, if set, runs once before Changeset is first used,
|
||||
// and its error stops the request. The CLI checks a changeset taken
|
||||
// from JARL_CHANGESET with it.
|
||||
CheckChangeset func(ctx context.Context) error
|
||||
csChecked bool
|
||||
csErr error
|
||||
DryRun bool
|
||||
Force bool
|
||||
// AcceptRisk confirms changes that open the server up
|
||||
// (docs/exposure.md).
|
||||
AcceptRisk bool
|
||||
// Preflight lets only requests through that can't change anything:
|
||||
// reads and dry runs. Running a command on several servers uses it to
|
||||
// check the command everywhere before running it anywhere.
|
||||
@@ -183,7 +192,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)
|
||||
}
|
||||
@@ -243,6 +252,19 @@ func (c *Client) Get(ctx context.Context, path string, query url.Values, out any
|
||||
return c.Do(ctx, http.MethodGet, path, query, nil, out)
|
||||
}
|
||||
|
||||
// UseChangeset returns the changeset to stage into (0 for none), once
|
||||
// CheckChangeset has accepted it.
|
||||
func (c *Client) UseChangeset(ctx context.Context) (int64, error) {
|
||||
if c.Changeset == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
if c.CheckChangeset != nil && !c.csChecked {
|
||||
c.csChecked = true
|
||||
c.csErr = c.CheckChangeset(ctx)
|
||||
}
|
||||
return c.Changeset, c.csErr
|
||||
}
|
||||
|
||||
// Mutate sends a configuration change, honoring Changeset, DryRun and
|
||||
// Force.
|
||||
func (c *Client) Mutate(ctx context.Context, method, path string, query url.Values, body any) (*api.MutationResponse, error) {
|
||||
@@ -250,8 +272,12 @@ func (c *Client) Mutate(ctx context.Context, method, path string, query url.Valu
|
||||
for k, v := range query {
|
||||
q[k] = v
|
||||
}
|
||||
if c.Changeset > 0 {
|
||||
q.Set("changeset", fmt.Sprint(c.Changeset))
|
||||
cs, err := c.UseChangeset(ctx)
|
||||
if err != nil {
|
||||
return &api.MutationResponse{}, err
|
||||
}
|
||||
if cs > 0 {
|
||||
q.Set("changeset", fmt.Sprint(cs))
|
||||
}
|
||||
if c.DryRun {
|
||||
q.Set("dry_run", "1")
|
||||
@@ -259,8 +285,11 @@ func (c *Client) Mutate(ctx context.Context, method, path string, query url.Valu
|
||||
if c.Force {
|
||||
q.Set("force", "1")
|
||||
}
|
||||
if c.AcceptRisk {
|
||||
q.Set("accept_risk", "1")
|
||||
}
|
||||
var out api.MutationResponse
|
||||
err := c.Do(ctx, method, path, q, body, &out)
|
||||
err = c.Do(ctx, method, path, q, body, &out)
|
||||
return &out, err
|
||||
}
|
||||
|
||||
|
||||
+398
-23
@@ -4,7 +4,9 @@ package config
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/netip"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
@@ -24,16 +26,159 @@ 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"`
|
||||
Metrics Metrics `yaml:"metrics"`
|
||||
// Certificates configures TLS certificates Jarl manages
|
||||
// (docs/certificates.md); the ACME account is under acme.
|
||||
Certificates Certificates `yaml:"certificates"`
|
||||
}
|
||||
|
||||
// Certificates configures the certificates Jarl manages.
|
||||
type Certificates struct {
|
||||
// Dir is where certificates are written for named (DNS over TLS and
|
||||
// HTTPS): one directory per certificate, readable by bind.file_group.
|
||||
// "" is <bind.config_dir>/tls.
|
||||
Dir string `yaml:"dir"`
|
||||
// MaxPerUser is how many certificates a user may own through jarl
|
||||
// cert get (certificates for other hosts), unless the user has a
|
||||
// limit of their own (jarl user update --cert-limit). Default 10.
|
||||
MaxPerUser int `yaml:"max_per_user"`
|
||||
}
|
||||
|
||||
// Metrics configures the Prometheus endpoint /metrics (docs/metrics.md).
|
||||
// A scrape is allowed from an address in Allow, or with Token as a bearer
|
||||
// token; root and jarld's own user may also scrape on the local socket.
|
||||
type Metrics struct {
|
||||
Enabled bool `yaml:"enabled"`
|
||||
// Listen are extra addresses that serve only /metrics, over plain
|
||||
// HTTP. /metrics is always served on the main listeners too.
|
||||
Listen []string `yaml:"listen"`
|
||||
// Allow are the addresses or prefixes that may scrape without a token.
|
||||
Allow []string `yaml:"allow"`
|
||||
// Token, when set, lets any address that sends it as
|
||||
// "Authorization: Bearer TOKEN" scrape.
|
||||
Token string `yaml:"token"`
|
||||
}
|
||||
|
||||
func (m *Metrics) check() error {
|
||||
if !m.Enabled {
|
||||
return nil
|
||||
}
|
||||
for _, a := range m.Listen {
|
||||
if _, port, err := net.SplitHostPort(a); err != nil || port == "" {
|
||||
return fmt.Errorf("listen %q must be ADDRESS:PORT, e.g. 0.0.0.0:9450", a)
|
||||
}
|
||||
}
|
||||
for _, p := range m.Allow {
|
||||
if _, err := ParsePrefix(p); err != nil {
|
||||
return fmt.Errorf("allow: %v", err)
|
||||
}
|
||||
}
|
||||
if m.Token != "" && (len(m.Token) < 16 || strings.ContainsAny(m.Token, " \t\r\n")) {
|
||||
return fmt.Errorf("token must be at least 16 characters, without spaces (e.g. from 'openssl rand -hex 24')")
|
||||
}
|
||||
if len(m.Allow) == 0 && m.Token == "" {
|
||||
return fmt.Errorf("set allow, token or both, or nobody can scrape")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// AllowPrefixes returns the parsed allow list.
|
||||
func (m *Metrics) AllowPrefixes() []netip.Prefix {
|
||||
var out []netip.Prefix
|
||||
for _, p := range m.Allow {
|
||||
if pf, err := ParsePrefix(p); err == nil {
|
||||
out = append(out, pf.Masked())
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Blocking configures blocklist downloads (docs/blocking.md). The lists
|
||||
// and rules themselves are configuration in the database.
|
||||
type Blocking struct {
|
||||
// Refresh is how often each list is checked for changes; a list is
|
||||
// only downloaded again when it changed.
|
||||
Refresh Duration `yaml:"refresh"`
|
||||
MaxDownloadMB int `yaml:"max_download_mb"`
|
||||
MaxEntries int `yaml:"max_entries"`
|
||||
}
|
||||
|
||||
func (b *Blocking) check() error {
|
||||
if b.Refresh < Duration(time.Hour) {
|
||||
return fmt.Errorf("refresh must be at least 1h (list hosts ask not to be polled more often)")
|
||||
}
|
||||
if b.MaxDownloadMB < 1 || b.MaxEntries < 1 {
|
||||
return fmt.Errorf("max_download_mb and max_entries must be at least 1")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Porkbun lets Jarl manage domains' name servers, glue records and DNSSEC
|
||||
// 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
|
||||
@@ -53,9 +198,85 @@ type Backup struct {
|
||||
WarnAfter Duration `yaml:"warn_after"`
|
||||
}
|
||||
|
||||
// ACME configures the ACME helpers.
|
||||
// ACME configures the ACME helpers and the account Jarl uses to obtain
|
||||
// certificates itself.
|
||||
type ACME struct {
|
||||
HTTPReq HTTPReq `yaml:"httpreq"`
|
||||
HTTPReq HTTPReq `yaml:"httpreq"`
|
||||
Account ACMEAccount `yaml:"account"`
|
||||
// Resolvers are the recursive resolvers used to see a domain as the
|
||||
// CA does: its zone, its public name servers and any CNAME on
|
||||
// _acme-challenge. Public resolvers by default, because this server's
|
||||
// own answers may come from an internal view.
|
||||
Resolvers []string `yaml:"resolvers"`
|
||||
// CABundle is a PEM file of extra CA certificates to trust when
|
||||
// talking to the ACME server (a private CA such as step-ca).
|
||||
CABundle string `yaml:"ca_bundle"`
|
||||
// CheckPropagation waits until every name server of the zone serves
|
||||
// a challenge before telling the CA to look.
|
||||
CheckPropagation bool `yaml:"check_propagation"`
|
||||
}
|
||||
|
||||
// ACMEAccount is the ACME account certificates are obtained with.
|
||||
type ACMEAccount struct {
|
||||
// Directory is letsencrypt, letsencrypt-staging or an ACME directory URL.
|
||||
Directory string `yaml:"directory"`
|
||||
Email string `yaml:"email"`
|
||||
// AgreeTOS says you accept the CA's terms of service. Jarl doesn't
|
||||
// register an account without it.
|
||||
AgreeTOS bool `yaml:"agree_tos"`
|
||||
// EABKeyID and EABHMAC are External Account Binding credentials, for
|
||||
// CAs that need them (ZeroSSL, Google).
|
||||
EABKeyID string `yaml:"eab_kid"`
|
||||
EABHMAC string `yaml:"eab_hmac"`
|
||||
}
|
||||
|
||||
// Directory URLs for the names acme.account.directory accepts.
|
||||
const (
|
||||
LetsEncrypt = "https://acme-v02.api.letsencrypt.org/directory"
|
||||
LetsEncryptStaging = "https://acme-staging-v02.api.letsencrypt.org/directory"
|
||||
)
|
||||
|
||||
// DirectoryURL is the account's ACME directory URL.
|
||||
func (a ACMEAccount) DirectoryURL() string {
|
||||
switch a.Directory {
|
||||
case "", "letsencrypt":
|
||||
return LetsEncrypt
|
||||
case "letsencrypt-staging":
|
||||
return LetsEncryptStaging
|
||||
}
|
||||
return a.Directory
|
||||
}
|
||||
|
||||
func (a *ACME) check() error {
|
||||
if d := a.Account.Directory; d != "" && d != "letsencrypt" && d != "letsencrypt-staging" {
|
||||
u, err := url.Parse(d)
|
||||
if err != nil || u.Scheme != "https" || u.Host == "" {
|
||||
return fmt.Errorf("account.directory must be letsencrypt, letsencrypt-staging or an https:// URL")
|
||||
}
|
||||
}
|
||||
if a.Account.Email != "" && !strings.Contains(a.Account.Email, "@") {
|
||||
return fmt.Errorf("account.email %q isn't an email address", a.Account.Email)
|
||||
}
|
||||
if (a.Account.EABKeyID == "") != (a.Account.EABHMAC == "") {
|
||||
return fmt.Errorf("account: set both eab_kid and eab_hmac, or neither")
|
||||
}
|
||||
for _, r := range a.Resolvers {
|
||||
if _, _, err := net.SplitHostPort(r); err != nil {
|
||||
return fmt.Errorf("resolvers: %q must be address:port, e.g. 1.1.1.1:53 or [2606:4700:4700::1111]:53", r)
|
||||
}
|
||||
}
|
||||
if a.CABundle != "" && !filepath.IsAbs(a.CABundle) {
|
||||
return fmt.Errorf("ca_bundle must be an absolute path")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// CertDir is certificates.dir, or <bind.config_dir>/tls.
|
||||
func (c *Config) CertDir() string {
|
||||
if c.Certificates.Dir != "" {
|
||||
return c.Certificates.Dir
|
||||
}
|
||||
return filepath.Join(c.BIND.ConfigDir, "tls")
|
||||
}
|
||||
|
||||
// HTTPReq configures the endpoint for lego's httpreq DNS provider
|
||||
@@ -93,12 +314,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"`
|
||||
@@ -122,6 +368,10 @@ type Stats struct {
|
||||
// per-zone counts) from named's response log. Without it only named's
|
||||
// own counters are collected.
|
||||
QueryLog bool `yaml:"query_log"`
|
||||
// RateLimitLog sends rate limiting's log lines (named's query-errors
|
||||
// category) to the response log too, so the Exposure page and the
|
||||
// statistics can show what was, or would be, limited.
|
||||
RateLimitLog bool `yaml:"rate_limit_log"`
|
||||
// Database is the statistics database, kept apart from Jarl's
|
||||
// configuration database.
|
||||
Database string `yaml:"database"`
|
||||
@@ -149,6 +399,9 @@ type StatsRetention struct {
|
||||
Minute Duration `yaml:"minute"`
|
||||
Hour Duration `yaml:"hour"`
|
||||
Day Duration `yaml:"day"`
|
||||
// Queries is how long the query log keeps every answer, for exploring
|
||||
// (docs/stats.md#exploring); 0 keeps none.
|
||||
Queries Duration `yaml:"queries"`
|
||||
}
|
||||
|
||||
// Duration is a time.Duration written as "15s" or "12h" in YAML. Days
|
||||
@@ -194,12 +447,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",
|
||||
@@ -207,16 +462,21 @@ func Default() *Config {
|
||||
},
|
||||
Stats: Stats{
|
||||
QueryLog: true,
|
||||
RateLimitLog: true,
|
||||
Database: "/var/lib/jarl/stats.db",
|
||||
StatisticsChannel: "127.0.0.1:8058",
|
||||
LogFile: "/var/log/named/jarl-responses.log",
|
||||
LogSize: "20m",
|
||||
LogVersions: 2,
|
||||
Retention: StatsRetention{Minute: Duration(7 * 24 * time.Hour), Hour: Duration(90 * 24 * time.Hour), Day: Duration(730 * 24 * time.Hour)},
|
||||
Retention: StatsRetention{Minute: Duration(7 * 24 * time.Hour), Hour: Duration(90 * 24 * time.Hour), Day: Duration(730 * 24 * time.Hour), Queries: Duration(7 * 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)},
|
||||
Certificates: Certificates{MaxPerUser: 10},
|
||||
ACME: ACME{HTTPReq: HTTPReq{RequireHTTPS: true}, Account: ACMEAccount{Directory: "letsencrypt"},
|
||||
Resolvers: []string{"1.1.1.1:53", "9.9.9.9:53", "8.8.8.8:53"}, CheckPropagation: true},
|
||||
Blocking: Blocking{Refresh: Duration(24 * time.Hour), MaxDownloadMB: 64, MaxEntries: 1_000_000},
|
||||
Metrics: Metrics{Allow: []string{"127.0.0.1", "::1"}},
|
||||
Backup: Backup{Dir: "/var/backups/jarl", Retention: Duration(30 * 24 * time.Hour), KeepAtLeast: 7, WarnAfter: Duration(2 * 24 * time.Hour)},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -267,12 +527,35 @@ 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)
|
||||
}
|
||||
if err := c.Metrics.check(); err != nil {
|
||||
return fmt.Errorf("metrics: %w", err)
|
||||
}
|
||||
if err := c.ACME.check(); err != nil {
|
||||
return fmt.Errorf("acme: %w", err)
|
||||
}
|
||||
if d := c.Certificates.Dir; d != "" && !filepath.IsAbs(d) {
|
||||
return fmt.Errorf("certificates.dir must be an absolute path")
|
||||
}
|
||||
switch c.Log.Format {
|
||||
case "text", "json":
|
||||
default:
|
||||
@@ -329,6 +612,9 @@ func (s *Stats) check() error {
|
||||
if r.Minute < Duration(time.Hour) || r.Hour < Duration(24*time.Hour) || r.Day < Duration(24*time.Hour) {
|
||||
return fmt.Errorf("retention must keep minutes for at least 1h, hours and days for at least 1d")
|
||||
}
|
||||
if r.Queries < 0 || (r.Queries > 0 && r.Queries < Duration(time.Hour)) {
|
||||
return fmt.Errorf("retention.queries must be at least 1h, or 0 for no query log")
|
||||
}
|
||||
if s.TopN < 10 || s.TopN > 10000 {
|
||||
return fmt.Errorf("top_n must be between 10 and 10000")
|
||||
}
|
||||
@@ -376,8 +662,10 @@ const Example = `# Jarl daemon configuration (/etc/jarl/jarl.yaml).
|
||||
listen:
|
||||
- "127.0.0.1:8053"
|
||||
|
||||
# HTTPS. The certificate is reread when the file changes (or on SIGHUP),
|
||||
# so renewed Let's Encrypt certificates are picked up automatically.
|
||||
# HTTPS from a certificate file you renew yourself. The certificate is
|
||||
# reread when the file changes (or on SIGHUP). Easier: let Jarl obtain one
|
||||
# ('jarl cert create ... --use web', docs/certificates.md), which then
|
||||
# takes over from this.
|
||||
tls:
|
||||
cert_file: ""
|
||||
key_file: ""
|
||||
@@ -404,6 +692,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 +712,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 +733,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
|
||||
@@ -451,6 +752,10 @@ stats:
|
||||
# Per-query data (top names and clients, per view and zone). false
|
||||
# collects only named's own counters, with no logging overhead.
|
||||
query_log: true
|
||||
# With a rate-limit set, also log what rate limiting limits (or would,
|
||||
# in log-only mode): named's query-errors category, which Jarl then adds
|
||||
# to its log channel (and keeps in syslog). The Exposure page shows it.
|
||||
rate_limit_log: true
|
||||
database: /var/lib/jarl/stats.db
|
||||
statistics_channel: 127.0.0.1:8058
|
||||
log_file: /var/log/named/jarl-responses.log
|
||||
@@ -460,6 +765,7 @@ stats:
|
||||
minute: 7d # per-minute counters
|
||||
hour: 90d # hourly counters and top lists
|
||||
day: 730d # daily counters and top lists
|
||||
queries: 7d # every answer, to explore (who asked what); 0: none
|
||||
top_n: 100 # names and clients kept per hour and per day
|
||||
anonymize_clients: false # store 10.10.0.0/24 instead of 10.10.0.37
|
||||
exclude_clients: [] # e.g. [127.0.0.1] to ignore local monitoring
|
||||
@@ -478,4 +784,73 @@ 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 account Jarl obtains certificates with (docs/certificates.md).
|
||||
# Nothing is registered until you create a certificate. Read the CA's
|
||||
# terms (https://letsencrypt.org/repository/) and set agree_tos: true.
|
||||
# Try letsencrypt-staging first: its certificates aren't trusted, but
|
||||
# its rate limits are generous.
|
||||
account:
|
||||
directory: letsencrypt # letsencrypt, letsencrypt-staging or a directory URL
|
||||
email: "" # optional
|
||||
agree_tos: false
|
||||
eab_kid: "" # External Account Binding, only for CAs that need it
|
||||
eab_hmac: ""
|
||||
# Resolvers to see your domains as the CA does (their public name
|
||||
# servers, a CNAME on _acme-challenge). Not this server: it may answer
|
||||
# from an internal view.
|
||||
resolvers: ["1.1.1.1:53", "9.9.9.9:53", "8.8.8.8:53"]
|
||||
ca_bundle: "" # extra CA certificates for a private ACME server
|
||||
# Wait until every name server of the zone serves a challenge before
|
||||
# telling the CA to look (they are asked directly, on port 53).
|
||||
check_propagation: true
|
||||
|
||||
# Where certificates with the "dns" use are written for named, one
|
||||
# directory per certificate (fullchain.pem, privkey.pem), readable by
|
||||
# bind.file_group. Default: <bind.config_dir>/tls.
|
||||
certificates:
|
||||
dir: ""
|
||||
|
||||
# Porkbun, as registrar and as DNS host. 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
|
||||
# (docs/dnssec.md#porkbun);
|
||||
# - publish the DNS-01 challenges of certificates Jarl obtains while
|
||||
# Porkbun still serves the domain's DNS (docs/certificates.md): it adds
|
||||
# and removes TXT records at _acme-challenge names only.
|
||||
# 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
|
||||
|
||||
# Prometheus metrics at /metrics (docs/metrics.md): named's counters and
|
||||
# Jarl's own (blocking, zones, views), plus health gauges to alert on
|
||||
# (named up, validation problems, blocklist errors, backup age, DNSSEC
|
||||
# steps, token expiry). Off by default. /metrics is served on the
|
||||
# addresses in 'listen' at the top of this file; a scrape is allowed from
|
||||
# an address in 'allow' below, or with the token. Query counters need
|
||||
# stats.enabled.
|
||||
metrics:
|
||||
enabled: false
|
||||
# Extra addresses that serve only /metrics, over plain HTTP, e.g.
|
||||
# ["0.0.0.0:9450"] for a Prometheus server elsewhere on the network.
|
||||
listen: []
|
||||
# Addresses or prefixes that may scrape without a token.
|
||||
allow: ["127.0.0.1", "::1"]
|
||||
# A bearer token (at least 16 characters) that lets any address scrape:
|
||||
# in Prometheus, 'authorization: { credentials: TOKEN }'. Keep this file
|
||||
# readable by root and jarld only when you set it.
|
||||
token: ""
|
||||
`
|
||||
@@ -3,6 +3,7 @@ package config
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
@@ -18,7 +19,7 @@ func TestExampleParses(t *testing.T) {
|
||||
if c.BIND.ZoneDir != d.BIND.ZoneDir || time.Duration(c.Session.IdleTimeout) != 2*time.Hour || c.Listen[0] != "127.0.0.1:8053" {
|
||||
t.Errorf("example differs from defaults: %+v", c)
|
||||
}
|
||||
if c.ACME != d.ACME || c.ACME.HTTPReq.Enabled || !c.ACME.HTTPReq.RequireHTTPS {
|
||||
if !reflect.DeepEqual(c.ACME, d.ACME) || !reflect.DeepEqual(c.Certificates, d.Certificates) || c.ACME.HTTPReq.Enabled || !c.ACME.HTTPReq.RequireHTTPS {
|
||||
t.Errorf("acme = %+v, want the defaults %+v", c.ACME, d.ACME)
|
||||
}
|
||||
}
|
||||
@@ -87,3 +88,31 @@ func TestStatsConfig(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMetricsConfig(t *testing.T) {
|
||||
d := Default()
|
||||
if d.Metrics.Enabled || len(d.Metrics.AllowPrefixes()) != 2 || d.Metrics.Token != "" {
|
||||
t.Errorf("defaults = %+v", d.Metrics)
|
||||
}
|
||||
path := filepath.Join(t.TempDir(), "jarl.yaml")
|
||||
for _, tc := range []struct {
|
||||
yaml string
|
||||
ok bool
|
||||
}{
|
||||
{"metrics: {enabled: true}", true},
|
||||
{"metrics: {enabled: true, listen: [\"0.0.0.0:9450\", \"[::]:9450\"], allow: [10.0.0.5, 192.168.1.0/24]}", true},
|
||||
{"metrics: {enabled: true, allow: [], token: 0123456789abcdef}", true},
|
||||
{"metrics: {enabled: true, allow: []}", false}, // nobody could scrape
|
||||
{"metrics: {enabled: true, token: short}", false},
|
||||
{"metrics: {enabled: true, token: \"has space 0123456789\"}", false},
|
||||
{"metrics: {enabled: true, listen: [\"9450\"]}", false},
|
||||
{"metrics: {enabled: true, allow: [prometheus.lan]}", false},
|
||||
{"metrics: {enabled: false, listen: [nonsense]}", true}, // only checked when enabled
|
||||
} {
|
||||
os.WriteFile(path, []byte(tc.yaml+"\n"), 0o644)
|
||||
_, err := Load(path, false)
|
||||
if (err == nil) != tc.ok {
|
||||
t.Errorf("%s: err = %v, want ok=%v", tc.yaml, err, tc.ok)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
|
||||
"github.com/miekg/dns"
|
||||
"golang.org/x/net/idna"
|
||||
"golang.org/x/net/publicsuffix"
|
||||
)
|
||||
|
||||
// Root is the root domain name.
|
||||
@@ -305,6 +306,28 @@ func EmailFromMailbox(rname string) string {
|
||||
return local + "@" + strings.Join(labels[1:], ".")
|
||||
}
|
||||
|
||||
// RegisteredDomain returns the domain registered at a registrar that name
|
||||
// is in, without a trailing dot: adbraun.lol for lab.adbraun.lol, from
|
||||
// the public suffix list. ok is false when no registrar is involved:
|
||||
// reverse zones and the rest of .arpa (delegated by whoever holds the
|
||||
// addresses), names under a suffix the list doesn't know (home.lan,
|
||||
// internal) and public suffixes themselves (co.uk).
|
||||
func RegisteredDomain(name string) (string, bool) {
|
||||
n := strings.ToLower(strings.TrimSuffix(name, "."))
|
||||
if n == "" || n == "arpa" || strings.HasSuffix(n, ".arpa") {
|
||||
return "", false
|
||||
}
|
||||
suffix, icann := publicsuffix.PublicSuffix(n)
|
||||
if !icann || suffix == n {
|
||||
return "", false
|
||||
}
|
||||
d, err := publicsuffix.EffectiveTLDPlusOne(n)
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
return d, true
|
||||
}
|
||||
|
||||
// IsReverse reports whether name is in in-addr.arpa or ip6.arpa.
|
||||
func IsReverse(name string) bool {
|
||||
return IsSubdomain(name, "in-addr.arpa.") || IsSubdomain(name, "ip6.arpa.") || IsSubdomain(name, "ip6.int.")
|
||||
@@ -469,3 +492,47 @@ func BestZone(name string, zones []string) string {
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
// PrefixFromReverse returns the network a reverse zone answers for:
|
||||
// 10.0.10.in-addr.arpa. is 10.0.10.0/24, a /32 zone a single address, and
|
||||
// an ip6.arpa zone of 12 nibbles a /48. False for names that aren't whole
|
||||
// octets or nibbles (classless delegations such as 0-63.1.0.10.in-addr.arpa.).
|
||||
func PrefixFromReverse(zone string) (netip.Prefix, bool) {
|
||||
zone = strings.ToLower(Fqdn(zone))
|
||||
switch {
|
||||
case strings.HasSuffix(zone, ".in-addr.arpa."):
|
||||
labels := strings.Split(strings.TrimSuffix(zone, ".in-addr.arpa."), ".")
|
||||
if len(labels) > 4 {
|
||||
return netip.Prefix{}, false
|
||||
}
|
||||
var b [4]byte
|
||||
for i, l := range labels {
|
||||
v, err := strconv.Atoi(l)
|
||||
if err != nil || v < 0 || v > 255 || strconv.Itoa(v) != l {
|
||||
return netip.Prefix{}, false
|
||||
}
|
||||
b[len(labels)-1-i] = byte(v)
|
||||
}
|
||||
return netip.PrefixFrom(netip.AddrFrom4(b), 8*len(labels)), true
|
||||
case strings.HasSuffix(zone, ".ip6.arpa."):
|
||||
labels := strings.Split(strings.TrimSuffix(zone, ".ip6.arpa."), ".")
|
||||
if len(labels) > 32 {
|
||||
return netip.Prefix{}, false
|
||||
}
|
||||
var b [16]byte
|
||||
for i, l := range labels {
|
||||
v := strings.IndexByte("0123456789abcdef", l[0])
|
||||
if len(l) != 1 || v < 0 {
|
||||
return netip.Prefix{}, false
|
||||
}
|
||||
n := len(labels) - 1 - i // nibble position from the left
|
||||
if n%2 == 0 {
|
||||
b[n/2] |= byte(v) << 4
|
||||
} else {
|
||||
b[n/2] |= byte(v)
|
||||
}
|
||||
}
|
||||
return netip.PrefixFrom(netip.AddrFrom16(b), 4*len(labels)), true
|
||||
}
|
||||
return netip.Prefix{}, false
|
||||
}
|
||||
@@ -217,3 +217,55 @@ func TestReverseZones(t *testing.T) {
|
||||
t.Errorf("ReverseZone /22: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrefixFromReverse(t *testing.T) {
|
||||
for zone, want := range map[string]string{
|
||||
"10.0.10.in-addr.arpa.": "10.0.10.0/24",
|
||||
"10.in-addr.arpa": "10.0.0.0/8",
|
||||
"154.144.144.139.in-addr.arpa.": "139.144.144.154/32",
|
||||
"d.3.f.7.3.1.0.8.2.0.a.2.ip6.arpa.": "2a02:8013:7f3d::/48",
|
||||
"b.a.b.a.b.a.b.a.b.a.d.f.ip6.arpa.": "fdab:abab:abab::/48",
|
||||
"0-63.1.0.10.in-addr.arpa.": "",
|
||||
"example.com.": "",
|
||||
"256.10.in-addr.arpa.": "",
|
||||
} {
|
||||
p, ok := PrefixFromReverse(zone)
|
||||
got := ""
|
||||
if ok {
|
||||
got = p.String()
|
||||
}
|
||||
if got != want {
|
||||
t.Errorf("PrefixFromReverse(%q) = %q, want %q", zone, got, want)
|
||||
}
|
||||
}
|
||||
// Round trip with ReverseZone.
|
||||
for _, s := range []string{"192.168.1.0/24", "2a02:8013:7f3d::/48", "fd00::/8", "10.0.0.5/32"} {
|
||||
z, err := ReverseZone(netip.MustParsePrefix(s))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if p, ok := PrefixFromReverse(z); !ok || p.String() != s {
|
||||
t.Errorf("%s -> %s -> %v", s, z, p)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisteredDomain(t *testing.T) {
|
||||
for name, want := range map[string]string{
|
||||
"adbraun.lol.": "adbraun.lol",
|
||||
"lab.adbraun.lol.": "adbraun.lol",
|
||||
"Example.CO.UK": "example.co.uk",
|
||||
"co.uk.": "",
|
||||
"lol.": "",
|
||||
"10.0.10.in-addr.arpa.": "",
|
||||
"d.f.ip6.arpa.": "",
|
||||
"home.arpa.": "",
|
||||
"nas.home.lan.": "",
|
||||
"": "",
|
||||
} {
|
||||
got, ok := RegisteredDomain(name)
|
||||
if got != want || ok != (want != "") {
|
||||
t.Errorf("RegisteredDomain(%q) = %q, %v; want %q", name, got, ok, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,559 @@
|
||||
// Package encdns sets up DNS over TLS (DoT) and DNS over HTTPS (DoH) in
|
||||
// named (docs/encrypted-dns.md): a tls statement naming a certificate Jarl
|
||||
// manages, and listen-on statements for port 853 (DoT) and the DoH port.
|
||||
// Its state is read from named's configuration, so what the setup stages
|
||||
// stays ordinary, visible configuration.
|
||||
package encdns
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/netip"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"jarl/internal/engine"
|
||||
"jarl/internal/model"
|
||||
"jarl/internal/namedconf"
|
||||
)
|
||||
|
||||
// Prefix starts the names of the tls statements the setup writes.
|
||||
const Prefix = "jarl-"
|
||||
|
||||
// DoTPort is the port for DNS over TLS (RFC 7858).
|
||||
const DoTPort = 853
|
||||
|
||||
// Listener is a listen-on or listen-on-v6 statement.
|
||||
type Listener struct {
|
||||
OptionID int64 `json:"option_id"`
|
||||
V6 bool `json:"v6"`
|
||||
Port int `json:"port"` // as written; 0 when it isn't
|
||||
TLS string `json:"tls,omitempty"` // tls statement ("" for plain DNS)
|
||||
HTTP string `json:"http,omitempty"` // http endpoints: DNS over HTTPS
|
||||
Addrs []string `json:"addresses"` // the address match list
|
||||
}
|
||||
|
||||
// EffectivePort is the port named listens on: as written, or its
|
||||
// default for the kind of listener.
|
||||
func (l Listener) EffectivePort() int {
|
||||
switch {
|
||||
case l.Port != 0:
|
||||
return l.Port
|
||||
case l.HTTP != "" && l.TLS != "" && l.TLS != "none":
|
||||
return 443
|
||||
case l.HTTP != "":
|
||||
return 80
|
||||
case l.TLS != "":
|
||||
return DoTPort
|
||||
}
|
||||
return 53
|
||||
}
|
||||
|
||||
// Kind is plain, dot or doh.
|
||||
func (l Listener) Kind() string {
|
||||
switch {
|
||||
case l.HTTP != "":
|
||||
return "doh"
|
||||
case l.TLS != "" && l.TLS != "none":
|
||||
return "dot"
|
||||
}
|
||||
return "plain"
|
||||
}
|
||||
|
||||
// Managed reports whether the setup wrote the listener.
|
||||
func (l Listener) Managed() bool { return strings.HasPrefix(l.TLS, Prefix) }
|
||||
|
||||
// Listeners returns the server's listen-on and listen-on-v6 statements.
|
||||
func Listeners(st *model.State) []Listener {
|
||||
var out []Listener
|
||||
for _, o := range st.ScopedOptions(model.ScopeOptions, 0) {
|
||||
if o.Name != "listen-on" && o.Name != "listen-on-v6" {
|
||||
continue
|
||||
}
|
||||
l, err := parseListener(o.Name, o.Value)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
l.OptionID, l.V6 = o.ID, o.Name == "listen-on-v6"
|
||||
out = append(out, l)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func parseListener(name, value string) (Listener, error) {
|
||||
var l Listener
|
||||
stmt, err := namedconf.ParseOptionValue(name, value)
|
||||
if err != nil {
|
||||
return l, err
|
||||
}
|
||||
items := stmt.Items[1:]
|
||||
for i := 0; i < len(items); i++ {
|
||||
it := items[i]
|
||||
if it.Block != nil {
|
||||
for _, s := range it.Block.Stmts {
|
||||
l.Addrs = append(l.Addrs, namedconf.Compact(s.Items))
|
||||
}
|
||||
continue
|
||||
}
|
||||
if it.Value == nil || i+1 >= len(items) || items[i+1].Value == nil {
|
||||
continue
|
||||
}
|
||||
arg := items[i+1].Value.Text
|
||||
switch strings.ToLower(it.Value.Text) {
|
||||
case "port":
|
||||
l.Port, _ = strconv.Atoi(arg)
|
||||
case "tls":
|
||||
l.TLS = arg
|
||||
case "http":
|
||||
l.HTTP = arg
|
||||
}
|
||||
i++
|
||||
}
|
||||
return l, nil
|
||||
}
|
||||
|
||||
// TLSBlock is a top-level tls statement.
|
||||
type TLSBlock struct {
|
||||
OptionID int64 `json:"option_id"`
|
||||
Name string `json:"name"`
|
||||
CertFile string `json:"cert_file"`
|
||||
KeyFile string `json:"key_file"`
|
||||
}
|
||||
|
||||
// TLSBlocks returns the top-level tls statements.
|
||||
func TLSBlocks(st *model.State) []TLSBlock {
|
||||
var out []TLSBlock
|
||||
for _, o := range st.ScopedOptions(model.ScopeTopLevel, 0) {
|
||||
if o.Name != "tls" {
|
||||
continue
|
||||
}
|
||||
stmt, err := namedconf.ParseOptionValue("tls", o.Value)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
b := TLSBlock{OptionID: o.ID, Name: stmt.Arg(0)}
|
||||
if blk := stmt.FirstBlock(); blk != nil {
|
||||
for _, s := range blk.Stmts {
|
||||
switch s.Keyword() {
|
||||
case "cert-file":
|
||||
b.CertFile = s.Arg(0)
|
||||
case "key-file":
|
||||
b.KeyFile = s.Arg(0)
|
||||
}
|
||||
}
|
||||
}
|
||||
out = append(out, b)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Endpoint is an address and port named serves DoT or DoH on.
|
||||
type Endpoint struct {
|
||||
Address string `json:"address"`
|
||||
Port int `json:"port"`
|
||||
}
|
||||
|
||||
// Status is what is set up.
|
||||
type Status struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
// TLS is the tls statement the setup wrote, and Certificate the
|
||||
// certificate it names (a Jarl certificate's name, when it is one).
|
||||
TLS *TLSBlock `json:"tls,omitempty"`
|
||||
Certificate string `json:"certificate,omitempty"`
|
||||
DoT []Endpoint `json:"dot"`
|
||||
DoH []Endpoint `json:"doh"`
|
||||
// Listeners are all listen-on statements, the setup's and others.
|
||||
Listeners []Listener `json:"listeners"`
|
||||
// Other are DoT or DoH listeners written by hand.
|
||||
Other bool `json:"other,omitempty"`
|
||||
}
|
||||
|
||||
// GetStatus reads the setup from the configuration. certDir is where Jarl
|
||||
// writes certificates for named (certificates.dir).
|
||||
func GetStatus(st *model.State, certDir string) Status {
|
||||
s := Status{Listeners: Listeners(st), DoT: []Endpoint{}, DoH: []Endpoint{}}
|
||||
if s.Listeners == nil {
|
||||
s.Listeners = []Listener{}
|
||||
}
|
||||
for _, b := range TLSBlocks(st) {
|
||||
if strings.HasPrefix(b.Name, Prefix) {
|
||||
b := b
|
||||
s.TLS = &b
|
||||
if dir := filepath.Dir(b.CertFile); filepath.Dir(dir) == filepath.Clean(certDir) {
|
||||
s.Certificate = filepath.Base(dir)
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
for _, l := range s.Listeners {
|
||||
if l.Kind() == "plain" {
|
||||
continue
|
||||
}
|
||||
if !l.Managed() {
|
||||
s.Other = true
|
||||
continue
|
||||
}
|
||||
for _, a := range l.Addrs {
|
||||
e := Endpoint{Address: a, Port: l.EffectivePort()}
|
||||
if l.Kind() == "doh" {
|
||||
s.DoH = append(s.DoH, e)
|
||||
} else {
|
||||
s.DoT = append(s.DoT, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
s.Enabled = len(s.DoT)+len(s.DoH) > 0
|
||||
return s
|
||||
}
|
||||
|
||||
// Setup is what to set up.
|
||||
type Setup struct {
|
||||
// Hostnames are the names clients connect to; the certificate must be
|
||||
// for all of them. Hostname (one) works too.
|
||||
Hostnames []string `json:"hostnames,omitempty"`
|
||||
Hostname string `json:"hostname,omitempty"`
|
||||
// Certificate is the Jarl certificate (with the dns use) to serve.
|
||||
Certificate string `json:"certificate"`
|
||||
// Addresses are the addresses of this server to listen on.
|
||||
Addresses []string `json:"addresses"`
|
||||
DoT bool `json:"dot"`
|
||||
DoH bool `json:"doh"`
|
||||
DoHPort int `json:"doh_port"` // default 443
|
||||
DoTPort int `json:"dot_port"` // default 853 (clients expect it)
|
||||
// AddressViews says, per listen address, the views where the host
|
||||
// names get it (A or AAAA): a private address shouldn't reach a public
|
||||
// view. Views (every address in each) works too. The records are local
|
||||
// to their view in a linked zone.
|
||||
AddressViews map[string][]string `json:"address_views,omitempty"`
|
||||
Views []string `json:"views,omitempty"`
|
||||
// PublicViews are the views the internet reaches (filled in by jarld,
|
||||
// for a warning).
|
||||
PublicViews map[string]bool `json:"-"`
|
||||
}
|
||||
|
||||
// Hosts returns the setup's host names, normalized, the first first.
|
||||
func (s Setup) Hosts() []string {
|
||||
var out []string
|
||||
for _, h := range append(append([]string{}, s.Hostnames...), s.Hostname) {
|
||||
h = strings.ToLower(strings.TrimSuffix(strings.TrimSpace(h), "."))
|
||||
if h != "" && !slices.Contains(out, h) {
|
||||
out = append(out, h)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Plan returns the requests that set up encrypted DNS as s says,
|
||||
// replacing an earlier setup, and notes for the user. certDir is where
|
||||
// the certificate's files are.
|
||||
func Plan(st *model.State, s Setup, certDir string) ([]engine.Request, []string, error) {
|
||||
var notes []string
|
||||
if !s.DoT && !s.DoH {
|
||||
return nil, nil, fmt.Errorf("choose DNS over TLS, DNS over HTTPS or both")
|
||||
}
|
||||
if s.DoHPort == 0 {
|
||||
s.DoHPort = 443
|
||||
}
|
||||
if s.DoTPort == 0 {
|
||||
s.DoTPort = DoTPort
|
||||
}
|
||||
for _, p := range []int{s.DoHPort, s.DoTPort} {
|
||||
if p < 1 || p > 65535 || p == 53 {
|
||||
return nil, nil, fmt.Errorf("port %d: use a free port other than 53", p)
|
||||
}
|
||||
}
|
||||
if s.DoT && s.DoH && s.DoHPort == s.DoTPort {
|
||||
return nil, nil, fmt.Errorf("DoT and DoH need different ports (both are %d)", s.DoHPort)
|
||||
}
|
||||
if s.Certificate == "" {
|
||||
return nil, nil, fmt.Errorf("choose the certificate to serve")
|
||||
}
|
||||
hosts := s.Hosts()
|
||||
if len(hosts) == 0 {
|
||||
return nil, nil, fmt.Errorf("give the host name clients connect to, e.g. dns.example.com")
|
||||
}
|
||||
for _, host := range hosts {
|
||||
if !strings.Contains(host, ".") || strings.HasPrefix(host, "*") {
|
||||
return nil, nil, fmt.Errorf("%q isn't a host name clients can connect to, e.g. dns.example.com", host)
|
||||
}
|
||||
}
|
||||
var v4, v6 []string
|
||||
for _, a := range s.Addresses {
|
||||
ip, err := netip.ParseAddr(strings.TrimSpace(a))
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("%q isn't an address of this server", a)
|
||||
}
|
||||
ip = ip.Unmap()
|
||||
if ip.IsUnspecified() {
|
||||
return nil, nil, fmt.Errorf("listen on this server's addresses, not on %s (that would include public ones)", ip)
|
||||
}
|
||||
if ip.Is4() {
|
||||
v4 = append(v4, ip.String())
|
||||
} else {
|
||||
v6 = append(v6, ip.String())
|
||||
}
|
||||
}
|
||||
if len(v4)+len(v6) == 0 {
|
||||
return nil, nil, fmt.Errorf("choose at least one address to listen on")
|
||||
}
|
||||
tlsName := Prefix + s.Certificate
|
||||
tlsValue := TLSValue(s.Certificate, certDir)
|
||||
|
||||
var reqs []engine.Request
|
||||
// Take out the earlier setup's listeners and tls statements.
|
||||
current := Listeners(st)
|
||||
for _, l := range current {
|
||||
if l.Managed() {
|
||||
reqs = append(reqs, engine.NewRequest(engine.OpOptionDelete, l.OptionID, nil))
|
||||
}
|
||||
}
|
||||
haveTLS := false
|
||||
for _, b := range TLSBlocks(st) {
|
||||
switch {
|
||||
case b.Name == tlsName:
|
||||
haveTLS = true
|
||||
reqs = append(reqs, engine.NewRequest(engine.OpOptionUpdate, b.OptionID, engine.OptionInput{Value: model.Ptr(tlsValue)}))
|
||||
case strings.HasPrefix(b.Name, Prefix):
|
||||
reqs = append(reqs, engine.NewRequest(engine.OpOptionDelete, b.OptionID, nil))
|
||||
}
|
||||
}
|
||||
if !haveTLS {
|
||||
reqs = append(reqs, option(model.ScopeTopLevel, "tls", tlsValue))
|
||||
}
|
||||
// named listens on port 53 everywhere only while there is no
|
||||
// listen-on statement at all: adding the first one for DoT would end
|
||||
// that, so keep it explicitly.
|
||||
plain := func(v6 bool) bool {
|
||||
for _, l := range current {
|
||||
if l.V6 == v6 && !l.Managed() && l.Kind() == "plain" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
add := func(name string, addrs []string) {
|
||||
if len(addrs) == 0 {
|
||||
return
|
||||
}
|
||||
list := "{ " + strings.Join(addrs, "; ") + "; }"
|
||||
if !plain(name == "listen-on-v6") {
|
||||
reqs = append(reqs, option(model.ScopeOptions, name, "{ any; }"))
|
||||
notes = append(notes, fmt.Sprintf("named listened on port 53 on every address by default; %s { any; } keeps that, as the first %s statement would otherwise end it", name, name))
|
||||
}
|
||||
if s.DoT {
|
||||
reqs = append(reqs, option(model.ScopeOptions, name, fmt.Sprintf("port %d tls %s %s", s.DoTPort, tlsName, list)))
|
||||
}
|
||||
if s.DoH {
|
||||
reqs = append(reqs, option(model.ScopeOptions, name, fmt.Sprintf("port %d tls %s http default %s", s.DoHPort, tlsName, list)))
|
||||
}
|
||||
}
|
||||
add("listen-on", v4)
|
||||
add("listen-on-v6", v6)
|
||||
|
||||
// The host names' addresses, in the views asked for, per address.
|
||||
want := map[string][]string{} // view -> addresses
|
||||
all := append(append([]string{}, v4...), v6...)
|
||||
if s.AddressViews != nil {
|
||||
for a, vs := range s.AddressViews {
|
||||
ip, err := netip.ParseAddr(strings.TrimSpace(a))
|
||||
if err != nil || !slices.Contains(all, ip.Unmap().String()) {
|
||||
return nil, nil, fmt.Errorf("address_views: %s isn't one of the addresses to listen on", a)
|
||||
}
|
||||
for _, vn := range vs {
|
||||
want[vn] = append(want[vn], ip.Unmap().String())
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for _, vn := range s.Views {
|
||||
want[vn] = all
|
||||
}
|
||||
}
|
||||
for vn, addrs := range want {
|
||||
if st.ViewByName(vn) == nil {
|
||||
return nil, nil, fmt.Errorf("view %q does not exist", vn)
|
||||
}
|
||||
if s.PublicViews[vn] {
|
||||
for _, a := range addrs {
|
||||
if ip, err := netip.ParseAddr(a); err == nil && Private(ip) {
|
||||
notes = append(notes, fmt.Sprintf("view %s is reachable from the internet, but gets the private address %s for %s: its clients can't reach that", vn, a, strings.Join(hosts, ", ")))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Ours are the addresses this setup (or the one before) gives the host
|
||||
// names: a view that no longer gets one loses it; other records of the
|
||||
// names stay.
|
||||
ours := map[string]bool{}
|
||||
for _, a := range all {
|
||||
ours[a] = true
|
||||
}
|
||||
for _, l := range current {
|
||||
if l.Managed() {
|
||||
for _, a := range l.Addrs {
|
||||
ours[a] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
names := slices.Clone(hosts)
|
||||
for _, h := range st.Settings.EncryptedDNSHosts {
|
||||
if !slices.Contains(names, h) {
|
||||
names = append(names, h)
|
||||
}
|
||||
}
|
||||
for _, v := range st.OrderedViews() {
|
||||
for _, host := range names {
|
||||
fqdn := host + "."
|
||||
z := zoneFor(st, fqdn, v.ID)
|
||||
wanted := want[v.Name]
|
||||
if !slices.Contains(hosts, host) {
|
||||
wanted = nil // a host name no longer used
|
||||
}
|
||||
if z == nil {
|
||||
if len(wanted) > 0 {
|
||||
notes = append(notes, fmt.Sprintf("view %s has no zone for %s, so no address records were added there; clients must find it some other way", v.Name, host))
|
||||
}
|
||||
continue
|
||||
}
|
||||
for _, typ := range []string{"A", "AAAA"} {
|
||||
var have, next []string
|
||||
for _, r := range st.RRset(z.ID, fqdn, typ) {
|
||||
have = append(have, r.Data)
|
||||
if !ours[r.Data] {
|
||||
next = append(next, r.Data)
|
||||
}
|
||||
}
|
||||
for _, a := range wanted {
|
||||
if strings.Contains(a, ":") == (typ == "AAAA") && !slices.Contains(next, a) {
|
||||
next = append(next, a)
|
||||
}
|
||||
}
|
||||
slices.Sort(have)
|
||||
slices.Sort(next)
|
||||
if slices.Equal(have, next) {
|
||||
continue
|
||||
}
|
||||
if next == nil {
|
||||
next = []string{}
|
||||
}
|
||||
reqs = append(reqs, engine.NewRequest(engine.OpRRSetReplace, 0, engine.RRSetInput{ZoneID: z.ID, Name: fqdn, Type: typ, Data: next, Local: model.Ptr(true)}))
|
||||
}
|
||||
}
|
||||
}
|
||||
if !slices.Equal(st.Settings.EncryptedDNSHosts, hosts) {
|
||||
reqs = append(reqs, engine.NewRequest(engine.OpSettingsUpdate, 0, engine.SettingsInput{EncryptedDNSHosts: &hosts}))
|
||||
}
|
||||
return reqs, notes, nil
|
||||
}
|
||||
|
||||
func option(scope, name, value string) engine.Request {
|
||||
return engine.NewRequest(engine.OpOptionCreate, 0, engine.OptionInput{Scope: model.Ptr(scope), Name: model.Ptr(name), Value: model.Ptr(value)})
|
||||
}
|
||||
|
||||
// zoneFor returns the primary zone in a view that holds name.
|
||||
func zoneFor(st *model.State, name string, viewID int64) *model.Zone {
|
||||
var best *model.Zone
|
||||
for _, z := range st.ZonesInView(viewID) {
|
||||
if z.Type == model.ZonePrimary && (name == z.Name || strings.HasSuffix(name, "."+z.Name)) && (best == nil || len(z.Name) > len(best.Name)) {
|
||||
best = z
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
// CurrentAddressViews returns, per address, the views whose records give
|
||||
// one of the host names that address (what the setup form starts from).
|
||||
func CurrentAddressViews(st *model.State, hosts []string) map[string][]string {
|
||||
out := map[string][]string{}
|
||||
for _, v := range st.OrderedViews() {
|
||||
for _, host := range hosts {
|
||||
fqdn := strings.TrimSuffix(host, ".") + "."
|
||||
z := zoneFor(st, fqdn, v.ID)
|
||||
if z == nil {
|
||||
continue
|
||||
}
|
||||
for _, typ := range []string{"A", "AAAA"} {
|
||||
for _, r := range st.RRset(z.ID, fqdn, typ) {
|
||||
if !r.Disabled && !slices.Contains(out[r.Data], v.Name) {
|
||||
out[r.Data] = append(out[r.Data], v.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// CertUses says what of named's configuration uses the certificate whose
|
||||
// files are in dir: "DNS over TLS on 10.0.10.5, fd00::5 port 853", or the
|
||||
// tls statement alone when no listener uses it.
|
||||
func CertUses(st *model.State, dir string) []string {
|
||||
var out []string
|
||||
for _, b := range TLSBlocks(st) {
|
||||
if filepath.Dir(b.CertFile) != filepath.Clean(dir) {
|
||||
continue
|
||||
}
|
||||
used := false
|
||||
byKind := map[string]map[int][]string{}
|
||||
for _, l := range Listeners(st) {
|
||||
if l.TLS != b.Name {
|
||||
continue
|
||||
}
|
||||
used = true
|
||||
k := l.Kind()
|
||||
if byKind[k] == nil {
|
||||
byKind[k] = map[int][]string{}
|
||||
}
|
||||
byKind[k][l.EffectivePort()] = append(byKind[k][l.EffectivePort()], l.Addrs...)
|
||||
}
|
||||
for _, k := range []string{"dot", "doh"} {
|
||||
label := map[string]string{"dot": "DNS over TLS", "doh": "DNS over HTTPS"}[k]
|
||||
ports := make([]int, 0, len(byKind[k]))
|
||||
for p := range byKind[k] {
|
||||
ports = append(ports, p)
|
||||
}
|
||||
slices.Sort(ports)
|
||||
for _, p := range ports {
|
||||
out = append(out, fmt.Sprintf("%s on %s port %d", label, strings.Join(byKind[k][p], ", "), p))
|
||||
}
|
||||
}
|
||||
if !used {
|
||||
out = append(out, "tls statement "+b.Name+" (no listener uses it)")
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Disable returns the requests that take the setup out: its listeners and
|
||||
// tls statement. Plain listeners and the host name's records stay.
|
||||
func Disable(st *model.State) []engine.Request {
|
||||
var reqs []engine.Request
|
||||
for _, l := range Listeners(st) {
|
||||
if l.Managed() {
|
||||
reqs = append(reqs, engine.NewRequest(engine.OpOptionDelete, l.OptionID, nil))
|
||||
}
|
||||
}
|
||||
for _, b := range TLSBlocks(st) {
|
||||
if strings.HasPrefix(b.Name, Prefix) {
|
||||
reqs = append(reqs, engine.NewRequest(engine.OpOptionDelete, b.OptionID, nil))
|
||||
}
|
||||
}
|
||||
if len(reqs) > 0 && len(st.Settings.EncryptedDNSHosts) > 0 {
|
||||
reqs = append(reqs, engine.NewRequest(engine.OpSettingsUpdate, 0, engine.SettingsInput{EncryptedDNSHosts: &[]string{}}))
|
||||
}
|
||||
return reqs
|
||||
}
|
||||
|
||||
// Private reports whether an address is private (RFC 1918, ULA, ...).
|
||||
func Private(a netip.Addr) bool {
|
||||
a = a.Unmap()
|
||||
return a.IsPrivate() || a.IsLoopback() || a.IsLinkLocalUnicast() || netip.MustParsePrefix("100.64.0.0/10").Contains(a)
|
||||
}
|
||||
|
||||
// TLSValue is the tls statement the setup writes for a certificate whose
|
||||
// files are in certDir/NAME.
|
||||
func TLSValue(cert, certDir string) string {
|
||||
dir := filepath.Join(certDir, cert)
|
||||
return fmt.Sprintf(`%s { cert-file "%s"; key-file "%s"; protocols { TLSv1.2; TLSv1.3; }; }`,
|
||||
Prefix+cert, filepath.Join(dir, "fullchain.pem"), filepath.Join(dir, "privkey.pem"))
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
package encdns
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/netip"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"jarl/internal/auth"
|
||||
"jarl/internal/engine"
|
||||
"jarl/internal/model"
|
||||
"jarl/internal/render"
|
||||
"jarl/internal/store"
|
||||
)
|
||||
|
||||
type env struct {
|
||||
t *testing.T
|
||||
eng *engine.Engine
|
||||
st *store.Store
|
||||
certDir string
|
||||
}
|
||||
|
||||
func newEnv(t *testing.T) *env {
|
||||
dir := t.TempDir()
|
||||
st, err := store.Open(filepath.Join(dir, "jarl.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { st.Close() })
|
||||
eng := engine.New(st, nil, engine.Config{
|
||||
Paths: render.Paths{ConfigDir: filepath.Join(dir, "etc"), ZoneDir: filepath.Join(dir, "zones"), CacheDir: filepath.Join(dir, "cache")},
|
||||
StagingDir: filepath.Join(dir, "staging"), NoReload: true, SkipCheck: true,
|
||||
}, slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||
e := &env{t: t, eng: eng, st: st, certDir: filepath.Join(dir, "tls")}
|
||||
os.MkdirAll(filepath.Join(e.certDir, "dns"), 0o755)
|
||||
os.WriteFile(filepath.Join(e.certDir, "dns", "fullchain.pem"), []byte("x"), 0o644)
|
||||
os.WriteFile(filepath.Join(e.certDir, "dns", "privkey.pem"), []byte("x"), 0o600)
|
||||
e.submit(engine.NewRequest(engine.OpSettingsUpdate, 0, engine.SettingsInput{Nameservers: &[]string{"ns1.example.net."}}),
|
||||
engine.NewRequest(engine.OpViewCreate, 0, engine.ViewInput{Name: model.Ptr("internal"), MatchClients: &[]string{"10.0.0.0/8"}}),
|
||||
engine.NewRequest(engine.OpViewCreate, 0, engine.ViewInput{Name: model.Ptr("external"), MatchClients: &[]string{"any"}}))
|
||||
e.submit(engine.NewRequest(engine.OpZoneCreate, 0, engine.ZoneInput{Name: model.Ptr("example.com"), View: model.Ptr("internal")}))
|
||||
return e
|
||||
}
|
||||
|
||||
func (e *env) submit(reqs ...engine.Request) *engine.Result {
|
||||
e.t.Helper()
|
||||
res, err := e.eng.Submit(context.Background(), reqs, auth.System, "test", "", engine.ApplyOptions{})
|
||||
if err != nil {
|
||||
e.t.Fatalf("%v (%+v)", err, res)
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
func (e *env) state() *model.State {
|
||||
st, err := e.st.LoadState(context.Background())
|
||||
if err != nil {
|
||||
e.t.Fatal(err)
|
||||
}
|
||||
return st
|
||||
}
|
||||
|
||||
func values(st *model.State, scope, name string) []string {
|
||||
var out []string
|
||||
for _, o := range st.ScopedOptions(scope, 0) {
|
||||
if o.Name == name {
|
||||
out = append(out, o.Value)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestSetup(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
setup := Setup{Hostname: "dns.example.com", Certificate: "dns", Addresses: []string{"10.0.10.5", "2001:db8::5"}, DoT: true, DoH: true, Views: []string{"internal", "external"}}
|
||||
reqs, notes, err := Plan(e.state(), setup, e.certDir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
e.submit(reqs...)
|
||||
st := e.state()
|
||||
tls := values(st, model.ScopeTopLevel, "tls")
|
||||
if len(tls) != 1 || !strings.HasPrefix(tls[0], "jarl-dns {") || !strings.Contains(tls[0], filepath.Join(e.certDir, "dns", "fullchain.pem")) {
|
||||
t.Errorf("tls = %q", tls)
|
||||
}
|
||||
// The plain listeners named had by default are kept explicitly.
|
||||
want4 := []string{"{ any; }", "port 853 tls jarl-dns { 10.0.10.5; }", "port 443 tls jarl-dns http default { 10.0.10.5; }"}
|
||||
if got := values(st, model.ScopeOptions, "listen-on"); !slices.Equal(got, want4) {
|
||||
t.Errorf("listen-on = %q, want %q", got, want4)
|
||||
}
|
||||
if got := values(st, model.ScopeOptions, "listen-on-v6"); len(got) != 3 || got[0] != "{ any; }" {
|
||||
t.Errorf("listen-on-v6 = %q", got)
|
||||
}
|
||||
if len(notes) != 3 || !strings.Contains(strings.Join(notes, " "), "external has no zone") {
|
||||
t.Errorf("notes = %q", notes)
|
||||
}
|
||||
// The host name's addresses in the internal view.
|
||||
z := st.FindZone("example.com.", st.ViewByName("internal").ID)
|
||||
var addrs []string
|
||||
for _, r := range st.ZoneRecords(z.ID) {
|
||||
if r.Name == "dns.example.com." {
|
||||
addrs = append(addrs, r.Type+" "+r.Data)
|
||||
}
|
||||
}
|
||||
if !slices.Equal(addrs, []string{"A 10.0.10.5", "AAAA 2001:db8::5"}) {
|
||||
t.Errorf("records = %q", addrs)
|
||||
}
|
||||
s := GetStatus(st, e.certDir)
|
||||
if !s.Enabled || s.Certificate != "dns" || len(s.DoT) != 2 || len(s.DoH) != 2 || s.DoH[0].Port != 443 || s.Other {
|
||||
t.Errorf("status = %+v", s)
|
||||
}
|
||||
|
||||
// Setting up again replaces the setup: DoT only, one address, and the
|
||||
// plain listeners aren't doubled.
|
||||
reqs, _, err = Plan(st, Setup{Hostname: "dns.example.com", Certificate: "dns", Addresses: []string{"10.0.10.5"}, DoT: true}, e.certDir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
e.submit(reqs...)
|
||||
st = e.state()
|
||||
if got := values(st, model.ScopeOptions, "listen-on"); !slices.Equal(got, []string{"{ any; }", "port 853 tls jarl-dns { 10.0.10.5; }"}) {
|
||||
t.Errorf("after the second setup: %q", got)
|
||||
}
|
||||
if got := values(st, model.ScopeOptions, "listen-on-v6"); !slices.Equal(got, []string{"{ any; }"}) {
|
||||
t.Errorf("listen-on-v6 after the second setup: %q", got)
|
||||
}
|
||||
if got := values(st, model.ScopeTopLevel, "tls"); len(got) != 1 {
|
||||
t.Errorf("tls statements: %q", got)
|
||||
}
|
||||
|
||||
// Off: the setup's listeners and tls statement go, the rest stays.
|
||||
e.submit(Disable(st)...)
|
||||
st = e.state()
|
||||
if got := values(st, model.ScopeOptions, "listen-on"); !slices.Equal(got, []string{"{ any; }"}) || len(values(st, model.ScopeTopLevel, "tls")) != 0 || GetStatus(st, e.certDir).Enabled {
|
||||
t.Errorf("after disabling: listen-on %q tls %q", got, values(st, model.ScopeTopLevel, "tls"))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetupRefuses(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
st := e.state()
|
||||
for _, s := range []Setup{
|
||||
{Hostname: "dns.example.com", Certificate: "dns", Addresses: []string{"10.0.10.5"}}, // neither DoT nor DoH
|
||||
{Hostname: "dns.example.com", Certificate: "dns", Addresses: []string{"0.0.0.0"}, DoT: true}, // every address
|
||||
{Hostname: "dns.example.com", Certificate: "dns", DoT: true}, // no address
|
||||
{Hostname: "dns.example.com", Certificate: "dns", Addresses: []string{"10.0.10.5"}, DoH: true, DoHPort: 53}, // port 53
|
||||
{Hostname: "dns", Certificate: "dns", Addresses: []string{"10.0.10.5"}, DoT: true}, // not a host name
|
||||
} {
|
||||
if _, _, err := Plan(st, s, e.certDir); err == nil {
|
||||
t.Errorf("accepted %+v", s)
|
||||
}
|
||||
}
|
||||
// A tls statement whose files are missing is refused by validation.
|
||||
_, err := e.eng.Submit(context.Background(), []engine.Request{engine.NewRequest(engine.OpOptionCreate, 0, engine.OptionInput{
|
||||
Scope: model.Ptr(model.ScopeTopLevel), Name: model.Ptr("tls"), Value: model.Ptr(`x { cert-file "/nonexistent/c.pem"; key-file "/nonexistent/k.pem"; }`)})},
|
||||
auth.System, "test", "", engine.ApplyOptions{})
|
||||
if err == nil || !strings.Contains(err.Error(), "doesn't exist") {
|
||||
t.Errorf("missing files: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPorts(t *testing.T) {
|
||||
s, err := parseProcAddr("0100007F:0035")
|
||||
if err != nil || s.Addr != netip.MustParseAddr("127.0.0.1") || s.Port != 53 {
|
||||
t.Errorf("v4: %+v %v", s, err)
|
||||
}
|
||||
s, err = parseProcAddr("00000000000000000000000001000000:01BB")
|
||||
if err != nil || s.Addr != netip.MustParseAddr("::1") || s.Port != 443 {
|
||||
t.Errorf("v6: %+v %v", s, err)
|
||||
}
|
||||
e := newEnv(t)
|
||||
old := ListeningSockets
|
||||
t.Cleanup(func() { ListeningSockets = old })
|
||||
ListeningSockets = func() ([]Socket, error) {
|
||||
return []Socket{{netip.MustParseAddr("0.0.0.0"), 443}, {netip.MustParseAddr("10.0.10.5"), 8443}, {netip.MustParseAddr("::"), 853}}, nil
|
||||
}
|
||||
c, err := PortCheck(e.state(), []string{"10.0.10.5"}, []int{853, 443, 8443}, []string{"0.0.0.0:443"})
|
||||
if err != nil || len(c) != 3 || c[1].What != "jarld (Jarl's web UI and API)" || c[2].What != "another program" {
|
||||
t.Errorf("conflicts = %+v %v", c, err)
|
||||
}
|
||||
if p := FreeDoHPort(e.state(), []string{"10.0.10.5"}, nil); p != 4443 {
|
||||
t.Errorf("free DoH port = %d, want 4443", p)
|
||||
}
|
||||
}
|
||||
|
||||
// Each address gets its own views; a view no longer given loses it, and
|
||||
// records of the host names that aren't the setup's stay.
|
||||
func TestSetupAddressViews(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
e.submit(engine.NewRequest(engine.OpZoneCreate, 0, engine.ZoneInput{Name: model.Ptr("example.com"), View: model.Ptr("external"), LinkWith: "internal"}))
|
||||
e.submit(engine.NewRequest(engine.OpRRSetReplace, 0, engine.RRSetInput{Zone: "example.com", View: "external", Name: "doh.example.com.", Type: "A", Data: []string{"198.51.100.9"}, Local: model.Ptr(true)}))
|
||||
hosts := func(st *model.State, view string) []string {
|
||||
z := st.FindZone("example.com.", st.ViewByName(view).ID)
|
||||
var out []string
|
||||
for _, r := range st.ZoneRecords(z.ID) {
|
||||
if strings.HasPrefix(r.Name, "dns.") || strings.HasPrefix(r.Name, "doh.") {
|
||||
out = append(out, strings.SplitN(r.Name, ".", 2)[0]+" "+r.Type+" "+r.Data)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
setup := Setup{Hostnames: []string{"dns.example.com", "doh.example.com"}, Certificate: "dns", DoT: true, DoH: true,
|
||||
Addresses: []string{"10.0.10.5", "203.0.113.5", "10.0.10.6"},
|
||||
AddressViews: map[string][]string{"10.0.10.5": {"internal"}, "203.0.113.5": {"external"}, "10.0.10.6": {"external"}},
|
||||
PublicViews: map[string]bool{"external": true}}
|
||||
reqs, notes, err := Plan(e.state(), setup, e.certDir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !slices.ContainsFunc(notes, func(n string) bool {
|
||||
return strings.Contains(n, "view external is reachable from the internet, but gets the private address 10.0.10.6")
|
||||
}) {
|
||||
t.Errorf("notes %q", notes)
|
||||
}
|
||||
e.submit(reqs...)
|
||||
st := e.state()
|
||||
if got, want := hosts(st, "internal"), []string{"dns A 10.0.10.5", "doh A 10.0.10.5"}; !slices.Equal(got, want) {
|
||||
t.Errorf("internal %q, want %q", got, want)
|
||||
}
|
||||
// The external doh record that isn't the setup's stays.
|
||||
if got, want := hosts(st, "external"), []string{"dns A 10.0.10.6", "dns A 203.0.113.5", "doh A 10.0.10.6", "doh A 198.51.100.9", "doh A 203.0.113.5"}; !slices.Equal(got, want) {
|
||||
t.Errorf("external %q, want %q", got, want)
|
||||
}
|
||||
if !slices.Equal(st.Settings.EncryptedDNSHosts, []string{"dns.example.com", "doh.example.com"}) {
|
||||
t.Errorf("hosts %q", st.Settings.EncryptedDNSHosts)
|
||||
}
|
||||
av := CurrentAddressViews(st, st.Settings.EncryptedDNSHosts)
|
||||
if !slices.Equal(av["10.0.10.5"], []string{"internal"}) || !slices.Equal(av["10.0.10.6"], []string{"external"}) {
|
||||
t.Errorf("address views %v", av)
|
||||
}
|
||||
uses := CertUses(st, filepath.Join(e.certDir, "dns"))
|
||||
if len(uses) != 2 || !strings.HasPrefix(uses[0], "DNS over TLS on 10.0.10.5, 203.0.113.5, 10.0.10.6 port 853") || !strings.HasPrefix(uses[1], "DNS over HTTPS on") {
|
||||
t.Errorf("cert uses %q", uses)
|
||||
}
|
||||
|
||||
// 10.0.10.6 no longer in external, doh.example.com no longer a host name.
|
||||
setup = Setup{Hostname: "dns.example.com", Certificate: "dns", DoT: true, Addresses: []string{"10.0.10.5", "203.0.113.5", "10.0.10.6"},
|
||||
AddressViews: map[string][]string{"10.0.10.5": {"internal"}, "203.0.113.5": {"external"}}}
|
||||
reqs, _, err = Plan(st, setup, e.certDir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
e.submit(reqs...)
|
||||
st = e.state()
|
||||
if got, want := hosts(st, "external"), []string{"dns A 203.0.113.5", "doh A 198.51.100.9"}; !slices.Equal(got, want) {
|
||||
t.Errorf("external after %q, want %q", got, want)
|
||||
}
|
||||
if got, want := hosts(st, "internal"), []string{"dns A 10.0.10.5"}; !slices.Equal(got, want) {
|
||||
t.Errorf("internal after %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
package encdns
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/miekg/dns"
|
||||
)
|
||||
|
||||
// Probe is the outcome of checking one DoT or DoH listener the way a
|
||||
// client would: connect, check the certificate, ask a question.
|
||||
type Probe struct {
|
||||
Kind string `json:"kind"` // dot or doh
|
||||
Address string `json:"address"`
|
||||
Port int `json:"port"`
|
||||
// OK: it answered over TLS with a certificate for the host name that
|
||||
// is current. Problem says what is wrong when not; Warning notes
|
||||
// something that doesn't stop clients from working everywhere.
|
||||
OK bool `json:"ok"`
|
||||
Problem string `json:"problem,omitempty"`
|
||||
Warning string `json:"warning,omitempty"`
|
||||
// What the certificate named served says.
|
||||
CertNames []string `json:"cert_names,omitempty"`
|
||||
CertNotAfter *time.Time `json:"cert_not_after,omitempty"`
|
||||
CertSerial string `json:"cert_serial,omitempty"`
|
||||
// Trusted: it chains to a CA this server trusts (not so for a
|
||||
// staging or test CA).
|
||||
Trusted bool `json:"trusted"`
|
||||
RCode string `json:"rcode,omitempty"`
|
||||
Millis int64 `json:"ms"`
|
||||
}
|
||||
|
||||
// Health is the last check of every listener.
|
||||
type Health struct {
|
||||
Checked time.Time `json:"checked"`
|
||||
Probes []Probe `json:"probes"`
|
||||
}
|
||||
|
||||
// OK reports whether every listener works.
|
||||
func (h *Health) OK() bool {
|
||||
for _, p := range h.Probes {
|
||||
if !p.OK {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// Check probes every DoT and DoH listener of the setup. hostname is what
|
||||
// clients ask for, serial the certificate Jarl last wrote for named ("" if
|
||||
// unknown), roots the CAs to trust (nil: the system's).
|
||||
func Check(ctx context.Context, s Status, hostname, serial string, roots *x509.CertPool) *Health {
|
||||
h := &Health{Checked: time.Now(), Probes: []Probe{}}
|
||||
for _, e := range s.DoT {
|
||||
h.Probes = append(h.Probes, probe(ctx, "dot", e, hostname, serial, roots))
|
||||
}
|
||||
for _, e := range s.DoH {
|
||||
h.Probes = append(h.Probes, probe(ctx, "doh", e, hostname, serial, roots))
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
const probeTimeout = 5 * time.Second
|
||||
|
||||
func probe(ctx context.Context, kind string, e Endpoint, hostname, serial string, roots *x509.CertPool) Probe {
|
||||
p := Probe{Kind: kind, Address: e.Address, Port: e.Port}
|
||||
addr := net.JoinHostPort(e.Address, strconv.Itoa(e.Port))
|
||||
ctx, cancel := context.WithTimeout(ctx, probeTimeout)
|
||||
defer cancel()
|
||||
start := time.Now()
|
||||
q := new(dns.Msg)
|
||||
q.SetQuestion(dns.Fqdn(hostname), dns.TypeA)
|
||||
q.RecursionDesired = true
|
||||
var state tls.ConnectionState
|
||||
var answer *dns.Msg
|
||||
var err error
|
||||
cfg := &tls.Config{ServerName: hostname, InsecureSkipVerify: true, MinVersion: tls.VersionTLS12} // checked below, to say what is wrong
|
||||
if kind == "dot" {
|
||||
cfg.NextProtos = []string{"dot"}
|
||||
state, answer, err = askDoT(ctx, addr, cfg, q)
|
||||
} else {
|
||||
state, answer, err = askDoH(ctx, addr, hostname, e.Port, cfg, q)
|
||||
}
|
||||
p.Millis = time.Since(start).Milliseconds()
|
||||
if len(state.PeerCertificates) > 0 {
|
||||
leaf := state.PeerCertificates[0]
|
||||
p.CertNames, p.CertSerial = leaf.DNSNames, fmt.Sprintf("%x", leaf.SerialNumber)
|
||||
na := leaf.NotAfter
|
||||
p.CertNotAfter = &na
|
||||
inter := x509.NewCertPool()
|
||||
for _, c := range state.PeerCertificates[1:] {
|
||||
inter.AddCert(c)
|
||||
}
|
||||
_, verr := leaf.Verify(x509.VerifyOptions{Roots: roots, Intermediates: inter, DNSName: hostname})
|
||||
p.Trusted = verr == nil
|
||||
switch {
|
||||
case leaf.VerifyHostname(hostname) != nil:
|
||||
p.Problem = fmt.Sprintf("named serves a certificate for %s, not %s: clients reject it", strings.Join(leaf.DNSNames, ", "), hostname)
|
||||
case time.Now().After(leaf.NotAfter):
|
||||
p.Problem = "named serves an expired certificate (until " + leaf.NotAfter.Format(time.DateOnly) + ")"
|
||||
case serial != "" && p.CertSerial != serial:
|
||||
p.Problem = "named still serves an older certificate (serial " + p.CertSerial + ", Jarl has " + serial + "): it hasn't reread the files (rndc reconfig)"
|
||||
case !p.Trusted:
|
||||
p.Warning = "the certificate isn't from a CA this server trusts (a staging or test CA?): clients reject it unless they trust that CA"
|
||||
}
|
||||
}
|
||||
switch {
|
||||
case err != nil:
|
||||
p.Problem = describe(err, e)
|
||||
case p.Problem != "":
|
||||
case answer == nil:
|
||||
p.Problem = "no DNS answer"
|
||||
default:
|
||||
p.RCode = dns.RcodeToString[answer.Rcode]
|
||||
p.OK = true
|
||||
if answer.Rcode == dns.RcodeRefused {
|
||||
p.Warning = strings.TrimPrefix(p.Warning+"; ", "; ") + "it answers, but REFUSED: this server's own address gets a view that doesn't answer for " + hostname
|
||||
}
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
func askDoT(ctx context.Context, addr string, cfg *tls.Config, q *dns.Msg) (tls.ConnectionState, *dns.Msg, error) {
|
||||
d := &tls.Dialer{NetDialer: &net.Dialer{Timeout: probeTimeout}, Config: cfg}
|
||||
conn, err := d.DialContext(ctx, "tcp", addr)
|
||||
if err != nil {
|
||||
return tls.ConnectionState{}, nil, err
|
||||
}
|
||||
defer conn.Close()
|
||||
tc := conn.(*tls.Conn)
|
||||
state := tc.ConnectionState()
|
||||
conn.SetDeadline(time.Now().Add(probeTimeout))
|
||||
co := &dns.Conn{Conn: conn}
|
||||
if err := co.WriteMsg(q); err != nil {
|
||||
return state, nil, err
|
||||
}
|
||||
r, err := co.ReadMsg()
|
||||
return state, r, err
|
||||
}
|
||||
|
||||
func askDoH(ctx context.Context, addr, hostname string, port int, cfg *tls.Config, q *dns.Msg) (tls.ConnectionState, *dns.Msg, error) {
|
||||
cfg = cfg.Clone()
|
||||
cfg.NextProtos = []string{"h2", "http/1.1"}
|
||||
tr := &http.Transport{
|
||||
TLSClientConfig: cfg,
|
||||
ForceAttemptHTTP2: true,
|
||||
// Whatever the host name resolves to here: the listener's address.
|
||||
DialContext: func(ctx context.Context, network, _ string) (net.Conn, error) {
|
||||
return (&net.Dialer{Timeout: probeTimeout}).DialContext(ctx, network, addr)
|
||||
},
|
||||
}
|
||||
defer tr.CloseIdleConnections()
|
||||
wire, err := q.Pack()
|
||||
if err != nil {
|
||||
return tls.ConnectionState{}, nil, err
|
||||
}
|
||||
url := "https://" + net.JoinHostPort(hostname, strconv.Itoa(port)) + "/dns-query"
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(wire))
|
||||
if err != nil {
|
||||
return tls.ConnectionState{}, nil, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/dns-message")
|
||||
req.Header.Set("Accept", "application/dns-message")
|
||||
resp, err := (&http.Client{Transport: tr, Timeout: probeTimeout}).Do(req)
|
||||
if err != nil {
|
||||
return tls.ConnectionState{}, nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
var state tls.ConnectionState
|
||||
if resp.TLS != nil {
|
||||
state = *resp.TLS
|
||||
}
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, 64<<10))
|
||||
if err != nil {
|
||||
return state, nil, err
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return state, nil, fmt.Errorf("HTTP %d at /dns-query", resp.StatusCode)
|
||||
}
|
||||
r := new(dns.Msg)
|
||||
if err := r.Unpack(body); err != nil {
|
||||
return state, nil, fmt.Errorf("not a DNS answer: %w", err)
|
||||
}
|
||||
return state, r, nil
|
||||
}
|
||||
|
||||
// describe says what a failed probe means.
|
||||
func describe(err error, e Endpoint) string {
|
||||
var ne net.Error
|
||||
switch {
|
||||
case errors.Is(err, syscall.ECONNREFUSED):
|
||||
return fmt.Sprintf("nothing listens on %s port %d: named may not have been able to bind it (look in named's log; ports below 1024 need named to start as root)", e.Address, e.Port)
|
||||
case errors.As(err, &ne) && ne.Timeout(), errors.Is(err, context.DeadlineExceeded):
|
||||
return fmt.Sprintf("no answer from %s port %d within %s (a firewall on the server?)", e.Address, e.Port, probeTimeout)
|
||||
}
|
||||
return err.Error()
|
||||
}
|
||||
|
||||
// RequireNames marks the probes whose certificate doesn't cover every host
|
||||
// name clients may use (the first is what the probe asked for).
|
||||
func (h *Health) RequireNames(hosts []string) {
|
||||
for i := range h.Probes {
|
||||
p := &h.Probes[i]
|
||||
if p.Problem != "" || len(p.CertNames) == 0 {
|
||||
continue
|
||||
}
|
||||
var missing []string
|
||||
for _, host := range hosts {
|
||||
if !nameCovered(p.CertNames, host) {
|
||||
missing = append(missing, host)
|
||||
}
|
||||
}
|
||||
if len(missing) > 0 {
|
||||
p.OK = false
|
||||
p.Problem = fmt.Sprintf("named serves a certificate for %s, which doesn't cover %s: clients using that name reject it", strings.Join(p.CertNames, ", "), strings.Join(missing, ", "))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func nameCovered(names []string, host string) bool {
|
||||
host = strings.ToLower(strings.TrimSuffix(host, "."))
|
||||
for _, n := range names {
|
||||
n = strings.ToLower(strings.TrimSuffix(n, "."))
|
||||
if n == host {
|
||||
return true
|
||||
}
|
||||
if rest, ok := strings.CutPrefix(n, "*."); ok {
|
||||
if _, parent, ok := strings.Cut(host, "."); ok && parent == rest {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
package encdns
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"io"
|
||||
"math/big"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/miekg/dns"
|
||||
)
|
||||
|
||||
func testCert(t *testing.T, names ...string) (tls.Certificate, *x509.Certificate) {
|
||||
key, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
tmpl := &x509.Certificate{SerialNumber: big.NewInt(0xabc123), Subject: pkix.Name{CommonName: names[0]}, DNSNames: names,
|
||||
NotBefore: time.Now().Add(-time.Hour), NotAfter: time.Now().Add(24 * time.Hour), ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}}
|
||||
der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
leaf, _ := x509.ParseCertificate(der)
|
||||
return tls.Certificate{Certificate: [][]byte{der}, PrivateKey: key, Leaf: leaf}, leaf
|
||||
}
|
||||
|
||||
// answer answers every question with 192.0.2.53.
|
||||
var answer = dns.HandlerFunc(func(w dns.ResponseWriter, r *dns.Msg) {
|
||||
m := new(dns.Msg)
|
||||
m.SetReply(r)
|
||||
m.Answer = append(m.Answer, &dns.A{Hdr: dns.RR_Header{Name: r.Question[0].Name, Rrtype: dns.TypeA, Class: dns.ClassINET, Ttl: 60}, A: net.ParseIP("192.0.2.53")})
|
||||
w.WriteMsg(m)
|
||||
})
|
||||
|
||||
func listeners(t *testing.T, cert tls.Certificate) (dot, doh Endpoint) {
|
||||
cfg := &tls.Config{Certificates: []tls.Certificate{cert}}
|
||||
ln, err := tls.Listen("tcp", "127.0.0.1:0", cfg)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
srv := &dns.Server{Listener: ln, Net: "tcp-tls", Handler: answer}
|
||||
go srv.ActivateAndServe()
|
||||
t.Cleanup(func() { srv.Shutdown() })
|
||||
hl, err := tls.Listen("tcp", "127.0.0.1:0", cfg)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
hs := &http.Server{Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/dns-query" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
b, _ := io.ReadAll(r.Body)
|
||||
q := new(dns.Msg)
|
||||
if q.Unpack(b) != nil {
|
||||
http.Error(w, "bad", 400)
|
||||
return
|
||||
}
|
||||
m := new(dns.Msg)
|
||||
m.SetReply(q)
|
||||
m.Rcode = dns.RcodeSuccess
|
||||
out, _ := m.Pack()
|
||||
w.Header().Set("Content-Type", "application/dns-message")
|
||||
w.Write(out)
|
||||
})}
|
||||
go hs.Serve(hl)
|
||||
t.Cleanup(func() { hs.Close() })
|
||||
port := func(l net.Listener) int { return l.Addr().(*net.TCPAddr).Port }
|
||||
return Endpoint{"127.0.0.1", port(ln)}, Endpoint{"127.0.0.1", port(hl)}
|
||||
}
|
||||
|
||||
func TestHealth(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
cert, leaf := testCert(t, "dns.example.com")
|
||||
dot, doh := listeners(t, cert)
|
||||
roots := x509.NewCertPool()
|
||||
roots.AddCert(leaf)
|
||||
st := Status{Enabled: true, DoT: []Endpoint{dot}, DoH: []Endpoint{doh}}
|
||||
|
||||
h := Check(ctx, st, "dns.example.com", "abc123", roots)
|
||||
if !h.OK() || len(h.Probes) != 2 {
|
||||
t.Fatalf("working setup: %+v", h.Probes)
|
||||
}
|
||||
for _, p := range h.Probes {
|
||||
if !p.Trusted || p.RCode != "NOERROR" || p.Warning != "" || p.CertSerial != "abc123" {
|
||||
t.Errorf("%s: %+v", p.Kind, p)
|
||||
}
|
||||
}
|
||||
// named hasn't reread a renewed certificate.
|
||||
if h := Check(ctx, st, "dns.example.com", "def456", roots); h.OK() || !strings.Contains(h.Probes[0].Problem, "older certificate") {
|
||||
t.Errorf("stale: %+v", h.Probes[0])
|
||||
}
|
||||
// The wrong name.
|
||||
if h := Check(ctx, st, "other.example.com", "", roots); h.OK() || !strings.Contains(h.Probes[0].Problem, "not other.example.com") {
|
||||
t.Errorf("wrong name: %+v", h.Probes[0])
|
||||
}
|
||||
// A CA this server doesn't trust: works, with a warning.
|
||||
if h := Check(ctx, st, "dns.example.com", "abc123", x509.NewCertPool()); !h.OK() || !strings.Contains(h.Probes[1].Warning, "staging") {
|
||||
t.Errorf("untrusted: %+v", h.Probes[1])
|
||||
}
|
||||
// Nothing listening.
|
||||
l, _ := net.Listen("tcp", "127.0.0.1:0")
|
||||
gone := l.Addr().(*net.TCPAddr).Port
|
||||
l.Close()
|
||||
if h := Check(ctx, Status{Enabled: true, DoT: []Endpoint{{"127.0.0.1", gone}}}, "dns.example.com", "", roots); h.OK() || !strings.Contains(h.Probes[0].Problem, "nothing listens") {
|
||||
t.Errorf("nothing listening: %+v", h.Probes[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequireNames(t *testing.T) {
|
||||
h := &Health{Probes: []Probe{
|
||||
{Kind: "dot", OK: true, CertNames: []string{"dns.example.com", "*.example.net"}},
|
||||
{Kind: "doh", OK: false, Problem: "nothing listens", CertNames: nil},
|
||||
}}
|
||||
h.RequireNames([]string{"dns.example.com", "doh.example.net"})
|
||||
if !h.Probes[0].OK {
|
||||
t.Errorf("covered names: %+v", h.Probes[0])
|
||||
}
|
||||
h.RequireNames([]string{"dns.example.com", "doh.example.com"})
|
||||
if p := h.Probes[0]; p.OK || !strings.Contains(p.Problem, "doesn't cover doh.example.com") {
|
||||
t.Errorf("missing name: %+v", p)
|
||||
}
|
||||
if h.Probes[1].Problem != "nothing listens" {
|
||||
t.Errorf("other problem replaced: %+v", h.Probes[1])
|
||||
}
|
||||
}
|
||||
Loaded 100 of 281 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user