Compare commits
90
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
No files matched your search
@@ -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.
|
||||
+662
@@ -8,6 +8,614 @@ All notable changes to Jarl are documented here. The format follows
|
||||
|
||||
### 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).
|
||||
|
||||
## [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
|
||||
@@ -40,6 +648,42 @@ All notable changes to Jarl are documented here. The format follows
|
||||
|
||||
### 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
|
||||
@@ -58,6 +702,24 @@ All notable changes to Jarl are documented here. The format follows
|
||||
|
||||
### 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
|
||||
|
||||
@@ -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.
|
||||
@@ -114,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,
|
||||
@@ -71,6 +72,19 @@ error: www.adbraun.lol has a CNAME, so it can't have any other records (found A)
|
||||
queries, record types, cache hit rate. Shown in the web UI and the CLI,
|
||||
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
|
||||
@@ -78,6 +92,18 @@ error: www.adbraun.lol has a CNAME, so it can't have any other records (found A)
|
||||
* **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.
|
||||
@@ -119,7 +145,12 @@ in `/etc/jarl/jarl.yaml` to reach it from your management network.
|
||||
| [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 |
|
||||
@@ -171,11 +202,31 @@ review step.
|
||||
|
||||
## Status
|
||||
|
||||
Version 0.3.0 is the current release, and 0.4.0 is in testing
|
||||
(0.4.0-rc.1): it adds blocking (blocklists and your own rules, like
|
||||
Pi-hole, done by named). 0.3.0 added disabled records, script-friendly
|
||||
CLI output and client names in the statistics, on top of 0.2.0's DNSSEC
|
||||
signing, Porkbun support, two-factor login and view colours. Jarl is feature-complete for one server's configuration.
|
||||
Version 0.6.3 is the current release; 0.6.4 is in testing (0.6.4-rc.1):
|
||||
failed logins and refused tokens are logged, address first, for Guard
|
||||
and fail2ban. 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)).
|
||||
|
||||
|
||||
+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 {
|
||||
|
||||
+12
-3
@@ -378,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))
|
||||
}
|
||||
|
||||
@@ -389,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}
|
||||
@@ -444,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
|
||||
@@ -466,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",
|
||||
@@ -750,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")
|
||||
|
||||
+45
-11
@@ -43,7 +43,7 @@ named answers "doesn't exist" for the domains on them (docs/blocking.md).
|
||||
Hosts, plain domain and Adblock (||domain^) lists work. Your own block and
|
||||
allow rules ('jarl block') take precedence over every list.`}
|
||||
var views []string
|
||||
var comment string
|
||||
var comment, step string
|
||||
|
||||
list := &cobra.Command{
|
||||
Use: "list",
|
||||
@@ -59,7 +59,7 @@ allow rules ('jarl block') take precedence over every list.`}
|
||||
return err
|
||||
}
|
||||
return output(lists, func(w io.Writer) {
|
||||
fmt.Fprintln(w, "NAME\tSTATE\tDOMAINS\tVIEWS\tUPDATED\tMEMORY\tURL")
|
||||
fmt.Fprintln(w, "NAME\tSTATE\tSTEP\tDOMAINS\tVIEWS\tUPDATED\tMEMORY\tURL")
|
||||
for _, b := range lists {
|
||||
domains, updated, mem := "-", "-", "-"
|
||||
if s := b.Status; s != nil && s.UpdatedAt != nil {
|
||||
@@ -68,7 +68,7 @@ allow rules ('jarl block') take precedence over every list.`}
|
||||
if b.MemoryMB > 0 {
|
||||
mem = fmt.Sprintf("~%d MB", b.MemoryMB)
|
||||
}
|
||||
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\t%s\n", b.Name, b.State, domains, viewsText(b.Views, b.AutoViews), updated, mem, b.URL)
|
||||
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n", b.Name, b.State, b.Step, domains, viewsText(b.Views, b.AutoViews), updated, mem, b.URL)
|
||||
}
|
||||
for _, b := range lists {
|
||||
if b.Status != nil && b.Status.Error != "" {
|
||||
@@ -95,6 +95,9 @@ allow rules ('jarl block') take precedence over every list.`}
|
||||
}
|
||||
body := map[string]any{"name": args[0], "url": args[1]}
|
||||
viewsBody(body, views)
|
||||
if step != "" {
|
||||
body["step"] = step
|
||||
}
|
||||
if comment != "" {
|
||||
body["comment"] = comment
|
||||
}
|
||||
@@ -111,6 +114,7 @@ allow rules ('jarl block') take precedence over every list.`}
|
||||
}
|
||||
add.Flags().StringArrayVar(&views, "view", nil, "a view it applies to (repeat for several; default: every view that offers recursion)")
|
||||
add.Flags().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{
|
||||
@@ -136,6 +140,9 @@ allow rules ('jarl block') take precedence over every list.`}
|
||||
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)")
|
||||
@@ -151,6 +158,7 @@ allow rules ('jarl block') take precedence over every list.`}
|
||||
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]
|
||||
@@ -394,6 +402,8 @@ names below it (docs/blocking.md).`}
|
||||
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:
|
||||
@@ -405,23 +415,40 @@ names below it (docs/blocking.md).`}
|
||||
}
|
||||
|
||||
pause := &cobra.Command{
|
||||
Use: "pause [DURATION]",
|
||||
Short: "Let every name through for a while (default 10m, at most 24h)",
|
||||
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.
|
||||
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),
|
||||
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)", args[0])
|
||||
return fmt.Errorf("invalid duration %q (like 10m or 2h, or indefinitely)", args[0])
|
||||
}
|
||||
}
|
||||
c, err := newClient()
|
||||
@@ -456,7 +483,7 @@ at once.
|
||||
},
|
||||
}
|
||||
|
||||
cmd.AddCommand(addRule("block"), addRule("allow"), list, del, check, pause, resume)
|
||||
cmd.AddCommand(addRule("block"), addRule("allow"), list, del, check, pause, resume, blockStepCmd(), blockGroupCmd())
|
||||
return cmd
|
||||
}
|
||||
|
||||
@@ -465,9 +492,16 @@ func pausedNote(c interface {
|
||||
Get(context.Context, string, url.Values, any) error
|
||||
}) string {
|
||||
var st struct {
|
||||
PausedUntil *time.Time `json:"paused_until"`
|
||||
PausedUntil *time.Time `json:"paused_until"`
|
||||
PausedIndefinitely bool `json:"paused_indefinitely"`
|
||||
}
|
||||
if c.Get(ctx(), "/blocking/status", nil, &st) != nil || st.PausedUntil == nil {
|
||||
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,752 @@
|
||||
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, 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"`
|
||||
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"`
|
||||
// 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 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
|
||||
}
|
||||
|
||||
// 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
|
||||
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 runs
|
||||
// the hook.
|
||||
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)
|
||||
}
|
||||
res := &getResult{Name: sc.Name, Dir: sc.Dir, Serial: ci.Serial, NotAfter: *ci.NotAfter}
|
||||
meta, _ := readMeta(sc.Dir)
|
||||
have := meta != nil && meta.Serial == ci.Serial && filesPresent(sc.Dir)
|
||||
if have && !force && meta.HookFailed == "" {
|
||||
return res, nil
|
||||
}
|
||||
renewed := meta != nil && meta.Serial != "" && meta.Serial != ci.Serial
|
||||
if !have || force {
|
||||
if err := downloadCert(c, sc, ci); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
res.Updated = true
|
||||
meta = &localMeta{Name: sc.Name, Domains: ci.Domains, Serial: ci.Serial, NotAfter: *ci.NotAfter, Fetched: time.Now().UTC()}
|
||||
}
|
||||
if sc.Hook != "" {
|
||||
res.Hook = "ok"
|
||||
if err := runHook(sc, ci, 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"}
|
||||
|
||||
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, ci certInfo, 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_DOMAINS="+strings.Join(ci.Domains, " "),
|
||||
"JARL_CERT_SERIAL="+ci.Serial,
|
||||
"JARL_CERT_NOT_AFTER="+ci.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, configDir string
|
||||
var noSave, force, replace 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, 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).
|
||||
|
||||
The settings (names, --dir, --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). 'jarl cert get' without a name then 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 (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_DOMAINS,
|
||||
JARL_CERT_SERIAL, JARL_CERT_NOT_AFTER and JARL_CERT_RENEWED (1 when it
|
||||
replaced an older certificate).`,
|
||||
Example: ` jarl cert get mail -d mail.adbraun.lol --hook 'systemctl reload postfix dovecot'
|
||||
jarl cert get proxy -d adbraun.lol -d '*.adbraun.lol' --dir /etc/haproxy/certs/proxy \
|
||||
--hook 'cat "$JARL_CERT_KEY" "$JARL_CERT_FULLCHAIN" > /etc/haproxy/proxy.pem && systemctl reload haproxy'
|
||||
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()
|
||||
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)
|
||||
}
|
||||
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 {
|
||||
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 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 {
|
||||
c, err := sc.Conn.open()
|
||||
if err == nil {
|
||||
var r *getResult
|
||||
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"
|
||||
if r.Updated {
|
||||
what = "new files"
|
||||
}
|
||||
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")
|
||||
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",
|
||||
Short: "The certificates this host gets with jarl cert get, and when they expire",
|
||||
Args: cobra.NoArgs,
|
||||
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 {
|
||||
sc.Conn.Token = "" // never print it
|
||||
m, _ := readMeta(sc.Dir)
|
||||
rows = append(rows, row{sc, m})
|
||||
}
|
||||
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\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\n", r.Name, strings.Join(r.Domains, ","), r.Dir, exp, fetched, r.Conn.Server, firstNonEmpty(r.Hook, "-"))
|
||||
}
|
||||
tw.Flush()
|
||||
})
|
||||
},
|
||||
}
|
||||
cmd.Flags().StringVar(&configDir, "config-dir", "", "where the settings are saved")
|
||||
return cmd
|
||||
}
|
||||
|
||||
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,59 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,667 @@
|
||||
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"`
|
||||
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\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"
|
||||
}
|
||||
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\n", ci.Name, ci.Source, dash(strings.Join(ci.Uses, ",")), strings.Join(names, " "), fmtDate(ci.NotAfter), state)
|
||||
}
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
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))
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
+70
-31
@@ -120,13 +120,17 @@ func byIDOrMatch(op string, d map[string]any) api.ChangeRequest {
|
||||
// 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)
|
||||
@@ -230,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 {
|
||||
@@ -245,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 {
|
||||
@@ -280,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" {
|
||||
@@ -312,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())
|
||||
}
|
||||
}
|
||||
+140
-11
@@ -30,14 +30,19 @@ func viewCmd() *cobra.Command {
|
||||
return output(views, func(w io.Writer) {
|
||||
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\t%s\n", i+1, v.ID, v.Name, orAny(v.MatchClients), orAny(v.MatchDestinations), v.Zones, orDefault(v.Color))
|
||||
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()
|
||||
@@ -60,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) {
|
||||
@@ -67,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",
|
||||
@@ -201,7 +214,45 @@ func viewCmd() *cobra.Command {
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cmd.AddCommand(list, create, update, del, order, color)
|
||||
var keyNamed bool
|
||||
key := &cobra.Command{
|
||||
Use: "key VIEW",
|
||||
Short: "Show the TSIG key that selects a view, to query it from anywhere",
|
||||
Long: `Every view has a TSIG key (jarl-view-NAME) at the front of its
|
||||
match-clients: a query signed with it lands in that view, whatever address
|
||||
it comes from. Jarl signs its own checks of this server with it; use it to
|
||||
see what a view answers:
|
||||
|
||||
jarl view key external # shows a ready dig -y command
|
||||
jarl view key external --named-conf > external.key
|
||||
dig -k external.key @SERVER adbraun.lol SOA
|
||||
|
||||
Showing it needs tsig:write and is recorded in the audit log.`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := newClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var k struct {
|
||||
Name string `json:"name"`
|
||||
Algorithm string `json:"algorithm"`
|
||||
Secret string `json:"secret"`
|
||||
}
|
||||
if err := c.Get(ctx(), "/views/"+url.PathEscape(args[0])+"/key", nil, &k); err != nil {
|
||||
return err
|
||||
}
|
||||
if keyNamed {
|
||||
fmt.Printf("key %q {\n\talgorithm %s;\n\tsecret %q;\n};\n", k.Name, k.Algorithm, k.Secret)
|
||||
return nil
|
||||
}
|
||||
return output(k, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "Key:\t%s\nAlgorithm:\t%s\nSecret:\t%s\ndig:\tdig -y %s:%s:%s @SERVER NAME\n", k.Name, k.Algorithm, k.Secret, k.Algorithm, k.Name, k.Secret)
|
||||
})
|
||||
},
|
||||
}
|
||||
key.Flags().BoolVar(&keyNamed, "named-conf", false, "print a key statement (for dig -k FILE)")
|
||||
cmd.AddCommand(list, create, update, del, order, color, key)
|
||||
return cmd
|
||||
}
|
||||
|
||||
@@ -540,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; }'
|
||||
@@ -564,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 {
|
||||
@@ -585,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()
|
||||
@@ -599,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 {
|
||||
@@ -612,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)
|
||||
@@ -679,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
|
||||
}
|
||||
|
||||
@@ -718,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",
|
||||
@@ -739,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]
|
||||
@@ -761,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")
|
||||
@@ -769,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])
|
||||
}
|
||||
+20
-9
@@ -23,8 +23,8 @@ keeps writing the unsigned zone, and named keeps a signed copy next to it
|
||||
and its keys in Jarl's key directory. See docs/dnssec.md.
|
||||
|
||||
Turning signing on is safe at any time; validation only starts once you add
|
||||
the zone's DS record at the parent zone (your registrar), and 'status' says
|
||||
when that is safe. Turning it off goes through BIND's insecure policy, which
|
||||
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
|
||||
@@ -71,7 +71,7 @@ DS after that.`,
|
||||
return err
|
||||
}
|
||||
if !flagQuiet {
|
||||
fmt.Printf("\nnamed is signing %s now. Add the DS record at your registrar only when\n'jarl zone dnssec status %s' says the zone is ready.\n", strings.TrimSuffix(args[0], "."), args[0]+viewFlag(view))
|
||||
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
|
||||
},
|
||||
@@ -87,8 +87,8 @@ DS after that.`,
|
||||
parent zone still has its DS record:
|
||||
|
||||
1. The first run switches the zone to BIND's insecure policy. named keeps it
|
||||
signed and publishes a CDS "delete" record. Remove the DS record at your
|
||||
registrar, then run 'jarl zone dnssec check-ds' so named knows. named
|
||||
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
|
||||
@@ -137,7 +137,7 @@ A zone whose DS never reached the parent can be wiped at once instead
|
||||
if next == "insecure" && st.ParentZone != "" {
|
||||
fmt.Printf("\nNext: remove the DS record for %s from %s:\n %s\nthen run\n jarl zone dnssec check-ds %s\nRun 'jarl zone dnssec disable %s' again when 'status' says it has finished.\n", displayName(st.Zone), displayName(st.ParentZone), parentDSCommand(st, "delete", ""), args[0]+viewFlag(view), args[0]+viewFlag(view))
|
||||
} else if next == "insecure" {
|
||||
fmt.Printf("\nNext: remove the DS record for %s at your registrar, then run\n jarl zone dnssec check-ds %s\nRun 'jarl zone dnssec disable %s' again when 'status' says it has finished.\n", displayName(st.Zone), args[0]+viewFlag(view), args[0]+viewFlag(view))
|
||||
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').")
|
||||
}
|
||||
@@ -153,7 +153,7 @@ A zone whose DS never reached the parent can be wiped at once instead
|
||||
Short: "Check the parent zone's DS records and tell named",
|
||||
Long: `Asks every server of the parent zone for the zone's DS records, and tells
|
||||
named (rndc dnssec -checkds) when its key's DS has appeared there (after you
|
||||
added it at the registrar) or is gone (while turning DNSSEC off). named
|
||||
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
|
||||
@@ -345,9 +345,12 @@ func printDNSSEC(w io.Writer, r *api.DNSSECResponse) {
|
||||
}
|
||||
}
|
||||
if len(ds) > 0 && r.Policy != "insecure" {
|
||||
where := "at your registrar"
|
||||
if r.DSAt == "none" {
|
||||
fmt.Fprintf(w, "\n%s\n", r.DSNote)
|
||||
}
|
||||
where := dsPlace(r)
|
||||
if r.ParentZone != "" {
|
||||
where = "in the parent zone " + displayName(r.ParentZone) + ", which is in Jarl: add it as a DS record there"
|
||||
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 {
|
||||
@@ -368,6 +371,14 @@ func printDNSSEC(w io.Writer, r *api.DNSSECResponse) {
|
||||
}
|
||||
}
|
||||
|
||||
// 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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
+71
-17
@@ -9,11 +9,11 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/netip"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"text/tabwriter"
|
||||
@@ -24,6 +24,7 @@ import (
|
||||
|
||||
"jarl/internal/api"
|
||||
"jarl/internal/client"
|
||||
"jarl/internal/dnsname"
|
||||
"jarl/internal/version"
|
||||
)
|
||||
|
||||
@@ -48,6 +49,7 @@ var (
|
||||
flagChangeset int64
|
||||
flagDryRun bool
|
||||
flagForce bool
|
||||
flagRisk bool
|
||||
flagOutput string
|
||||
flagInsecure bool
|
||||
flagCACert string
|
||||
@@ -101,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
|
||||
@@ -123,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 {
|
||||
@@ -139,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
|
||||
}
|
||||
@@ -149,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
|
||||
}
|
||||
@@ -239,6 +274,7 @@ Exit codes: 0 ok, 1 error, 3 not authenticated or not allowed, 4 not found,
|
||||
pf.Int64VarP(&flagChangeset, "changeset", "C", 0, "stage changes into this changeset instead of applying them (env JARL_CHANGESET)")
|
||||
pf.BoolVarP(&flagDryRun, "dry-run", "n", false, "validate and show the changes without applying them")
|
||||
pf.BoolVar(&flagForce, "force", false, "overwrite BIND files that were changed outside Jarl")
|
||||
pf.BoolVar(&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")
|
||||
@@ -252,7 +288,7 @@ Exit codes: 0 ok, 1 error, 3 not authenticated or not allowed, 4 not found,
|
||||
}
|
||||
add("dns", zoneCmd(), recordCmd(), viewCmd(), aclCmd(), tsigCmd(), optionCmd(), settingsCmd(), blockCmd(), blocklistCmd(), acmeCmd(), searchCmd(), statsCmd())
|
||||
add("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
|
||||
}
|
||||
@@ -530,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)
|
||||
}
|
||||
@@ -550,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"
|
||||
|
||||
@@ -232,3 +232,20 @@ func TestOutputFields(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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())
|
||||
}
|
||||
}
|
||||
+121
-12
@@ -4,6 +4,7 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"net/url"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -21,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.
|
||||
@@ -77,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)
|
||||
@@ -128,14 +156,15 @@ export the data.`,
|
||||
|
||||
var limit int
|
||||
top := &cobra.Command{
|
||||
Use: "top [names|clients|nxdomain|servfail|refused|blocked|blocked-clients]",
|
||||
Use: "top [names|clients|nxdomain|servfail|refused|blocked|blocked-clients|ratelimited|ratelimited-names]",
|
||||
Short: "The most queried names, busiest clients, names with errors, refused clients, blocked names",
|
||||
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 blocked # what blocking stops most (docs/blocking.md)
|
||||
jarl stats top blocked-clients # the devices blocking stops most`,
|
||||
jarl stats top blocked-clients # the devices blocking stops most
|
||||
jarl stats top ratelimited # client networks rate limiting limits, or would (docs/exposure.md)`,
|
||||
Args: cobra.MaximumNArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
kind := stats.KindName
|
||||
@@ -147,13 +176,16 @@ export the data.`,
|
||||
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) {
|
||||
@@ -203,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) {
|
||||
@@ -252,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) {
|
||||
@@ -304,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) {
|
||||
@@ -325,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
|
||||
}
|
||||
|
||||
@@ -398,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")
|
||||
}
|
||||
+670
-51
@@ -8,6 +8,7 @@ import (
|
||||
"net/netip"
|
||||
"net/url"
|
||||
"os"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
@@ -45,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)
|
||||
}
|
||||
})
|
||||
},
|
||||
@@ -73,12 +78,15 @@ 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)
|
||||
if z.Disabled > 0 {
|
||||
fmt.Fprintf(w, "Records:\t%d (%d disabled: jarl record list %s --disabled)\n", z.Records, z.Disabled, args[0])
|
||||
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)
|
||||
}
|
||||
@@ -86,6 +94,28 @@ func zoneCmd() *cobra.Command {
|
||||
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)
|
||||
}
|
||||
@@ -98,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()
|
||||
@@ -142,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)
|
||||
@@ -212,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)",
|
||||
@@ -257,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)")
|
||||
}
|
||||
@@ -281,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{
|
||||
@@ -306,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",
|
||||
@@ -373,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(), zoneDNSSECCmd(), zoneRegistrarCmd())
|
||||
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
|
||||
}
|
||||
|
||||
@@ -406,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, onlyDisabled, wide 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",
|
||||
@@ -432,8 +542,15 @@ func recordCmd() *cobra.Command {
|
||||
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) {
|
||||
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 {
|
||||
@@ -443,10 +560,18 @@ func recordCmd() *cobra.Command {
|
||||
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 {
|
||||
fitColumn(rows, 4)
|
||||
col := 4
|
||||
if linked {
|
||||
col = 5
|
||||
}
|
||||
fitColumn(rows, col)
|
||||
}
|
||||
for _, row := range rows {
|
||||
fmt.Fprintln(w, strings.Join(row, "\t"))
|
||||
@@ -488,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{
|
||||
@@ -533,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)")
|
||||
}
|
||||
@@ -549,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",
|
||||
@@ -581,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)")
|
||||
@@ -627,17 +765,23 @@ 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")
|
||||
|
||||
toggle := func(disable bool) *cobra.Command {
|
||||
verb := "enable"
|
||||
@@ -685,30 +829,37 @@ with DATA), or one record by ID.
|
||||
if len(args) < 3 {
|
||||
return fmt.Errorf("give ZONE NAME TYPE [DATA], or --id")
|
||||
}
|
||||
path, zq := zonePath(args[0], tview)
|
||||
for k, v := range zq {
|
||||
qv[k] = v
|
||||
}
|
||||
qv.Set("name", args[1])
|
||||
qv.Set("type", args[2])
|
||||
if len(args) > 3 {
|
||||
qv.Set("data", strings.Join(args[3:], " "))
|
||||
}
|
||||
if tmissing {
|
||||
qv.Set("missing_ok", "1")
|
||||
}
|
||||
resp, err := c.Mutate(ctx(), "POST", path+"/records/"+verb, qv, nil)
|
||||
if err != nil && tmissing && strings.HasSuffix(err.Error(), "nothing to do") {
|
||||
fmt.Println("nothing to do")
|
||||
return nil
|
||||
}
|
||||
views, err := zoneViews(c, args[0], tview)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return report(resp)
|
||||
return perView(c, views, verb+" "+args[1]+" "+strings.ToUpper(args[2]), func(c *client.Client, view string) (*api.MutationResponse, error) {
|
||||
path, zq := zonePath(args[0], view)
|
||||
vq := q()
|
||||
for k, v := range qv {
|
||||
vq[k] = v
|
||||
}
|
||||
for k, v := range zq {
|
||||
vq[k] = v
|
||||
}
|
||||
vq.Set("name", args[1])
|
||||
vq.Set("type", args[2])
|
||||
if len(args) > 3 {
|
||||
vq.Set("data", strings.Join(args[3:], " "))
|
||||
}
|
||||
if tmissing {
|
||||
vq.Set("missing_ok", "1")
|
||||
}
|
||||
resp, err := c.Mutate(ctx(), "POST", path+"/records/"+verb, vq, nil)
|
||||
if err != nil && tmissing && strings.HasSuffix(err.Error(), "nothing to do") {
|
||||
fmt.Println("nothing to do")
|
||||
return nil, nil
|
||||
}
|
||||
return resp, err
|
||||
})
|
||||
},
|
||||
}
|
||||
c.Flags().StringVar(&tview, "view", "", "view of the zone")
|
||||
c.Flags().StringVar(&tview, "view", "", viewsFlagHelp)
|
||||
c.Flags().Int64Var(&tid, "id", 0, verb+" the record with this ID")
|
||||
c.Flags().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)")
|
||||
@@ -769,3 +920,471 @@ func fitColumn(rows [][]string, col int) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
+5
-1
@@ -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
|
||||
|
||||
+31
-2
@@ -24,9 +24,11 @@ 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"
|
||||
@@ -122,6 +124,7 @@ 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,
|
||||
@@ -148,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())
|
||||
@@ -167,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)
|
||||
@@ -185,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
|
||||
}
|
||||
@@ -303,6 +308,24 @@ func serveCmd() *cobra.Command {
|
||||
a.log.Warn("the configuration file holds the Porkbun API key and everyone can read it; make it readable only by root and jarld (chown root:jarl, chmod 640)", "file", configPath)
|
||||
}
|
||||
}
|
||||
if a.cfg.Metrics.Enabled && a.cfg.Metrics.Token != "" {
|
||||
if fi, err := os.Stat(configPath); err == nil && fi.Mode().Perm()&0o004 != 0 {
|
||||
a.log.Warn("the configuration file holds metrics.token and everyone can read it; make it readable only by root and jarld (chown root:jarl, chmod 640)", "file", configPath)
|
||||
}
|
||||
}
|
||||
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),
|
||||
@@ -311,6 +334,11 @@ func serveCmd() *cobra.Command {
|
||||
BackupDir: a.cfg.Backup.Dir, BackupWarnAfter: time.Duration(a.cfg.Backup.WarnAfter),
|
||||
Porkbun: pb, PorkbunAllows: a.cfg.Porkbun.Allows,
|
||||
TOTPRequire: a.cfg.TOTP.Require, TOTPIssuer: a.cfg.TOTP.IssuerName(),
|
||||
Metrics: server.Metrics{Enabled: a.cfg.Metrics.Enabled, Listen: a.cfg.Metrics.Listen,
|
||||
Allow: a.cfg.Metrics.AllowPrefixes(), Token: a.cfg.Metrics.Token},
|
||||
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,
|
||||
@@ -328,6 +356,7 @@ func serveCmd() *cobra.Command {
|
||||
}()
|
||||
go auditRetention(ctx, a)
|
||||
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")
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -55,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
|
||||
@@ -63,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.
|
||||
+111
-15
@@ -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
|
||||
|
||||
@@ -147,6 +149,14 @@ honours `If-Match: <version>`, and batch operations take `base_version`. If
|
||||
the object changed in the meantime the request fails with 409. Staged
|
||||
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`
|
||||
@@ -160,7 +170,7 @@ may be the name.
|
||||
| GET | `/zones` | `?view=`, `?type=`, `?q=` (name contains) |
|
||||
| POST | `/zones` | ZoneInput (below) |
|
||||
| GET | `/zones/{zone}` | |
|
||||
| PATCH | `/zones/{zone}` | ZoneInput (only the fields to change) |
|
||||
| PATCH | `/zones/{zone}` | ZoneInput (only the fields to change); `{"disabled": true}` disables the zone: named stops loading it, Jarl keeps it |
|
||||
| DELETE | `/zones/{zone}` | deletes the zone with its records and options |
|
||||
| GET | `/zones/{zone}/file` | the generated zone file (text/plain) |
|
||||
| GET | `/zones/{zone}/serials` | asks the zone's other name servers for their serial (below) |
|
||||
@@ -180,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
|
||||
@@ -189,6 +201,7 @@ ZoneInput:
|
||||
"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
|
||||
@@ -198,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:
|
||||
@@ -277,11 +299,20 @@ view ([dnssec.md](dnssec.md#what-jarl-refuses-and-why)).
|
||||
},
|
||||
"named": {"secure": true, "inline_signing": true, "serial": 2026100802, "signed_serial": 2026100804, "next_key_event": "..."},
|
||||
"policies": ["default"],
|
||||
"parent_zone": "", // set when the parent zone is a primary zone in Jarl
|
||||
"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)).
|
||||
@@ -332,6 +363,28 @@ 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=`, `?disabled=1` (only disabled records) or `0` (only enabled ones) |
|
||||
@@ -375,7 +428,9 @@ 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".
|
||||
(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
|
||||
|
||||
@@ -386,6 +441,7 @@ Disabling a record that is already disabled is a 409; with `missing_ok=1`
|
||||
| GET / PATCH / DELETE | `/views/{id}` | DELETE `?cascade=1` also deletes the view's zones |
|
||||
| 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.
|
||||
@@ -404,7 +460,7 @@ send the request with that changeset (`?changeset=` or
|
||||
| Method | Path | Body |
|
||||
|--------|------|------|
|
||||
| GET | `/acls` | each with `used_by` |
|
||||
| POST | `/acls` | `{"name": "homelab", "elements": ["192.168.1.0/24", "!192.168.1.66", "key xfer", "localhost"], "comment": ""}` |
|
||||
| POST | `/acls` | `{"name": "homelab", "elements": ["192.168.1.0/24", "!192.168.1.66", "10.0.10.101-10.0.10.199", "key xfer", "localhost"], "comment": ""}` (ranges: [concepts.md](concepts.md#model-views-zones-records-options)) |
|
||||
| GET / PATCH / DELETE | `/acls/{id}` | renaming updates references; deleting one in use → 409 |
|
||||
| GET | `/tsig-keys` | without secrets |
|
||||
| POST | `/tsig-keys` | `{"name": "xfer", "algorithm": "hmac-sha256", "secret": "<base64>"}` (secret generated when absent; the response `object` shows it once) |
|
||||
@@ -419,16 +475,21 @@ Needs `blocking:read` (`blocking:write` to change). See
|
||||
| Method | Path | Body / query |
|
||||
|--------|------|--------------|
|
||||
| GET | `/blocking/lists` | each with `views` (where it applies), `auto_views` (not chosen: every view that offers recursion), `state` (ok, waiting, error, disabled, idle), `status` (`entries`, `records`, `skipped`, `checked_at`, `updated_at`, `error`) and `memory_mb` (named's memory for it, estimated) |
|
||||
| POST | `/blocking/lists` | `{"name": "hagezi-multi", "url": "https://...", "views": ["internal"], "comment": ""}` (`views` empty or absent: automatic); jarld downloads it right after |
|
||||
| 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}` (what applies when blocking isn't paused) |
|
||||
| GET | `/blocking/status` | `{paused_until}` (null when not paused) |
|
||||
| POST | `/blocking/pause` | `{"minutes": 15}` (1 to 1440): let every name through until then; jarld resumes by itself |
|
||||
| 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
|
||||
@@ -438,13 +499,16 @@ listing with `?changeset=` marks them `pending`.
|
||||
|
||||
| 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
|
||||
|
||||
@@ -453,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
|
||||
@@ -463,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}` |
|
||||
@@ -487,7 +553,7 @@ An **Operation** is `{"op": "...", "id": 0, "base_version": 0, "data": {...}}`:
|
||||
| `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"}`, or `null` to resume |
|
||||
| `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
|
||||
@@ -497,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`); `blocklist_errors` (with `blocking:read`: lists whose last update failed, `{name, error, updated_at}`); `blocking_paused_until` (with `blocking:read`, while paused) |
|
||||
| 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/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, 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/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 `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
|
||||
@@ -541,7 +637,7 @@ 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`, and `username` to rename. Users have `totp_enabled_at` (two-factor login on since) and `totp_required` |
|
||||
| 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 |
|
||||
|
||||
+70
-3
@@ -147,6 +147,56 @@ A list's state is **ok** (in use), **waiting** (not downloaded yet),
|
||||
**error** (the last update failed; the previous version stays in use),
|
||||
**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
|
||||
@@ -168,15 +218,18 @@ most blocked names link to this check.
|
||||
|
||||
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.
|
||||
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, and
|
||||
the dashboard says while blocking is paused. The lists stay loaded (the
|
||||
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.
|
||||
@@ -191,6 +244,10 @@ names, and the clients with the most blocked queries (`jarl stats summary`,
|
||||
breakdown blocklist`). Blocked answers are left out of the NXDOMAIN figures, which
|
||||
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
|
||||
@@ -247,6 +304,16 @@ response-policy {
|
||||
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)).
|
||||
|
||||
|
||||
@@ -0,0 +1,362 @@
|
||||
# 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
|
||||
```
|
||||
|
||||
* **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.
|
||||
* **Saved settings**: the names, `--dir`, `--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
|
||||
forget NAME` stops getting one (`--delete` deletes it on jarld too).
|
||||
* **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, with
|
||||
`JARL_CERT_NAME`, `JARL_CERT_DIR`, `JARL_CERT_CERT`, `JARL_CERT_CHAIN`,
|
||||
`JARL_CERT_FULLCHAIN`, `JARL_CERT_KEY`, `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.
|
||||
|
||||
## 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
|
||||
```
|
||||
+164
-6
@@ -41,7 +41,7 @@ jarl whoami
|
||||
| API token (`@FILE` reads it from a file) | `--token` | `JARL_TOKEN` | `token` |
|
||||
| Skip TLS verification | `--insecure` | | `insecure` |
|
||||
| Extra trusted CA (PEM) | `--ca-cert` | | `ca_cert` |
|
||||
| Changeset to stage into | `-C/--changeset` | `JARL_CHANGESET` | |
|
||||
| Changeset to stage into (and the default for `jarl changeset diff/apply/...`) | `-C/--changeset` | `JARL_CHANGESET` (checked: open and yours, see [Changesets](#changesets-stage-review-apply)) | |
|
||||
|
||||
Flags win over the environment, and the environment wins over the file. The
|
||||
file is `jarl/config.yaml` in the user's configuration directory, or
|
||||
@@ -143,6 +143,7 @@ to stderr with `-o json` or `-o yaml`, so stdout holds just the documents.
|
||||
| `-n, --dry-run` | Validate and show the diff without changing anything. |
|
||||
| `-C, --changeset ID` | Stage the change into an open changeset instead of applying it. |
|
||||
| `--force` | Overwrite BIND files that were changed outside Jarl (drift). |
|
||||
| `--accept-risk` | Confirm a change that opens a public view up, such as an open resolver ([exposure.md](exposure.md)). Without it such a change stops with exit 5. |
|
||||
| `-q, --quiet` | Print only errors, or just the IDs: of what a command creates, or what `show` and `list` find (like `-o id`). |
|
||||
|
||||
## Exit codes
|
||||
@@ -176,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
|
||||
@@ -201,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.
|
||||
|
||||
@@ -264,8 +296,20 @@ jarl record disable --id 1234 # by ID; same for enab
|
||||
|
||||
# reverse zones: name records by address; the host name needs no final dot
|
||||
jarl record add 0.10.10.in-addr.arpa 10.10.0.20 PTR nas.adbraun.lol
|
||||
|
||||
# a zone in several views (split horizon): the same change in each
|
||||
jarl record add adbraun.lol mx2 A 203.0.113.5 --view internal,external
|
||||
jarl record delete adbraun.lol old A --view all -y
|
||||
```
|
||||
|
||||
**Several views.** When a zone exists in more than one view, the record
|
||||
commands that change records (`add`, `set`, `delete`, `disable`,
|
||||
`enable`) take `--view a,b` or `--view all` (every view the zone is in).
|
||||
The changes are applied together as one changeset: if one view refuses
|
||||
(the zone isn't in it, a CNAME conflict, ...), no view is changed. With
|
||||
`--changeset` they are staged there instead. Keep split-horizon data that
|
||||
differs per view (an internal address, say) to one view at a time.
|
||||
|
||||
Names are relative to the zone (`@` is the apex) unless they end with a
|
||||
dot. Data uses zone file syntax, except that TXT/SPF take plain text: Jarl
|
||||
adds the quotes and splits long values into 255-byte strings. In reverse zones the name can be the address
|
||||
@@ -273,7 +317,10 @@ 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.
|
||||
|
||||
@@ -324,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]
|
||||
@@ -336,8 +383,84 @@ 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 saved # this host's certificates and their expiry
|
||||
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
|
||||
@@ -347,8 +470,10 @@ jarl view order internal external
|
||||
jarl view update external --match-clients "!homelab,any"
|
||||
jarl view delete old [--cascade]
|
||||
jarl view color internal green # web UI colour; "default" resets it
|
||||
jarl view key external [--named-conf] # the key that selects the view (dig -k), docs/views.md
|
||||
|
||||
jarl acl create homelab 192.168.1.0/24 10.10.0.0/16 localhost
|
||||
jarl acl create dhcp 10.0.10.101-10.0.10.199 2001:db8::100-2001:db8::1ff # ranges (concepts.md)
|
||||
jarl acl update homelab --add 10.20.0.0/16 --remove 10.10.0.0/16
|
||||
jarl acl update homelab --rename lan # references are updated
|
||||
jarl acl delete lan # refused while in use
|
||||
@@ -362,12 +487,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
|
||||
@@ -391,6 +537,7 @@ 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
|
||||
@@ -416,6 +563,9 @@ 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
|
||||
@@ -423,6 +573,7 @@ jarl stats top names | clients | nxdomain | servfail | refused | blocked | block
|
||||
jarl stats timeline --since 6h [--key rcode:NXDOMAIN] [--table]
|
||||
jarl stats heatmap --since 28d
|
||||
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.
|
||||
@@ -443,6 +594,13 @@ 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
|
||||
```
|
||||
|
||||
|
||||
+48
-1
@@ -48,6 +48,14 @@ 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
|
||||
|
||||
+46
-7
@@ -20,7 +20,7 @@ The CLI has its own small configuration file, described in [cli.md](cli.md).
|
||||
|-----|---------|---------|
|
||||
| `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. |
|
||||
@@ -73,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
|
||||
@@ -114,18 +144,27 @@ there (`ReadWritePaths`). If you change them, update both
|
||||
ReadWritePaths=/srv/bind/jarl /srv/bind/zones
|
||||
```
|
||||
|
||||
### Porkbun (registrar)
|
||||
### 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)). Without a key
|
||||
(the default) nothing about Porkbun shows up.
|
||||
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 (default: every primary zone the key can reach). |
|
||||
| `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
|
||||
|
||||
+14
-1
@@ -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
|
||||
@@ -31,6 +32,12 @@ internal/
|
||||
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
|
||||
@@ -117,7 +124,13 @@ checker tests work with the installed `bind9-utils` too.
|
||||
ACME hooks, DNSSEC signing, backup and restore with keys, audit) and checks
|
||||
the answers with `dig`. The Porkbun code is tested against
|
||||
`porkbun.Fake`, an in-memory stand-in for its API; nothing calls the real
|
||||
one. `KEEP=1` leaves named
|
||||
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.
|
||||
|
||||
|
||||
+30
-3
@@ -30,6 +30,17 @@ 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
|
||||
@@ -175,13 +186,17 @@ Before you go public:
|
||||
## 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.
|
||||
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 (domains, read),
|
||||
and to your server's address.
|
||||
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:
|
||||
@@ -200,6 +215,18 @@ 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`:
|
||||
|
||||
| | |
|
||||
|
||||
@@ -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`).
|
||||
@@ -317,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
|
||||
|
||||
+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
|
||||
```
|
||||
@@ -360,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,
|
||||
|
||||
+19
-4
@@ -116,8 +116,10 @@ demote or delete the last enabled administrator.
|
||||
|
||||
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,
|
||||
run `resync`, wipe a zone's DNSSEC keys, and use the registrar (Porkbun,
|
||||
whose key can move the domain).
|
||||
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:
|
||||
|
||||
@@ -136,7 +138,8 @@ RESOURCE:ACCESS [zones=PATTERNS] [views=PATTERNS] [names=PATTERNS] [types=TYPES]
|
||||
| `audit` | everyone's audit events and changesets (without it, users see only their own) |
|
||||
| `stats` | query statistics, including client addresses and the names they look up (read only) |
|
||||
| `blocking` | blocklists and block/allow rules ([blocking.md](blocking.md)); `write` also downloads lists now |
|
||||
| `*` | all of the above |
|
||||
| `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
|
||||
@@ -145,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:
|
||||
@@ -156,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
|
||||
|
||||
@@ -172,6 +185,7 @@ you can then edit.
|
||||
| `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
|
||||
@@ -197,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
|
||||
```
|
||||
|
||||
+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.
|
||||
+109
-3
@@ -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.
|
||||
|
||||
@@ -103,6 +112,27 @@ lists, views, zones). named's own counters can't be filtered.
|
||||
| `zone:NAME` | response log | answers per local zone; `zone:-` for names in none of your zones |
|
||||
| `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`
|
||||
@@ -131,7 +161,9 @@ prefixes and get no name.
|
||||
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**:
|
||||
|
||||
@@ -145,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
|
||||
|
||||
+54
-4
@@ -34,7 +34,7 @@ showing them.
|
||||
| `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 |
|
||||
@@ -57,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 |
|
||||
@@ -65,11 +67,12 @@ showing them.
|
||||
|
||||
| Code | Severity | Rule |
|
||||
|------|----------|------|
|
||||
| `acl_element_invalid` | error | Address match elements: addresses, prefixes (host bits must be zero, like BIND requires), `!negation`, `key NAME`, ACL names, `{ nested lists }`, `any`/`none`/`localhost`/`localnets` |
|
||||
| `acl_element_invalid` | error | Address match elements: addresses, prefixes (host bits must be zero, like BIND requires), ranges (`10.0.10.101-10.0.10.199`: both ends full addresses of one family, start not after end), `!negation`, `key NAME`, ACL names, `{ nested lists }`, `any`/`none`/`localhost`/`localnets`. Ranges in options are checked when they're entered, and only options with address match lists take them (not `forwarders`, say) |
|
||||
| `range_mapped4` | warning | An IPv6 range that reaches into `::ffff:0:0/96`: named can see IPv4 clients as such addresses, so it may match IPv4 clients too |
|
||||
| `acl_ref_missing`, `key_ref_missing` | error | Every ACL or TSIG key that is referenced must exist |
|
||||
| `acl_cycle` | error | ACLs that include each other |
|
||||
| `acl_duplicate`, `acl_name_invalid`, `acl_empty`, `acl_shadowed` | error / warning | Unique names, built-in names reserved, elements that can never match |
|
||||
| `key_*` | error / warning | Valid key names, a supported algorithm, a base64 secret of a sensible length. hmac-md5/sha1 are flagged as weak |
|
||||
| `acl_duplicate`, `acl_name_invalid`, `acl_empty`, `acl_shadowed` | error / warning | Unique names, built-in names reserved, names that look like addresses or ranges, elements that can never match (a prefix or range an earlier one already covers) |
|
||||
| `key_*` | error / warning | Valid key names, a supported algorithm, a base64 secret of a sensible length. hmac-md5/sha1 are flagged as weak. Names starting with `jarl-view-` are reserved for the view keys (`key_name_reserved`) |
|
||||
| `view_duplicate`, `view_name_invalid`, `view_acl_invalid` | error | Valid, unique views and match lists |
|
||||
| `view_unreachable` | warning | A view that comes after one matching every client is never used |
|
||||
| `option_unknown`, `option_scope_invalid` | error | The option must exist in BIND 9.20 and be allowed where it is set (server, view, zone of this type, top level) |
|
||||
@@ -101,6 +104,26 @@ applied.
|
||||
| `dnssec_old_keys` | info | Signing a zone whose key directory still has old keys |
|
||||
| `serial_not_higher` | error | An explicit serial on a signed zone must be above the one named has loaded (automatic serials are raised by themselves) |
|
||||
|
||||
## 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).
|
||||
@@ -113,14 +136,34 @@ From [blocking.md](blocking.md).
|
||||
| `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
|
||||
|
||||
@@ -130,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.
|
||||
+190
-11
@@ -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,12 +299,17 @@ 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`.
|
||||
* Give each view a colour (Views → Edit → Colour, or
|
||||
* Each view has its own page (Views → the view's name): its settings,
|
||||
options and zones.
|
||||
* Give each view a colour (the view's page → Settings → Colour, or
|
||||
`jarl view color internal green`) to tell the zones of each view apart
|
||||
in the zone list. The colour only affects the web UI, so it applies at
|
||||
once, with no changeset or deploy. `jarl view color internal default`
|
||||
|
||||
@@ -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
|
||||
@@ -15,7 +16,9 @@ require (
|
||||
)
|
||||
|
||||
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=
|
||||
|
||||
+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
|
||||
|
||||
+164
-7
@@ -22,13 +22,38 @@ type Zone struct {
|
||||
Display string `json:"display_name"`
|
||||
View string `json:"view,omitempty"`
|
||||
Records int `json:"record_count"`
|
||||
// Disabled is how many of the records are disabled.
|
||||
Disabled int `json:"disabled_count,omitempty"`
|
||||
ZoneFile string `json:"zone_file,omitempty"` // the file named loads
|
||||
RName string `json:"rname_email,omitempty"`
|
||||
// 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"`
|
||||
}
|
||||
@@ -40,6 +65,8 @@ 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, delete,
|
||||
@@ -52,6 +79,21 @@ type View struct {
|
||||
model.View
|
||||
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.
|
||||
@@ -208,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"`
|
||||
}
|
||||
@@ -249,8 +294,67 @@ type StatusResponse struct {
|
||||
// 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.
|
||||
BlockingPausedUntil *time.Time `json:"blocking_paused_until,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
|
||||
@@ -322,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.
|
||||
@@ -388,8 +495,20 @@ type DNSSECResponse struct {
|
||||
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 same view): the DS record then goes there, as a record.
|
||||
// (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"`
|
||||
@@ -504,6 +623,27 @@ type Blocklist struct {
|
||||
// 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.
|
||||
@@ -513,3 +653,20 @@ type BlockRule struct {
|
||||
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")
|
||||
}
|
||||
}
|
||||
+16
-4
@@ -21,11 +21,14 @@ const (
|
||||
ResAudit = "audit" // audit log and everyone's changesets
|
||||
ResStats = "stats" // query statistics
|
||||
ResBlocking = "blocking" // blocklists and block/allow rules
|
||||
ResAll = "*"
|
||||
// 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, ResBlocking}
|
||||
var Resources = []string{ResZones, ResRecords, ResViews, ResACLs, ResTSIG, ResOptions, ResAudit, ResStats, ResBlocking, ResCerts}
|
||||
|
||||
// Access levels.
|
||||
const (
|
||||
@@ -74,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")
|
||||
@@ -228,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
|
||||
}
|
||||
@@ -382,4 +391,7 @@ var RoleTemplates = map[string][]model.Grant{
|
||||
},
|
||||
"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}},
|
||||
}
|
||||
+16
-1
@@ -340,7 +340,14 @@ type ServerStatus struct {
|
||||
ConfigFile string `json:"config_file"`
|
||||
Zones string `json:"zones"`
|
||||
Running bool `json:"running"`
|
||||
Raw string `json:"raw"`
|
||||
// RecursiveClients is "now/soft limit/hard limit" (the soft limit is
|
||||
// where named starts dropping the oldest queries), TCPClients
|
||||
// "now/limit".
|
||||
RecursiveClients string `json:"recursive_clients,omitempty"`
|
||||
TCPClients string `json:"tcp_clients,omitempty"`
|
||||
CPUs string `json:"cpus,omitempty"`
|
||||
WorkerThreads string `json:"worker_threads,omitempty"`
|
||||
Raw string `json:"raw"`
|
||||
}
|
||||
|
||||
// Status returns named's status.
|
||||
@@ -363,6 +370,14 @@ func (t *Tools) Status(ctx context.Context) (*ServerStatus, error) {
|
||||
st.ConfigFile = v
|
||||
case ok && k == "number of zones":
|
||||
st.Zones = v
|
||||
case ok && k == "recursive clients":
|
||||
st.RecursiveClients = v
|
||||
case ok && k == "tcp clients":
|
||||
st.TCPClients = v
|
||||
case ok && k == "CPUs found":
|
||||
st.CPUs = v
|
||||
case ok && k == "worker threads":
|
||||
st.WorkerThreads = v
|
||||
case strings.Contains(line, "server is up and running"):
|
||||
st.Running = true
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -40,6 +40,9 @@ type Match struct {
|
||||
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.
|
||||
|
||||
@@ -427,3 +427,87 @@ func classify(name, grammar string) (string, []string) {
|
||||
}
|
||||
return KindRaw, nil
|
||||
}
|
||||
|
||||
// listParam matches an optional parameter before a statement's list, such
|
||||
// as "[ port <integer> ]" in allow-transfer's grammar.
|
||||
var listParam = regexp.MustCompile(`\[ ([a-z0-9-]+) `)
|
||||
|
||||
// WrapList adds the braces to a list value given without them, for
|
||||
// statements that end in a list ("allow-transfer [ port <integer> ] {
|
||||
// <address_match_element>; ... }"): "10.0.0.53" becomes "{ 10.0.0.53; }",
|
||||
// "port 5353 10.0.0.53; key xfer" becomes "port 5353 { 10.0.0.53; key
|
||||
// xfer; }". Elements are separated by ";", "," or new lines, or by spaces
|
||||
// when they are all addresses. Values with a brace, and values of other
|
||||
// statements, are returned as they are.
|
||||
func (st *Statement) WrapList(v string) string {
|
||||
g, _, _ := strings.Cut(st.Grammar, "\n")
|
||||
open := strings.Index(g, " {")
|
||||
if strings.Contains(v, "{") || open < 0 || !strings.HasSuffix(g, "; ... };") {
|
||||
return v
|
||||
}
|
||||
params := map[string]bool{}
|
||||
for _, m := range listParam.FindAllStringSubmatch(g[:open], -1) {
|
||||
params[m[1]] = true
|
||||
}
|
||||
fields := strings.Fields(v)
|
||||
i := 0
|
||||
for i+1 < len(fields) && params[strings.ToLower(fields[i])] {
|
||||
i += 2
|
||||
}
|
||||
head := strings.Join(fields[:i], " ")
|
||||
rest := strings.Join(fields[i:], " ")
|
||||
var elems []string
|
||||
if !strings.ContainsAny(rest, ";,\n") {
|
||||
elems = spaceElements(fields[i:])
|
||||
} else {
|
||||
for _, e := range strings.FieldsFunc(rest, func(r rune) bool { return r == ';' || r == ',' || r == '\n' }) {
|
||||
if e = strings.TrimSpace(e); e != "" {
|
||||
elems = append(elems, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(elems) == 0 {
|
||||
return v
|
||||
}
|
||||
list := "{ " + strings.Join(elems, "; ") + "; }"
|
||||
if head != "" {
|
||||
return head + " " + list
|
||||
}
|
||||
return list
|
||||
}
|
||||
|
||||
// spaceElements splits list elements separated by spaces: "key NAME"
|
||||
// is one element, and "port N" and "tls NAME" belong to the address
|
||||
// before them (forwarders { 192.0.2.1 port 53; }).
|
||||
func spaceElements(fields []string) []string {
|
||||
var out []string
|
||||
for i := 0; i < len(fields); i++ {
|
||||
f := fields[i]
|
||||
switch strings.ToLower(f) {
|
||||
case "key", "!key", "!":
|
||||
if i+1 < len(fields) {
|
||||
f += " " + fields[i+1]
|
||||
i++
|
||||
}
|
||||
if f[0] == '!' && strings.HasPrefix(strings.ToLower(f), "! key") && i+1 < len(fields) {
|
||||
f += " " + fields[i+1]
|
||||
i++
|
||||
}
|
||||
case "-":
|
||||
// A range with spaces around the dash: 10.0.0.1 - 10.0.0.9.
|
||||
if len(out) > 0 && i+1 < len(fields) {
|
||||
out[len(out)-1] += " - " + fields[i+1]
|
||||
i++
|
||||
continue
|
||||
}
|
||||
case "port", "tls":
|
||||
if len(out) > 0 && i+1 < len(fields) {
|
||||
out[len(out)-1] += " " + f + " " + fields[i+1]
|
||||
i++
|
||||
continue
|
||||
}
|
||||
}
|
||||
out = append(out, f)
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
+234
-14
@@ -4,6 +4,7 @@ package config
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/netip"
|
||||
"net/url"
|
||||
"os"
|
||||
@@ -38,6 +39,71 @@ type Config struct {
|
||||
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
|
||||
@@ -132,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
|
||||
@@ -226,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"`
|
||||
@@ -253,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
|
||||
@@ -313,16 +462,20 @@ 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}},
|
||||
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)},
|
||||
}
|
||||
}
|
||||
@@ -394,6 +547,15 @@ func (c *Config) check() error {
|
||||
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:
|
||||
@@ -450,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")
|
||||
}
|
||||
@@ -497,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: ""
|
||||
@@ -585,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
|
||||
@@ -594,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
|
||||
@@ -612,15 +784,44 @@ 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
|
||||
|
||||
# The registrar Porkbun (docs/dnssec.md#porkbun): with an API key, Jarl can
|
||||
# show and set a domain's name servers, glue records and DNSSEC DS records
|
||||
# there, after checking that the change won't break the domain. Off without
|
||||
# a key. Create the key at porkbun.com (Account -> API Access), switch on
|
||||
# "API Access" for the domain, and limit the key to that domain if you can.
|
||||
# The key can move your domain elsewhere: it lives only in this file (keep
|
||||
# it readable by root and jarld only: chown root:jarl, chmod 640), never in
|
||||
# Jarl's database or backups.
|
||||
# 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_...
|
||||
@@ -633,4 +834,23 @@ 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,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])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
package encdns
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"net/netip"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"jarl/internal/model"
|
||||
)
|
||||
|
||||
// Socket is a listening TCP socket on this host.
|
||||
type Socket struct {
|
||||
Addr netip.Addr
|
||||
Port int
|
||||
}
|
||||
|
||||
// ListeningSockets reads the host's listening TCP sockets from
|
||||
// /proc/net/tcp and tcp6 (Linux; nil elsewhere). It needs no privileges
|
||||
// and binds nothing. Tests replace it.
|
||||
var ListeningSockets = func() ([]Socket, error) {
|
||||
var out []Socket
|
||||
for _, f := range []string{"/proc/net/tcp", "/proc/net/tcp6"} {
|
||||
socks, err := parseProcNet(f)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
continue
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, socks...)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func parseProcNet(path string) ([]Socket, error) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer f.Close()
|
||||
var out []Socket
|
||||
sc := bufio.NewScanner(f)
|
||||
sc.Scan() // header
|
||||
for sc.Scan() {
|
||||
fields := strings.Fields(sc.Text())
|
||||
if len(fields) < 4 || fields[3] != "0A" { // 0A: LISTEN
|
||||
continue
|
||||
}
|
||||
s, err := parseProcAddr(fields[1])
|
||||
if err == nil {
|
||||
out = append(out, s)
|
||||
}
|
||||
}
|
||||
return out, sc.Err()
|
||||
}
|
||||
|
||||
// parseProcAddr parses "0100007F:0035": the address in 32-bit words of
|
||||
// host byte order (little-endian here), then the port.
|
||||
func parseProcAddr(s string) (Socket, error) {
|
||||
ha, hp, ok := strings.Cut(s, ":")
|
||||
if !ok {
|
||||
return Socket{}, fmt.Errorf("bad address %q", s)
|
||||
}
|
||||
b, err := hex.DecodeString(ha)
|
||||
if err != nil || (len(b) != 4 && len(b) != 16) {
|
||||
return Socket{}, fmt.Errorf("bad address %q", s)
|
||||
}
|
||||
for i := 0; i+4 <= len(b); i += 4 {
|
||||
b[i], b[i+1], b[i+2], b[i+3] = b[i+3], b[i+2], b[i+1], b[i]
|
||||
}
|
||||
addr, _ := netip.AddrFromSlice(b)
|
||||
port, err := strconv.ParseUint(hp, 16, 16)
|
||||
if err != nil {
|
||||
return Socket{}, err
|
||||
}
|
||||
return Socket{Addr: addr.Unmap(), Port: int(port)}, nil
|
||||
}
|
||||
|
||||
// Conflict is something else listening where a listener would go.
|
||||
type Conflict struct {
|
||||
Address string `json:"address"`
|
||||
Port int `json:"port"`
|
||||
What string `json:"what"` // who it is, when Jarl knows
|
||||
}
|
||||
|
||||
// PortCheck says which of addresses × ports something else listens on
|
||||
// already. named's own listeners (the setup's, now) and jarld's (known
|
||||
// listen addresses) are told apart.
|
||||
func PortCheck(st *model.State, addresses []string, ports []int, jarld []string) ([]Conflict, error) {
|
||||
socks, err := ListeningSockets()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// What named listens on now with DoT or DoH (it will again).
|
||||
ours := map[string]bool{}
|
||||
for _, l := range Listeners(st) {
|
||||
if l.Kind() != "plain" {
|
||||
for _, a := range l.Addrs {
|
||||
ours[a+"#"+strconv.Itoa(l.EffectivePort())] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
jarldOn := map[string]bool{}
|
||||
for _, l := range jarld {
|
||||
jarldOn[l] = true
|
||||
}
|
||||
var out []Conflict
|
||||
for _, as := range addresses {
|
||||
a, err := netip.ParseAddr(as)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
a = a.Unmap()
|
||||
for _, p := range ports {
|
||||
if ours[a.String()+"#"+strconv.Itoa(p)] {
|
||||
continue
|
||||
}
|
||||
for _, s := range socks {
|
||||
if s.Port != p || !(s.Addr == a || s.Addr.IsUnspecified() && s.Addr.Is4() == a.Is4() || s.Addr.IsUnspecified() && s.Addr.Is6()) {
|
||||
continue
|
||||
}
|
||||
what := "another program"
|
||||
for l := range jarldOn {
|
||||
if strings.HasSuffix(l, ":"+strconv.Itoa(p)) {
|
||||
what = "jarld (Jarl's web UI and API)"
|
||||
}
|
||||
}
|
||||
out = append(out, Conflict{Address: a.String(), Port: p, What: what})
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// FreeDoHPort returns the first of the usual DoH ports nothing listens on
|
||||
// at the addresses (0 when none is free).
|
||||
func FreeDoHPort(st *model.State, addresses []string, jarld []string) int {
|
||||
for _, p := range []int{443, 8443, 4443, 9443} {
|
||||
if c, err := PortCheck(st, addresses, []int{p}, jarld); err == nil && len(c) == 0 {
|
||||
return p
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
@@ -275,6 +275,22 @@ func TestBlockingPause(t *testing.T) {
|
||||
if _, err := env.e.Apply(context.Background(), rev.ID, env.admin, ApplyOptions{}); err != nil || env.state().Settings.BlockingPausedUntil != nil {
|
||||
t.Errorf("revert of a pause: %v, %v", err, env.state().Settings.BlockingPausedUntil)
|
||||
}
|
||||
// Until resumed: jarld never ends it, a resume does.
|
||||
res = env.submit(env.admin, NewRequest(OpBlockingPause, 0, BlockingPauseInput{Indefinitely: true}))
|
||||
if res.Ops[0].Summary != "pause blocking until resumed" || !env.state().Settings.BlockingPaused() {
|
||||
t.Errorf("indefinite pause: %q", res.Ops[0].Summary)
|
||||
}
|
||||
if f, _ := os.ReadFile(rulesFile); !strings.Contains(string(f), "*\tCNAME\trpz-passthru.\n") {
|
||||
t.Errorf("rules zone while paused indefinitely:\n%s", f)
|
||||
}
|
||||
env.e.Now = func() time.Time { return until.Add(1000 * time.Hour) }
|
||||
if got := env.e.ResumeBlocking(context.Background()); !got.IsZero() || !env.state().Settings.BlockingPausedIndefinitely {
|
||||
t.Errorf("an indefinite pause ended by itself: %v", got)
|
||||
}
|
||||
env.submit(env.admin, NewRequest(OpBlockingPause, 0, BlockingPauseInput{}))
|
||||
if env.state().Settings.BlockingPaused() {
|
||||
t.Error("resume didn't end an indefinite pause")
|
||||
}
|
||||
// At most a day.
|
||||
long := env.e.Now().Add(48 * time.Hour)
|
||||
if e := env.submitErr(env.admin, NewRequest(OpBlockingPause, 0, BlockingPauseInput{Until: &long})); e.Kind != KindBadRequest {
|
||||
|
||||
@@ -78,7 +78,7 @@ func (e *Engine) RefreshDue(ctx context.Context) {
|
||||
e.log.Warn("blocklists: loading their status failed", "err", err)
|
||||
return
|
||||
}
|
||||
out := render.Render(st, e.cfg.Paths, e.cfg.Extras)
|
||||
out := render.Render(st, e.cfg.Paths, e.extras(ctx))
|
||||
if err := e.ensureBlocklistFiles(out.Blocklists); err != nil {
|
||||
e.log.Warn("blocklists: creating empty zone files failed", "err", err)
|
||||
}
|
||||
@@ -335,7 +335,9 @@ func (l *limitedReader) Read(p []byte) (int, error) {
|
||||
|
||||
// CheckBlocking reports, for every view with blocking, whether name is
|
||||
// blocked or allowed there, and by which rule or list. It looks names up
|
||||
// the way named does: rules first, then the lists in order.
|
||||
// the way named does: rules first, then the lists in order. The answer is
|
||||
// for clients at the normal step; a list of a higher step that blocks the
|
||||
// name says so (Match.Step), and clients at step off skip every list.
|
||||
func (e *Engine) CheckBlocking(st *model.State, name string) ([]blocking.Match, error) {
|
||||
n, err := blocking.NormalizeName(name)
|
||||
if err != nil {
|
||||
@@ -349,8 +351,8 @@ func (e *Engine) CheckBlocking(st *model.State, name string) ([]blocking.Match,
|
||||
out = append(out, blocking.Match{View: z.View, Result: "none"})
|
||||
cur = &out[len(out)-1]
|
||||
}
|
||||
if cur.Result != "none" {
|
||||
continue // an earlier zone decided
|
||||
if cur.Result != "none" || z.IsGate() {
|
||||
continue // an earlier zone decided, or a gate (it never blocks)
|
||||
}
|
||||
if z.ListID == 0 {
|
||||
owner, act, ok := blocking.MatchRules(blocking.RuleEntries(render.ViewRules(st, z.ViewID)), n)
|
||||
@@ -372,7 +374,11 @@ func (e *Engine) CheckBlocking(st *model.State, name string) ([]blocking.Match,
|
||||
feeds[z.File] = entry
|
||||
}
|
||||
if entry != "" {
|
||||
cur.Result, cur.Entry, cur.List = "blocked", entry, st.Blocklists[z.ListID].Name
|
||||
b := st.Blocklists[z.ListID]
|
||||
cur.Result, cur.Entry, cur.List = "blocked", entry, b.Name
|
||||
if b.StepID != model.StepNormal {
|
||||
cur.Step = st.StepName(b.StepID)
|
||||
}
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
|
||||
@@ -117,6 +117,16 @@ func applyBlocklistPut(st *model.State, op model.Op, c *applyCtx) error {
|
||||
if in.Disabled != nil {
|
||||
b.Disabled = *in.Disabled
|
||||
}
|
||||
step, given, err := stepRef(st, in.Step, in.StepID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if given {
|
||||
if step == model.StepOff {
|
||||
return errf(KindBadRequest, "a blocklist starts at normal or a step above it, not off")
|
||||
}
|
||||
b.StepID = step
|
||||
}
|
||||
if in.Comment != nil {
|
||||
b.Comment = *in.Comment
|
||||
}
|
||||
@@ -226,6 +236,11 @@ func blockingUsers(st *model.State, viewID int64) []string {
|
||||
out = append(out, ruleLabel(r))
|
||||
}
|
||||
}
|
||||
for _, g := range st.SortedClientGroups() {
|
||||
if slices.Contains(g.ViewIDs, viewID) {
|
||||
out = append(out, "client group "+g.Name)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -282,9 +297,13 @@ func applyBlockingPause(st *model.State, op model.Op, c *applyCtx) error {
|
||||
return err
|
||||
}
|
||||
if in.Until != nil && in.Until.Sub(c.now) > MaxBlockingPause+time.Minute {
|
||||
return errf(KindBadRequest, "blocking can be paused for at most %s; disable lists to stop using them", MaxBlockingPause)
|
||||
return errf(KindBadRequest, "blocking can be paused for at most %s, or until it is resumed", MaxBlockingPause)
|
||||
}
|
||||
if in.Indefinitely && in.Until != nil {
|
||||
return errf(KindBadRequest, "a pause is either until a time or until resumed, not both")
|
||||
}
|
||||
st.Settings.BlockingPausedUntil = in.Until
|
||||
st.Settings.BlockingPausedIndefinitely = in.Indefinitely
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,270 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/netip"
|
||||
"strings"
|
||||
|
||||
"jarl/internal/iprange"
|
||||
"jarl/internal/model"
|
||||
)
|
||||
|
||||
// ---- blocking steps and client groups (docs/blocking.md)
|
||||
|
||||
// stepRef resolves a step given by name or ID. ok is false when neither
|
||||
// is given.
|
||||
func stepRef(st *model.State, name *string, id *int64) (int64, bool, error) {
|
||||
switch {
|
||||
case name != nil:
|
||||
s, found := st.StepByName(*name)
|
||||
if !found {
|
||||
return 0, true, errf(KindBadRequest, "blocking step %q does not exist (steps: %s)", *name, stepNames(st))
|
||||
}
|
||||
return s, true, nil
|
||||
case id != nil:
|
||||
if _, found := st.StepRank(*id); !found {
|
||||
return 0, true, errf(KindBadRequest, "blocking step %d does not exist", *id)
|
||||
}
|
||||
return *id, true, nil
|
||||
}
|
||||
return 0, false, nil
|
||||
}
|
||||
|
||||
// stepNames lists the steps for messages: "off, normal, strict".
|
||||
func stepNames(st *model.State) string {
|
||||
names := []string{"off", "normal"}
|
||||
for _, s := range st.OrderedBlockSteps() {
|
||||
names = append(names, s.Name)
|
||||
}
|
||||
return strings.Join(names, ", ")
|
||||
}
|
||||
|
||||
// NormalizeMember checks a client group member: an address, a prefix or
|
||||
// an ACL name (an ACL's own elements are checked by validation). It
|
||||
// returns the canonical form.
|
||||
func NormalizeMember(st *model.State, m string) (string, error) {
|
||||
m = strings.TrimSpace(m)
|
||||
if a, err := netip.ParseAddr(m); err == nil {
|
||||
if a.Zone() != "" {
|
||||
return "", errf(KindBadRequest, "%q: an address with a zone can't be matched", m)
|
||||
}
|
||||
return a.Unmap().String(), nil
|
||||
}
|
||||
if p, err := netip.ParsePrefix(m); err == nil {
|
||||
return p.Masked().String(), nil
|
||||
}
|
||||
if r, ok, err := iprange.Parse(m); ok {
|
||||
if err != nil {
|
||||
return "", errf(KindBadRequest, "%v", err)
|
||||
}
|
||||
return r.String(), nil
|
||||
}
|
||||
if a := st.ACLByName(m); a != nil {
|
||||
return a.Name, nil
|
||||
}
|
||||
return "", errf(KindBadRequest, "%q is not an address, a prefix (10.10.0.0/24), a range (10.10.0.20-10.10.0.49) or an ACL", m)
|
||||
}
|
||||
|
||||
func applyBlockStepPut(st *model.State, op model.Op, c *applyCtx) error {
|
||||
if err := c.require(blockingAction()); err != nil {
|
||||
return err
|
||||
}
|
||||
var in BlockStepInput
|
||||
if err := decode(op.Data, &in); err != nil {
|
||||
return err
|
||||
}
|
||||
var s *model.BlockStep
|
||||
if op.Kind == OpBlockStepCreate {
|
||||
if op.ID <= 0 || st.BlockSteps[op.ID] != nil {
|
||||
return errf(KindConflict, "step ID %d is already in use", op.ID)
|
||||
}
|
||||
if in.Name == nil {
|
||||
return errf(KindBadRequest, "a blocking step needs a name")
|
||||
}
|
||||
pos := 0
|
||||
for _, o := range st.BlockSteps {
|
||||
pos = max(pos, o.Position+1)
|
||||
}
|
||||
s = &model.BlockStep{ID: op.ID, Position: pos}
|
||||
} else {
|
||||
s = st.BlockSteps[op.ID]
|
||||
if s == nil {
|
||||
return errf(KindNotFound, "blocking step %d does not exist", op.ID)
|
||||
}
|
||||
if err := checkVersion(op, s.Version, "step "+s.Name); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if in.Name != nil {
|
||||
name := strings.ToLower(strings.TrimSpace(*in.Name))
|
||||
if !blocklistNameRe.MatchString(name) || name == "off" || name == "normal" {
|
||||
return errf(KindBadRequest, "step name %q: use 1 to 40 lowercase letters, digits and hyphens, like strict (off and normal are built in)", *in.Name)
|
||||
}
|
||||
if id, found := st.StepByName(name); found && id != s.ID {
|
||||
return errf(KindConflict, "step %s already exists", name)
|
||||
}
|
||||
s.Name = name
|
||||
}
|
||||
if in.Position != nil {
|
||||
s.Position = *in.Position
|
||||
}
|
||||
if in.Comment != nil {
|
||||
s.Comment = *in.Comment
|
||||
}
|
||||
s.Touch(c.user(), c.now)
|
||||
st.BlockSteps[s.ID] = s
|
||||
return nil
|
||||
}
|
||||
|
||||
func applyBlockStepDelete(st *model.State, op model.Op, c *applyCtx) error {
|
||||
if err := c.require(blockingAction()); err != nil {
|
||||
return err
|
||||
}
|
||||
s := st.BlockSteps[op.ID]
|
||||
if s == nil {
|
||||
return errf(KindNotFound, "blocking step %d does not exist", op.ID)
|
||||
}
|
||||
if err := checkVersion(op, s.Version, "step "+s.Name); err != nil {
|
||||
return err
|
||||
}
|
||||
if users := stepUsers(st, s.ID); len(users) > 0 {
|
||||
return errf(KindConflict, "step %s is used by %s; move them to another step first", s.Name, strings.Join(users, ", "))
|
||||
}
|
||||
delete(st.BlockSteps, s.ID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// stepUsers lists the blocklists and client groups at a step.
|
||||
func stepUsers(st *model.State, id int64) []string {
|
||||
var out []string
|
||||
for _, b := range st.SortedBlocklists() {
|
||||
if b.StepID == id {
|
||||
out = append(out, "blocklist "+b.Name)
|
||||
}
|
||||
}
|
||||
for _, g := range st.SortedClientGroups() {
|
||||
if g.StepID == id {
|
||||
out = append(out, "client group "+g.Name)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func applyClientGroupPut(st *model.State, op model.Op, c *applyCtx) error {
|
||||
if err := c.require(blockingAction()); err != nil {
|
||||
return err
|
||||
}
|
||||
var in ClientGroupInput
|
||||
if err := decode(op.Data, &in); err != nil {
|
||||
return err
|
||||
}
|
||||
var g *model.ClientGroup
|
||||
if op.Kind == OpClientGroupCreate {
|
||||
if op.ID <= 0 || st.ClientGroups[op.ID] != nil {
|
||||
return errf(KindConflict, "client group ID %d is already in use", op.ID)
|
||||
}
|
||||
if in.Name == nil {
|
||||
return errf(KindBadRequest, "a client group needs a name")
|
||||
}
|
||||
g = &model.ClientGroup{ID: op.ID, Members: []string{}, StepID: model.StepNormal, ViewIDs: []int64{}}
|
||||
} else {
|
||||
g = st.ClientGroups[op.ID]
|
||||
if g == nil {
|
||||
return errf(KindNotFound, "client group %d does not exist", op.ID)
|
||||
}
|
||||
if err := checkVersion(op, g.Version, "client group "+g.Name); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if in.Name != nil {
|
||||
name := strings.ToLower(strings.TrimSpace(*in.Name))
|
||||
if !blocklistNameRe.MatchString(name) {
|
||||
return errf(KindBadRequest, "client group name %q: use 1 to 40 lowercase letters, digits and hyphens, like kids", *in.Name)
|
||||
}
|
||||
if other := st.ClientGroupByName(name); other != nil && other.ID != g.ID {
|
||||
return errf(KindConflict, "client group %s already exists", name)
|
||||
}
|
||||
g.Name = name
|
||||
}
|
||||
if in.Members != nil {
|
||||
members := []string{}
|
||||
seen := map[string]bool{}
|
||||
for _, m := range *in.Members {
|
||||
if strings.TrimSpace(m) == "" {
|
||||
continue
|
||||
}
|
||||
n, err := NormalizeMember(st, m)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !seen[n] {
|
||||
seen[n] = true
|
||||
members = append(members, n)
|
||||
}
|
||||
}
|
||||
g.Members = members
|
||||
}
|
||||
step, given, err := stepRef(st, in.Step, in.StepID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if given {
|
||||
g.StepID = step
|
||||
}
|
||||
ids, err := viewIDs(st, in.Views, in.ViewIDs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if ids != nil {
|
||||
g.ViewIDs = ids
|
||||
}
|
||||
if in.Comment != nil {
|
||||
g.Comment = *in.Comment
|
||||
}
|
||||
g.Touch(c.user(), c.now)
|
||||
st.ClientGroups[g.ID] = g
|
||||
return nil
|
||||
}
|
||||
|
||||
func applyClientGroupDelete(st *model.State, op model.Op, c *applyCtx) error {
|
||||
if err := c.require(blockingAction()); err != nil {
|
||||
return err
|
||||
}
|
||||
g := st.ClientGroups[op.ID]
|
||||
if g == nil {
|
||||
return errf(KindNotFound, "client group %d does not exist", op.ID)
|
||||
}
|
||||
if err := checkVersion(op, g.Version, "client group "+g.Name); err != nil {
|
||||
return err
|
||||
}
|
||||
delete(st.ClientGroups, g.ID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---- expansion
|
||||
|
||||
func (x *expander) blockStepCreate(req Request) ([]model.Op, error) {
|
||||
return x.simpleCreate(req, OpBlockStepCreate, func(data json.RawMessage) (string, error) {
|
||||
var in BlockStepInput
|
||||
if err := decode(data, &in); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if in.Name == nil {
|
||||
return "", errf(KindBadRequest, "a blocking step needs a name")
|
||||
}
|
||||
return "add blocking step " + strings.ToLower(*in.Name), nil
|
||||
})
|
||||
}
|
||||
|
||||
func (x *expander) clientGroupCreate(req Request) ([]model.Op, error) {
|
||||
return x.simpleCreate(req, OpClientGroupCreate, func(data json.RawMessage) (string, error) {
|
||||
var in ClientGroupInput
|
||||
if err := decode(data, &in); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if in.Name == nil {
|
||||
return "", errf(KindBadRequest, "a client group needs a name")
|
||||
}
|
||||
return "add client group " + strings.ToLower(*in.Name), nil
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"jarl/internal/model"
|
||||
)
|
||||
|
||||
func clientGroup(name, step string, members ...string) Request {
|
||||
return NewRequest(OpClientGroupCreate, 0, ClientGroupInput{Name: &name, Step: &step, Members: &members})
|
||||
}
|
||||
|
||||
func TestClientGroups(t *testing.T) {
|
||||
env := newEnv(t)
|
||||
setupZone(env)
|
||||
_, srv := newListServer(t, "||ads.example^\n")
|
||||
env.submit(env.admin,
|
||||
NewRequest(OpBlockStepCreate, 0, BlockStepInput{Name: model.Ptr("strict")}),
|
||||
NewRequest(OpACLCreate, 0, ACLInput{Name: model.Ptr("tablets"), Elements: &[]string{"10.0.0.50", "10.0.0.51"}}),
|
||||
)
|
||||
env.submit(env.admin,
|
||||
blocklist("ads", srv.URL+"/a.txt"),
|
||||
NewRequest(OpBlocklistCreate, 0, BlocklistInput{Name: model.Ptr("social"), URL: model.Ptr(srv.URL + "/s.txt"), Step: model.Ptr("strict")}),
|
||||
clientGroup("work", "off", "10.0.0.20"),
|
||||
clientGroup("kids", "strict", "tablets", "2001:db8:1::/64"),
|
||||
)
|
||||
conf, _ := os.ReadFile(filepath.Join(env.dir, "etc/named.conf"))
|
||||
c := string(conf)
|
||||
// Off clients pass before the normal lists, everyone below strict
|
||||
// before the strict ones; gates aren't logged.
|
||||
want := "response-policy {\n" +
|
||||
"\t\tzone \"off.gate.jarl.invalid\" min-update-interval 0 log no;\n" +
|
||||
"\t\tzone \"ads.blocklist.jarl.invalid\" min-update-interval 0 log no;\n" +
|
||||
"\t\tzone \"strict.gate.jarl.invalid\" min-update-interval 0 log no;\n" +
|
||||
"\t\tzone \"social.blocklist.jarl.invalid\" min-update-interval 0 log no;\n"
|
||||
if !strings.Contains(c, want) {
|
||||
t.Errorf("response-policy:\n%s", c)
|
||||
}
|
||||
gate := func(name string) string {
|
||||
b, err := os.ReadFile(filepath.Join(env.dir, "zones/_default", name+".gate.jarl.invalid.rpz"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
if g := gate("off"); !strings.Contains(g, "32.20.0.0.10.rpz-client-ip\tCNAME\trpz-passthru.\n") || strings.Count(g, "rpz-client-ip") != 1 {
|
||||
t.Errorf("off gate:\n%s", g)
|
||||
}
|
||||
// Everyone but the kids' tablets (via the ACL) and network passes.
|
||||
sg := gate("strict")
|
||||
for _, notIn := range []string{"32.50.0.0.10.rpz-client-ip", "32.51.0.0.10.rpz-client-ip", "64.zz.1.db8.2001.rpz-client-ip"} {
|
||||
if strings.Contains(sg, notIn) {
|
||||
t.Errorf("strict gate lets %s through:\n%s", notIn, sg)
|
||||
}
|
||||
}
|
||||
// 10.0.0.0/27 holds the work laptop, 10.0.0.52/30 the next addresses
|
||||
// after the tablets, 128.0.0.0/1 the upper half of IPv4.
|
||||
for _, in := range []string{"27.0.0.0.10.rpz-client-ip", "30.52.0.0.10.rpz-client-ip", "1.0.0.0.128.rpz-client-ip"} {
|
||||
if !strings.Contains(sg, in) {
|
||||
t.Errorf("strict gate lacks %s:\n%s", in, sg)
|
||||
}
|
||||
}
|
||||
|
||||
st := env.state()
|
||||
strict, _ := st.StepByName("strict")
|
||||
// A step that lists or groups use can't be deleted.
|
||||
if e := env.submitErr(env.admin, NewRequest(OpBlockStepDelete, strict, nil)); e.Kind != KindConflict || !strings.Contains(e.Message, "blocklist social") {
|
||||
t.Errorf("deleting a used step: %+v", e)
|
||||
}
|
||||
// One client, one group; ACLs must hold addresses only.
|
||||
if e := env.submitErr(env.admin, clientGroup("dup", "normal", "10.0.0.20")); e.Kind != KindValidation || !strings.Contains(e.Message, "10.0.0.20/32 is in client groups") {
|
||||
t.Errorf("an address in two groups: %+v", e)
|
||||
}
|
||||
env.submit(env.admin, NewRequest(OpACLCreate, 0, ACLInput{Name: model.Ptr("keyed"), Elements: &[]string{"localnets"}}))
|
||||
if e := env.submitErr(env.admin, clientGroup("bad", "normal", "keyed")); e.Kind != KindValidation || !strings.Contains(e.Message, "isn't an address") {
|
||||
t.Errorf("an ACL with a key: %+v", e)
|
||||
}
|
||||
for _, req := range []Request{clientGroup("x", "nope", "10.1.1.1"), clientGroup("y", "normal", "not-an-acl"), clientGroup("Bad Name", "normal")} {
|
||||
if e := env.submitErr(env.admin, req); e.Kind != KindBadRequest {
|
||||
t.Errorf("%s: %+v", req.Data, e)
|
||||
}
|
||||
}
|
||||
// A more specific address in another group is fine: it wins.
|
||||
env.submit(env.admin, clientGroup("guest", "normal", "2001:db8:1::9"))
|
||||
if sg := gate("strict"); !strings.Contains(sg, "128.9.zz.1.db8.2001.rpz-client-ip") {
|
||||
t.Errorf("the guest inside the kids' network passes the strict gate:\n%s", sg)
|
||||
}
|
||||
|
||||
// The kids' group gone: the strict list blocks nobody (a warning).
|
||||
kids := env.state().ClientGroupByName("kids")
|
||||
res := env.submit(env.admin, NewRequest(OpClientGroupDelete, kids.ID, nil))
|
||||
found := false
|
||||
for _, p := range res.Problems {
|
||||
found = found || p.Code == "blocklist_step_unused"
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("no warning for a step no group reaches: %+v", res.Problems)
|
||||
}
|
||||
// Reverting brings it back.
|
||||
rev, err := env.e.Revert(context.Background(), res.ChangesetID, env.admin)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(rev.Ops) != 1 {
|
||||
t.Errorf("revert ops: %+v (only the deleted group should come back)", rev.Ops)
|
||||
}
|
||||
if _, err := env.e.Apply(context.Background(), rev.ID, env.admin, ApplyOptions{}); err != nil || env.state().ClientGroupByName("kids") == nil {
|
||||
t.Fatalf("revert: %v", err)
|
||||
}
|
||||
// Renaming the ACL renames the member; deleting it is refused.
|
||||
tablets := env.state().ACLByName("tablets")
|
||||
env.submit(env.admin, NewRequest(OpACLUpdate, tablets.ID, ACLInput{Name: model.Ptr("pads")}))
|
||||
if g := env.state().ClientGroupByName("kids"); g.Members[0] != "pads" {
|
||||
t.Errorf("ACL rename: members %v", g.Members)
|
||||
}
|
||||
if e := env.submitErr(env.admin, NewRequest(OpACLDelete, tablets.ID, nil)); e.Kind != KindConflict || !strings.Contains(e.Message, "client group kids") {
|
||||
t.Errorf("deleting an ACL a group uses: %+v", e)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"jarl/internal/dnsname"
|
||||
"jarl/internal/model"
|
||||
)
|
||||
|
||||
// ViewSet is what one view has for a name and type.
|
||||
type ViewSet struct {
|
||||
View string `json:"view"`
|
||||
ZoneID int64 `json:"zone_id"`
|
||||
Records []string `json:"records"` // data (with TTL and "(disabled)" when set), sorted
|
||||
IDs []int64 `json:"ids"` // the records, in the same order
|
||||
Data []string `json:"data"` // their data alone, in the same order
|
||||
TTL *uint32 `json:"ttl,omitempty"` // the first record's own TTL
|
||||
Linked string `json:"linked,omitempty"` // shared or local, in a linked zone
|
||||
}
|
||||
|
||||
// CompareRow is one name and type across views.
|
||||
type CompareRow struct {
|
||||
Name string `json:"name"` // relative to the zone
|
||||
FQDN string `json:"fqdn"`
|
||||
Type string `json:"type"`
|
||||
Same bool `json:"same"`
|
||||
Views []ViewSet `json:"views"` // in the order of the zones given
|
||||
}
|
||||
|
||||
// CompareZones lines up the records of zones of the same name in
|
||||
// different views (docs/views.md#linked-zones), one row per name and type,
|
||||
// sorted like a zone file.
|
||||
func CompareZones(st *model.State, zones []*model.Zone) []CompareRow {
|
||||
type key struct{ name, typ string }
|
||||
sets := make([]map[key][]*model.Record, len(zones))
|
||||
var keys []key
|
||||
seen := map[key]bool{}
|
||||
var all []*model.Record
|
||||
for i, z := range zones {
|
||||
sets[i] = map[key][]*model.Record{}
|
||||
for _, r := range st.ZoneRecords(z.ID) {
|
||||
k := key{r.Name, r.Type}
|
||||
sets[i][k] = append(sets[i][k], r)
|
||||
if !seen[k] {
|
||||
seen[k] = true
|
||||
keys = append(keys, k)
|
||||
all = append(all, r)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(zones) > 0 {
|
||||
// Zone file order: the apex first, then by name and type.
|
||||
model.SortRecords(all, zones[0])
|
||||
keys = keys[:0]
|
||||
for _, r := range all {
|
||||
keys = append(keys, key{r.Name, r.Type})
|
||||
}
|
||||
}
|
||||
var out []CompareRow
|
||||
for _, k := range keys {
|
||||
row := CompareRow{FQDN: k.name, Type: k.typ, Same: true}
|
||||
if len(zones) > 0 {
|
||||
row.Name = relName(k.name, zones[0].Name)
|
||||
}
|
||||
var first string
|
||||
for i, z := range zones {
|
||||
vs := ViewSet{View: st.ViewName(z), ZoneID: z.ID, Records: []string{}, IDs: []int64{}, Data: []string{}}
|
||||
rs := slices.Clone(sets[i][k])
|
||||
slices.SortFunc(rs, func(a, b *model.Record) int { return strings.Compare(strings.ToLower(a.Data), strings.ToLower(b.Data)) })
|
||||
for _, r := range rs {
|
||||
d := r.Data
|
||||
if r.TTL != nil {
|
||||
d = fmt.Sprintf("%s (TTL %d)", d, *r.TTL)
|
||||
}
|
||||
if r.Disabled {
|
||||
d += " (disabled)"
|
||||
}
|
||||
vs.Records = append(vs.Records, d)
|
||||
vs.IDs = append(vs.IDs, r.ID)
|
||||
vs.Data = append(vs.Data, r.Data)
|
||||
if vs.TTL == nil && r.TTL != nil {
|
||||
vs.TTL = model.Ptr(*r.TTL)
|
||||
}
|
||||
}
|
||||
if z.LinkGroup != 0 && len(rs) > 0 {
|
||||
vs.Linked = "shared"
|
||||
if rs[0].SharedID == 0 {
|
||||
vs.Linked = "local"
|
||||
}
|
||||
}
|
||||
sig := strings.Join(vs.Records, "\n")
|
||||
if i == 0 {
|
||||
first = sig
|
||||
} else if sig != first {
|
||||
row.Same = false
|
||||
}
|
||||
row.Views = append(row.Views, vs)
|
||||
}
|
||||
out = append(out, row)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func relName(name, zone string) string {
|
||||
if name == zone {
|
||||
return "@"
|
||||
}
|
||||
return strings.TrimSuffix(name, "."+zone)
|
||||
}
|
||||
|
||||
// CompareReport is a zone in one view (the base) next to the same zone in
|
||||
// other views (docs/views.md#comparing-views): what the other views answer
|
||||
// differently, the names only some views have, and how much is the same.
|
||||
type CompareReport struct {
|
||||
Zone string `json:"zone"`
|
||||
Base string `json:"base"` // the view compared from
|
||||
Views []string `json:"views"` // the base first
|
||||
Linked bool `json:"linked"` // all in one link
|
||||
// Summary is, per other view, how it differs from the base.
|
||||
Summary []CompareSummary `json:"summary"`
|
||||
// Different are names and types more than one view has, with
|
||||
// different records.
|
||||
Different []CompareRow `json:"different"`
|
||||
// Only are the names and types only some views have, by the views
|
||||
// that have them.
|
||||
Only []CompareOnly `json:"only"`
|
||||
Same int `json:"same"`
|
||||
// SameRows are the ones that are the same (when asked for).
|
||||
SameRows []CompareRow `json:"same_rows,omitempty"`
|
||||
}
|
||||
|
||||
// CompareSummary counts how a view differs from the base view.
|
||||
type CompareSummary struct {
|
||||
View string `json:"view"`
|
||||
Different int `json:"different"` // both have it, with different records
|
||||
OnlyBase int `json:"only_base"` // the base has it, this view doesn't
|
||||
OnlyOther int `json:"only_other"` // this view has it, the base doesn't
|
||||
Same int `json:"same"`
|
||||
}
|
||||
|
||||
// CompareOnly are names and types that only some views have.
|
||||
type CompareOnly struct {
|
||||
Views []string `json:"views"`
|
||||
Rows []CompareRow `json:"rows"`
|
||||
}
|
||||
|
||||
// CompareFilter narrows a comparison to a name, and optionally a type.
|
||||
type CompareFilter struct {
|
||||
Name string // relative ("www", "@") or absolute
|
||||
Type string
|
||||
All bool // include what is the same
|
||||
}
|
||||
|
||||
// Compare builds the report for zones (the base first). Apex NS records are
|
||||
// left out: they name the servers, which views often list differently on
|
||||
// purpose.
|
||||
func Compare(st *model.State, zones []*model.Zone, f CompareFilter) *CompareReport {
|
||||
rep := &CompareReport{Zone: dnsname.Display(zones[0].Name), Base: st.ViewName(zones[0]), Different: []CompareRow{}, Only: []CompareOnly{}, Linked: true}
|
||||
for _, z := range zones {
|
||||
rep.Views = append(rep.Views, st.ViewName(z))
|
||||
rep.Linked = rep.Linked && z.LinkGroup != 0 && z.LinkGroup == zones[0].LinkGroup
|
||||
}
|
||||
for _, v := range rep.Views[1:] {
|
||||
rep.Summary = append(rep.Summary, CompareSummary{View: v})
|
||||
}
|
||||
want := ""
|
||||
if f.Name != "" {
|
||||
if o, err := dnsname.Owner(f.Name, zones[0].Name); err == nil {
|
||||
want = o
|
||||
}
|
||||
}
|
||||
only := map[string]int{}
|
||||
for _, row := range CompareZones(st, zones) {
|
||||
if row.FQDN == zones[0].Name && row.Type == "NS" {
|
||||
continue
|
||||
}
|
||||
if want != "" && (row.FQDN != want || f.Type != "" && !strings.EqualFold(row.Type, f.Type)) {
|
||||
continue
|
||||
}
|
||||
base := row.Views[0]
|
||||
for i, vs := range row.Views[1:] {
|
||||
s := &rep.Summary[i]
|
||||
switch {
|
||||
case len(base.Records) > 0 && len(vs.Records) > 0 && slices.Equal(base.Records, vs.Records):
|
||||
s.Same++
|
||||
case len(base.Records) > 0 && len(vs.Records) > 0:
|
||||
s.Different++
|
||||
case len(base.Records) > 0:
|
||||
s.OnlyBase++
|
||||
case len(vs.Records) > 0:
|
||||
s.OnlyOther++
|
||||
}
|
||||
}
|
||||
if row.Same {
|
||||
rep.Same++
|
||||
if f.All || want != "" {
|
||||
rep.SameRows = append(rep.SameRows, row)
|
||||
}
|
||||
continue
|
||||
}
|
||||
var having []string
|
||||
versions := map[string]bool{}
|
||||
for _, vs := range row.Views {
|
||||
if len(vs.Records) > 0 {
|
||||
having = append(having, vs.View)
|
||||
versions[strings.Join(vs.Records, "\n")] = true
|
||||
}
|
||||
}
|
||||
if len(versions) > 1 {
|
||||
rep.Different = append(rep.Different, row)
|
||||
continue
|
||||
}
|
||||
k := strings.Join(having, ",")
|
||||
i, ok := only[k]
|
||||
if !ok {
|
||||
i = len(rep.Only)
|
||||
only[k] = i
|
||||
rep.Only = append(rep.Only, CompareOnly{Views: having})
|
||||
}
|
||||
rep.Only[i].Rows = append(rep.Only[i].Rows, row)
|
||||
}
|
||||
// The base's own names first, then by how many views have them.
|
||||
slices.SortStableFunc(rep.Only, func(a, b CompareOnly) int {
|
||||
ab, bb := slices.Contains(a.Views, rep.Base), slices.Contains(b.Views, rep.Base)
|
||||
if ab != bb {
|
||||
if ab {
|
||||
return -1
|
||||
}
|
||||
return 1
|
||||
}
|
||||
return len(b.Views) - len(a.Views)
|
||||
})
|
||||
return rep
|
||||
}
|
||||
@@ -335,5 +335,8 @@ func fileDiff(path string, old []byte, existed bool, new []byte, removed, secret
|
||||
if d != "" && !strings.HasSuffix(d, "\n") {
|
||||
d += "\n"
|
||||
}
|
||||
if strings.HasSuffix(path, ".conf") {
|
||||
d = labelHunks(d, string(old), string(new))
|
||||
}
|
||||
return d
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// hunkHeader matches a unified diff hunk header; the line counts are left
|
||||
// out when they are 1.
|
||||
var hunkHeader = regexp.MustCompile(`^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@$`)
|
||||
|
||||
// labelHunks adds the blocks a hunk's first change is in to its header,
|
||||
// like git does with function names, so a change to named.conf says where
|
||||
// it is:
|
||||
//
|
||||
// @@ -50,6 +49,8 @@ view "internal" {
|
||||
//
|
||||
// Removed lines are looked up in the old file, added ones in the new.
|
||||
func labelHunks(diff, old, new string) string {
|
||||
if !strings.Contains(diff, "\n@@ ") {
|
||||
return diff
|
||||
}
|
||||
oldCtx, newCtx := blockContext(old), blockContext(new)
|
||||
lines := strings.SplitAfter(diff, "\n")
|
||||
for i, line := range lines {
|
||||
m := hunkHeader.FindStringSubmatch(strings.TrimSuffix(line, "\n"))
|
||||
if m == nil {
|
||||
continue
|
||||
}
|
||||
o, _ := strconv.Atoi(m[1])
|
||||
n, _ := strconv.Atoi(m[2])
|
||||
label := ""
|
||||
hunk:
|
||||
for _, l := range lines[i+1:] {
|
||||
switch {
|
||||
case strings.HasPrefix(l, "-"):
|
||||
label = at(oldCtx, o)
|
||||
break hunk
|
||||
case strings.HasPrefix(l, "+"):
|
||||
label = at(newCtx, n)
|
||||
break hunk
|
||||
case strings.HasPrefix(l, " "):
|
||||
o, n = o+1, n+1
|
||||
case strings.HasPrefix(l, `\`):
|
||||
default:
|
||||
break hunk
|
||||
}
|
||||
}
|
||||
if label != "" {
|
||||
lines[i] = strings.TrimSuffix(line, "\n") + " " + label + "\n"
|
||||
}
|
||||
}
|
||||
return strings.Join(lines, "")
|
||||
}
|
||||
|
||||
// at returns the context of line n (1-based), or "".
|
||||
func at(ctx []string, n int) string {
|
||||
if n < 1 || n > len(ctx) {
|
||||
return ""
|
||||
}
|
||||
return ctx[n-1]
|
||||
}
|
||||
|
||||
// blockContext returns, for each line of a named.conf file, the blocks
|
||||
// open at its start: `view "internal" { zone "example.com" {`, or "" at
|
||||
// the top level. It knows named.conf's strings and comments.
|
||||
func blockContext(text string) []string {
|
||||
var (
|
||||
out []string
|
||||
stack []string
|
||||
stmt strings.Builder // the current statement, up to a { or ;
|
||||
inStr bool
|
||||
inBlock bool // in a /* */ comment
|
||||
)
|
||||
label := func() string {
|
||||
if len(stack) == 0 {
|
||||
return ""
|
||||
}
|
||||
return strings.Join(stack, " { ") + " {"
|
||||
}
|
||||
for _, line := range strings.SplitAfter(text, "\n") {
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
out = append(out, label())
|
||||
for i := 0; i < len(line); i++ {
|
||||
c := line[i]
|
||||
switch {
|
||||
case inBlock:
|
||||
if c == '*' && i+1 < len(line) && line[i+1] == '/' {
|
||||
inBlock = false
|
||||
i++
|
||||
}
|
||||
case inStr:
|
||||
stmt.WriteByte(c)
|
||||
if c == '\\' && i+1 < len(line) {
|
||||
stmt.WriteByte(line[i+1])
|
||||
i++
|
||||
} else if c == '"' {
|
||||
inStr = false
|
||||
}
|
||||
case c == '"':
|
||||
inStr = true
|
||||
stmt.WriteByte(c)
|
||||
case c == '#' || c == '/' && i+1 < len(line) && line[i+1] == '/':
|
||||
i = len(line) // a comment to the end of the line
|
||||
case c == '/' && i+1 < len(line) && line[i+1] == '*':
|
||||
inBlock = true
|
||||
i++
|
||||
case c == '{':
|
||||
stack = append(stack, shortLabel(stmt.String()))
|
||||
stmt.Reset()
|
||||
case c == '}':
|
||||
if len(stack) > 0 {
|
||||
stack = stack[:len(stack)-1]
|
||||
}
|
||||
stmt.Reset()
|
||||
case c == ';':
|
||||
stmt.Reset()
|
||||
default:
|
||||
stmt.WriteByte(c)
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// shortLabel tidies the text before a { for a hunk header.
|
||||
func shortLabel(s string) string {
|
||||
s = strings.Join(strings.Fields(s), " ")
|
||||
if len(s) > 60 {
|
||||
s = s[:57] + "..."
|
||||
}
|
||||
return s
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestLabelHunks(t *testing.T) {
|
||||
old := `// header
|
||||
options {
|
||||
directory "/var/cache/bind";
|
||||
forwarders { 1.1.1.1; 9.9.9.9; };
|
||||
listen-on { any; };
|
||||
recursion no;
|
||||
version "a { b"; # a { in a string and comments: /* {
|
||||
dnssec-validation auto;
|
||||
};
|
||||
|
||||
view "internal" {
|
||||
match-clients { 10.0.0.0/8; };
|
||||
recursion yes;
|
||||
allow-transfer { localhost; };
|
||||
/* a { comment
|
||||
over lines */
|
||||
zone "example.com" {
|
||||
type primary;
|
||||
file "example.com.db";
|
||||
notify yes;
|
||||
};
|
||||
zone "other.example" { type primary; file "o.db"; };
|
||||
};
|
||||
`
|
||||
new := strings.Replace(old, "\tforwarders { 1.1.1.1; 9.9.9.9; };\n", "", 1)
|
||||
new = strings.Replace(new, "\tallow-transfer { localhost; };\n", "\tallow-transfer { localhost; };\n\tforward first;\n\tforwarders { 1.1.1.1; 9.9.9.9; };\n", 1)
|
||||
new = strings.Replace(new, "\t\tnotify yes;\n", "\t\tnotify no;\n", 1)
|
||||
d := fileDiff("/etc/bind/named.conf", []byte(old), true, []byte(new), false, false)
|
||||
var headers []string
|
||||
for _, l := range strings.Split(d, "\n") {
|
||||
if strings.HasPrefix(l, "@@") {
|
||||
headers = append(headers, l)
|
||||
}
|
||||
}
|
||||
want := []string{
|
||||
`@@ -1,7 +1,6 @@ options {`,
|
||||
`@@ -12,12 +11,14 @@ view "internal" {`,
|
||||
}
|
||||
if strings.Join(headers, "\n") != strings.Join(want, "\n") {
|
||||
t.Errorf("headers:\n%s\n\ndiff:\n%s", strings.Join(headers, "\n"), d)
|
||||
}
|
||||
|
||||
// The first change of a hunk decides, and a change inside a zone
|
||||
// names both blocks.
|
||||
new2 := strings.Replace(old, "\t\tnotify yes;\n", "\t\tnotify no;\n", 1)
|
||||
d = fileDiff("/etc/bind/named.conf", []byte(old), true, []byte(new2), false, false)
|
||||
if !strings.Contains(d, ` @@ view "internal" { zone "example.com" {`+"\n") {
|
||||
t.Errorf("zone change:\n%s", d)
|
||||
}
|
||||
// Zone files aren't named.conf syntax.
|
||||
d = fileDiff("/var/lib/bind/example.com.db", []byte(old), true, []byte(new2), false, false)
|
||||
if strings.Contains(d, "@@ view") {
|
||||
t.Errorf("zone file labelled:\n%s", d)
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package engine
|
||||
import (
|
||||
"fmt"
|
||||
"reflect"
|
||||
"slices"
|
||||
"sort"
|
||||
|
||||
"jarl/internal/dnsname"
|
||||
@@ -17,13 +18,15 @@ func DiffOps(from, to *model.State) []model.Op {
|
||||
add := func(kind string, id int64, base int, data any, summary string) {
|
||||
ops = append(ops, newOp(kind, id, base, data, summary))
|
||||
}
|
||||
if !reflect.DeepEqual(from.Settings.BlockingPausedUntil, to.Settings.BlockingPausedUntil) {
|
||||
add(OpBlockingPause, 0, 0, BlockingPauseInput{Until: to.Settings.BlockingPausedUntil}, map[bool]string{true: "pause blocking", false: "resume blocking"}[to.Settings.BlockingPausedUntil != nil])
|
||||
if !reflect.DeepEqual(from.Settings.BlockingPausedUntil, to.Settings.BlockingPausedUntil) || from.Settings.BlockingPausedIndefinitely != to.Settings.BlockingPausedIndefinitely {
|
||||
add(OpBlockingPause, 0, 0, BlockingPauseInput{Until: to.Settings.BlockingPausedUntil, Indefinitely: to.Settings.BlockingPausedIndefinitely}, map[bool]string{true: "pause blocking", false: "resume blocking"}[to.Settings.BlockingPaused()])
|
||||
}
|
||||
if !reflect.DeepEqual(stripVersion(from.Settings), stripVersion(to.Settings)) {
|
||||
s := to.Settings
|
||||
add(OpSettingsUpdate, 0, 0, SettingsInput{DefaultTTL: &s.DefaultTTL, Nameservers: model.Ptr(append([]string{}, s.Nameservers...)), RName: &s.RName,
|
||||
Refresh: &s.Refresh, Retry: &s.Retry, Expire: &s.Expire, NegativeTTL: &s.NegativeTTL, SerialPolicy: &s.SerialPolicy}, "restore settings")
|
||||
Refresh: &s.Refresh, Retry: &s.Retry, Expire: &s.Expire, NegativeTTL: &s.NegativeTTL, SerialPolicy: &s.SerialPolicy,
|
||||
Exposure: &s.Exposure, Acknowledged: model.Ptr(slices.Clone(s.Acknowledged)), DismissedReverse: model.Ptr(slices.Clone(s.DismissedReverse)),
|
||||
EncryptedDNSHosts: model.Ptr(slices.Clone(s.EncryptedDNSHosts))}, "restore settings")
|
||||
}
|
||||
|
||||
for _, k := range sortedKeys(to.Keys) {
|
||||
@@ -47,7 +50,9 @@ func DiffOps(from, to *model.State) []model.Op {
|
||||
for _, k := range sortedKeys(to.Views) {
|
||||
n := to.Views[k]
|
||||
in := ViewInput{Name: &n.Name, MatchClients: model.Ptr(append([]string{}, n.MatchClients...)),
|
||||
MatchDestinations: model.Ptr(append([]string{}, n.MatchDestinations...)), Comment: &n.Comment, Position: model.Ptr(n.Position)}
|
||||
MatchDestinations: model.Ptr(append([]string{}, n.MatchDestinations...)), Comment: &n.Comment, Position: model.Ptr(n.Position),
|
||||
Exposure: model.Ptr(n.Exposure), Acknowledged: model.Ptr(slices.Clone(n.Acknowledged)),
|
||||
ReverseSuggestions: model.Ptr(!n.ReverseSuggestionsOff), DismissedReverse: model.Ptr(slices.Clone(n.DismissedReverse))}
|
||||
if n.MatchRecursiveOnly != nil {
|
||||
in.MatchRecursiveOnly = model.Some(*n.MatchRecursiveOnly)
|
||||
} else {
|
||||
@@ -63,7 +68,7 @@ func DiffOps(from, to *model.State) []model.Op {
|
||||
n := to.Zones[k]
|
||||
s := n.SOA
|
||||
in := ZoneInput{Name: &n.Name, ViewID: &n.ViewID, Type: &n.Type, InView: &n.InView, File: &n.File,
|
||||
DefaultTTL: &n.DefaultTTL, SerialPolicy: &n.SerialPolicy, DNSSECPolicy: &n.DNSSECPolicy, Comment: &n.Comment}
|
||||
DefaultTTL: &n.DefaultTTL, SerialPolicy: &n.SerialPolicy, DNSSECPolicy: &n.DNSSECPolicy, Comment: &n.Comment, Disabled: &n.Disabled, LinkGroup: model.Ptr(n.LinkGroup), AutoPTR: model.Ptr(n.AutoPTR)}
|
||||
if n.Type == model.ZonePrimary {
|
||||
in.SOA = &SOAInput{MName: &s.MName, RName: &s.RName, Serial: &s.Serial, Refresh: &s.Refresh, Retry: &s.Retry, Expire: &s.Expire, Minimum: &s.Minimum}
|
||||
}
|
||||
@@ -91,7 +96,7 @@ func DiffOps(from, to *model.State) []model.Op {
|
||||
}
|
||||
for _, k := range sortedKeys(to.Records) {
|
||||
n := to.Records[k]
|
||||
in := RecordInput{ZoneID: n.ZoneID, Name: &n.Name, Type: &n.Type, Data: &n.Data, Comment: &n.Comment, Disabled: &n.Disabled}
|
||||
in := RecordInput{ZoneID: n.ZoneID, Name: &n.Name, Type: &n.Type, Data: &n.Data, Comment: &n.Comment, Disabled: &n.Disabled, SharedID: model.Ptr(n.SharedID)}
|
||||
if n.TTL != nil {
|
||||
in.TTL = model.Some(*n.TTL)
|
||||
} else {
|
||||
@@ -104,9 +109,19 @@ func DiffOps(from, to *model.State) []model.Op {
|
||||
add(OpRecordUpdate, k, o.Version, in, fmt.Sprintf("restore %s %s %s", n.Name, n.Type, n.Data))
|
||||
}
|
||||
}
|
||||
// Steps before the lists and groups that use them.
|
||||
for _, k := range sortedKeys(to.BlockSteps) {
|
||||
n := to.BlockSteps[k]
|
||||
in := BlockStepInput{Name: &n.Name, Position: model.Ptr(n.Position), Comment: &n.Comment}
|
||||
if o, ok := from.BlockSteps[k]; !ok {
|
||||
add(OpBlockStepCreate, k, 0, in, "add blocking step "+n.Name)
|
||||
} else if !sameObject(o, n) {
|
||||
add(OpBlockStepUpdate, k, o.Version, in, "restore blocking step "+n.Name)
|
||||
}
|
||||
}
|
||||
for _, k := range sortedKeys(to.Blocklists) {
|
||||
n := to.Blocklists[k]
|
||||
in := BlocklistInput{Name: &n.Name, URL: &n.URL, ViewIDs: model.Ptr(append([]int64{}, n.ViewIDs...)), Disabled: &n.Disabled, Comment: &n.Comment}
|
||||
in := BlocklistInput{Name: &n.Name, URL: &n.URL, ViewIDs: model.Ptr(append([]int64{}, n.ViewIDs...)), Disabled: &n.Disabled, Comment: &n.Comment, StepID: model.Ptr(n.StepID)}
|
||||
if o, ok := from.Blocklists[k]; !ok {
|
||||
add(OpBlocklistCreate, k, 0, in, "add blocklist "+n.Name)
|
||||
} else if !sameObject(o, n) {
|
||||
@@ -122,7 +137,21 @@ func DiffOps(from, to *model.State) []model.Op {
|
||||
add(OpBlockRuleUpdate, k, o.Version, in, "restore rule: "+ruleLabel(n))
|
||||
}
|
||||
}
|
||||
for _, k := range sortedKeys(to.ClientGroups) {
|
||||
n := to.ClientGroups[k]
|
||||
in := ClientGroupInput{Name: &n.Name, Members: model.Ptr(append([]string{}, n.Members...)), StepID: model.Ptr(n.StepID), ViewIDs: model.Ptr(append([]int64{}, n.ViewIDs...)), Comment: &n.Comment}
|
||||
if o, ok := from.ClientGroups[k]; !ok {
|
||||
add(OpClientGroupCreate, k, 0, in, "add client group "+n.Name)
|
||||
} else if !sameObject(o, n) {
|
||||
add(OpClientGroupUpdate, k, o.Version, in, "restore client group "+n.Name)
|
||||
}
|
||||
}
|
||||
// Deletions, most dependent objects first.
|
||||
for _, k := range sortedKeys(from.ClientGroups) {
|
||||
if _, ok := to.ClientGroups[k]; !ok {
|
||||
add(OpClientGroupDelete, k, from.ClientGroups[k].Version, nil, "delete client group "+from.ClientGroups[k].Name)
|
||||
}
|
||||
}
|
||||
for _, k := range sortedKeys(from.BlockRules) {
|
||||
if _, ok := to.BlockRules[k]; !ok {
|
||||
add(OpBlockRuleDelete, k, from.BlockRules[k].Version, nil, "delete rule: "+ruleLabel(from.BlockRules[k]))
|
||||
@@ -133,6 +162,11 @@ func DiffOps(from, to *model.State) []model.Op {
|
||||
add(OpBlocklistDelete, k, from.Blocklists[k].Version, nil, "delete blocklist "+from.Blocklists[k].Name)
|
||||
}
|
||||
}
|
||||
for _, k := range sortedKeys(from.BlockSteps) {
|
||||
if _, ok := to.BlockSteps[k]; !ok {
|
||||
add(OpBlockStepDelete, k, from.BlockSteps[k].Version, nil, "delete blocking step "+from.BlockSteps[k].Name)
|
||||
}
|
||||
}
|
||||
for _, k := range sortedKeys(from.Records) {
|
||||
if _, ok := to.Records[k]; !ok {
|
||||
o := from.Records[k]
|
||||
@@ -187,7 +221,7 @@ func DiffOps(from, to *model.State) []model.Op {
|
||||
|
||||
func stripVersion(s model.Settings) model.Settings {
|
||||
s.Version = 0
|
||||
s.BlockingPausedUntil = nil // its own operation
|
||||
s.BlockingPausedUntil, s.BlockingPausedIndefinitely = nil, false // their own operation
|
||||
return s
|
||||
}
|
||||
|
||||
|
||||
+109
-24
@@ -15,6 +15,7 @@ import (
|
||||
"io/fs"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
@@ -56,6 +57,10 @@ type Config struct {
|
||||
NoReload bool
|
||||
// SkipCheck disables named-checkconf (only for tests without BIND).
|
||||
SkipCheck bool
|
||||
// LocalAddrs returns this host's addresses, for working out whether a
|
||||
// server without views is reachable from the internet
|
||||
// (docs/exposure.md). nil: none known.
|
||||
LocalAddrs func() []netip.Addr
|
||||
}
|
||||
|
||||
// Engine applies changesets.
|
||||
@@ -73,6 +78,11 @@ type Engine struct {
|
||||
blKick chan struct{} // a list was added or changed
|
||||
HTTP *http.Client // for downloads; nil: a default client
|
||||
Limits BlocklistLimits
|
||||
|
||||
namedCheck namedCheck // named-checkconf on the running configuration (namedwarn.go)
|
||||
|
||||
seedMu sync.Mutex
|
||||
seed []byte // the view keys' seed (viewkeys.go)
|
||||
}
|
||||
|
||||
// New creates an engine.
|
||||
@@ -105,17 +115,20 @@ type Result struct {
|
||||
DryRun bool `json:"dry_run,omitempty"`
|
||||
Valid bool `json:"valid"`
|
||||
Problems []validate.Problem `json:"problems"` // problems this change introduces
|
||||
Existing []validate.Problem `json:"existing_problems,omitempty"`
|
||||
Check []CheckMessage `json:"check,omitempty"`
|
||||
CheckOK bool `json:"check_ok"`
|
||||
Diff string `json:"diff"`
|
||||
Files []string `json:"files_changed"`
|
||||
Serials []SerialChange `json:"serials,omitempty"`
|
||||
Reloaded []string `json:"reloaded,omitempty"`
|
||||
Warnings []string `json:"warnings,omitempty"`
|
||||
Drift []DriftEntry `json:"drift,omitempty"`
|
||||
Ops []model.Op `json:"ops,omitempty"`
|
||||
Error *Error `json:"error,omitempty"`
|
||||
// Risks are the introduced problems that open the server up; applying
|
||||
// needs AcceptRisk (docs/exposure.md).
|
||||
Risks []validate.Problem `json:"risks,omitempty"`
|
||||
Existing []validate.Problem `json:"existing_problems,omitempty"`
|
||||
Check []CheckMessage `json:"check,omitempty"`
|
||||
CheckOK bool `json:"check_ok"`
|
||||
Diff string `json:"diff"`
|
||||
Files []string `json:"files_changed"`
|
||||
Serials []SerialChange `json:"serials,omitempty"`
|
||||
Reloaded []string `json:"reloaded,omitempty"`
|
||||
Warnings []string `json:"warnings,omitempty"`
|
||||
Drift []DriftEntry `json:"drift,omitempty"`
|
||||
Ops []model.Op `json:"ops,omitempty"`
|
||||
Error *Error `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// stored is what is kept in a changeset's result column.
|
||||
@@ -133,6 +146,9 @@ type ApplyOptions struct {
|
||||
Force bool
|
||||
// NoReload writes the files without reloading named.
|
||||
NoReload bool
|
||||
// AcceptRisk confirms a change that opens the server up, such as an
|
||||
// open resolver on a public view (validate.RiskCodes).
|
||||
AcceptRisk bool
|
||||
// wipeZone lets WipeDNSSEC switch signing off for this zone at once.
|
||||
wipeZone int64
|
||||
}
|
||||
@@ -175,7 +191,46 @@ func ValidateOptions(st *model.State) validate.Options {
|
||||
}
|
||||
|
||||
func (e *Engine) validate(st *model.State) []validate.Problem {
|
||||
return validate.State(st, ValidateOptions(st))
|
||||
return validate.State(st, e.validateOptions(st))
|
||||
}
|
||||
|
||||
func (e *Engine) validateOptions(st *model.State) validate.Options {
|
||||
opts := ValidateOptions(st)
|
||||
if e.cfg.LocalAddrs != nil {
|
||||
opts.LocalAddrs = e.cfg.LocalAddrs()
|
||||
}
|
||||
return opts
|
||||
}
|
||||
|
||||
// Validate checks a state as applying it would.
|
||||
func (e *Engine) Validate(st *model.State) []validate.Problem { return e.validate(st) }
|
||||
|
||||
// Exposures says which views (or the server, without views) are
|
||||
// reachable from the internet, and why (docs/exposure.md).
|
||||
func (e *Engine) Exposures(st *model.State) []validate.Exposure {
|
||||
return validate.Exposures(st, e.validateOptions(st))
|
||||
}
|
||||
|
||||
// risks returns the introduced problems that open a public view (or a
|
||||
// public server) up: risky warnings that weren't there before at all. One
|
||||
// that was there already, acknowledged (taking the acknowledgement back)
|
||||
// or not, opens nothing new.
|
||||
func risks(before, introduced []validate.Problem, public map[string]bool) []validate.Problem {
|
||||
type key struct {
|
||||
code, objType, view string
|
||||
id int64
|
||||
}
|
||||
had := map[key]bool{}
|
||||
for _, p := range before {
|
||||
had[key{p.Code, p.ObjectType, p.View, p.ObjectID}] = true
|
||||
}
|
||||
var out []validate.Problem
|
||||
for _, p := range introduced {
|
||||
if p.Severity == validate.Warning && validate.RiskCodes[p.Code] && public[p.View] && !had[key{p.Code, p.ObjectType, p.View, p.ObjectID}] {
|
||||
out = append(out, p)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ---- changeset management
|
||||
@@ -230,7 +285,7 @@ func (e *Engine) expandAll(ctx context.Context, work *model.State, reqs []Reques
|
||||
var warnings []string
|
||||
c := &applyCtx{p: p, now: e.Now()}
|
||||
for i, req := range reqs {
|
||||
x := &expander{ctx: ctx, st: work, alloc: e.store.AllocateIDs, p: p, now: e.Now()}
|
||||
x := &expander{ctx: ctx, st: work, alloc: e.store.AllocateIDs, p: p, now: e.Now(), exposures: e.Exposures}
|
||||
more, err := x.expand(req)
|
||||
if err != nil {
|
||||
if ee, ok := AsError(err); ok && len(reqs) > 1 {
|
||||
@@ -275,6 +330,9 @@ func (e *Engine) Stage(ctx context.Context, csID int64, reqs []Request, p *auth.
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(ops) == 0 {
|
||||
return nil, errf(KindBadRequest, "nothing to change")
|
||||
}
|
||||
intro := validate.Introduced(before, e.validate(work))
|
||||
if validate.HasErrors(intro) {
|
||||
return nil, &Error{Kind: KindValidation, Message: describeErrors(intro), Problems: validate.Errors(intro)}
|
||||
@@ -384,6 +442,8 @@ type plan struct {
|
||||
base, work *model.State
|
||||
introduced []validate.Problem
|
||||
existing []validate.Problem
|
||||
before []validate.Problem // the problems before the change
|
||||
public map[string]bool // public views by name ("": the server), after the change
|
||||
serials []SerialChange
|
||||
out *render.Output
|
||||
changed []*render.File
|
||||
@@ -414,6 +474,13 @@ func (pl *plan) files() []string {
|
||||
func (e *Engine) makePlan(ctx context.Context, base, work *model.State, user string, opts ApplyOptions) (*plan, error) {
|
||||
pl := &plan{base: base, work: work, verify: map[int64]uint32{}}
|
||||
before := e.validate(base)
|
||||
pl.before = before
|
||||
pl.public = map[string]bool{}
|
||||
for _, x := range e.Exposures(work) {
|
||||
pl.public[x.View] = x.Public
|
||||
// Server-wide findings (no view) count when anything is public.
|
||||
pl.public[""] = pl.public[""] || x.Public
|
||||
}
|
||||
after := e.validate(work)
|
||||
pl.introduced = append(validate.Introduced(before, after), e.dnssecChanges(base, work, opts)...)
|
||||
introducedKeys := map[string]bool{}
|
||||
@@ -435,7 +502,7 @@ func (e *Engine) makePlan(ctx context.Context, base, work *model.State, user str
|
||||
pl.introduced = append(pl.introduced, problems...)
|
||||
return pl, nil
|
||||
}
|
||||
pl.out = render.Render(work, e.cfg.Paths, e.cfg.Extras)
|
||||
pl.out = render.Render(work, e.cfg.Paths, e.extras(ctx))
|
||||
deployed, err := e.store.DeployedFiles(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -471,18 +538,18 @@ func (e *Engine) makePlan(ctx context.Context, base, work *model.State, user str
|
||||
pl.diff = diff.String()
|
||||
|
||||
// Which zones named must reload, and which serials to verify.
|
||||
baseOut := render.Render(base, e.cfg.Paths, e.cfg.Extras)
|
||||
baseOut := render.Render(base, e.cfg.Paths, e.extras(ctx))
|
||||
changedPaths := map[string]bool{}
|
||||
for _, f := range pl.changed {
|
||||
changedPaths[f.Path] = true
|
||||
}
|
||||
for _, z := range work.SortedZones() {
|
||||
if z.Type != model.ZonePrimary {
|
||||
continue
|
||||
if z.Type != model.ZonePrimary || z.Disabled {
|
||||
continue // disabled zones aren't in named: nothing to reload
|
||||
}
|
||||
path := pl.out.ZoneFiles[z.ID]
|
||||
old := base.Zones[z.ID]
|
||||
existedInNamed := old != nil && old.Type == model.ZonePrimary && baseOut.ZoneFiles[old.ID] == path
|
||||
existedInNamed := old != nil && old.Type == model.ZonePrimary && !old.Disabled && baseOut.ZoneFiles[old.ID] == path
|
||||
if !existedInNamed {
|
||||
pl.newZones = true
|
||||
pl.verify[z.ID] = z.SOA.Serial
|
||||
@@ -496,13 +563,15 @@ func (e *Engine) makePlan(ctx context.Context, base, work *model.State, user str
|
||||
if len(work.Zones) != len(base.Zones) {
|
||||
pl.configChanged = true
|
||||
}
|
||||
// Rules zones whose content changed. New ones come with a change to
|
||||
// named.conf, and reconfig loads them.
|
||||
// Rules zones and gates whose content changed. New ones come with a
|
||||
// change to named.conf, and reconfig loads them.
|
||||
for _, z := range pl.out.Policy {
|
||||
if z.ListID != 0 || !changedPaths[z.File] {
|
||||
continue
|
||||
}
|
||||
if slices.Contains(baseOut.Policy, z) {
|
||||
if slices.ContainsFunc(baseOut.Policy, func(o render.PolicyZone) bool {
|
||||
return o.Name == z.Name && o.ViewID == z.ViewID && o.File == z.File
|
||||
}) {
|
||||
pl.reloadPolicy = append(pl.reloadPolicy, z)
|
||||
}
|
||||
}
|
||||
@@ -527,7 +596,7 @@ func (e *Engine) stagingCheck(ctx context.Context, pl *plan) error {
|
||||
}
|
||||
defer os.RemoveAll(dir)
|
||||
sp := render.Paths{ConfigDir: filepath.Join(dir, "config"), ZoneDir: filepath.Join(dir, "zones"), CacheDir: filepath.Join(dir, "cache")}
|
||||
sout := render.Render(pl.work, sp, e.cfg.Extras)
|
||||
sout := render.Render(pl.work, sp, e.extras(ctx))
|
||||
for _, d := range sout.Dirs {
|
||||
if err := os.MkdirAll(d, 0o750); err != nil {
|
||||
return err
|
||||
@@ -645,6 +714,7 @@ func (pl *plan) result() *Result {
|
||||
Files: pl.files(),
|
||||
Serials: pl.serials,
|
||||
Drift: pl.drift,
|
||||
Risks: risks(pl.before, pl.introduced, pl.public),
|
||||
}
|
||||
r.Valid = r.Valid && r.CheckOK
|
||||
if r.Files == nil {
|
||||
@@ -774,6 +844,13 @@ func (e *Engine) applyOps(ctx context.Context, cs *model.Changeset, reqs []Reque
|
||||
if opts.DryRun {
|
||||
return res, nil
|
||||
}
|
||||
if rs := risks(pl.before, pl.introduced, pl.public); len(rs) > 0 && !opts.AcceptRisk {
|
||||
msg := rs[0].Message
|
||||
if len(rs) > 1 {
|
||||
msg += fmt.Sprintf(" (and %d more)", len(rs)-1)
|
||||
}
|
||||
return res, &Error{Kind: KindRisk, Message: "this change opens the server up: " + msg + "; apply it again with accept_risk to confirm (jarl: --accept-risk), or acknowledge the finding for good", Problems: rs}
|
||||
}
|
||||
if len(pl.drift) > 0 && !opts.Force {
|
||||
var paths []string
|
||||
for _, d := range pl.drift {
|
||||
@@ -1054,7 +1131,7 @@ func (e *Engine) Resync(ctx context.Context, p *auth.Principal, opts ApplyOption
|
||||
pl.configChanged = true
|
||||
pl.reloadZones = nil
|
||||
for _, z := range work.SortedZones() {
|
||||
if z.Type == model.ZonePrimary {
|
||||
if z.Type == model.ZonePrimary && !z.Disabled {
|
||||
pl.reloadZones = append(pl.reloadZones, z)
|
||||
pl.verify[z.ID] = z.SOA.Serial
|
||||
}
|
||||
@@ -1175,6 +1252,14 @@ type Status struct {
|
||||
MainConfig string `json:"main_config"`
|
||||
}
|
||||
|
||||
// NamedStatus asks named for its status (rndc status).
|
||||
func (e *Engine) NamedStatus(ctx context.Context) (*bind.ServerStatus, error) {
|
||||
if e.cfg.NoReload || e.tools == nil {
|
||||
return nil, errors.New("named is not managed by this jarld (no_reload)")
|
||||
}
|
||||
return e.tools.Status(ctx)
|
||||
}
|
||||
|
||||
// Status returns the current status.
|
||||
func (e *Engine) Status(ctx context.Context) (*Status, error) {
|
||||
st, err := e.store.LoadState(ctx)
|
||||
@@ -1218,7 +1303,7 @@ func (e *Engine) Status(ctx context.Context) (*Status, error) {
|
||||
}
|
||||
}
|
||||
}
|
||||
s.Problems = nonNilProblems(e.validate(st))
|
||||
s.Problems = nonNilProblems(append(e.validate(st), e.namedProblems(ctx, st, deployed)...))
|
||||
s.Counts = map[string]int{"views": len(st.Views), "zones": len(st.Zones), "records": len(st.Records), "acls": len(st.ACLs), "tsig_keys": len(st.Keys), "options": len(st.Options)}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
@@ -462,3 +462,39 @@ func TestParseWarningsReachTheResult(t *testing.T) {
|
||||
t.Errorf("ops = %+v", res.Ops)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnchangedZoneUpdateStagesNothing(t *testing.T) {
|
||||
env := newEnv(t)
|
||||
setupZone(env)
|
||||
env.submit(env.admin, NewRequest(OpZoneCreate, 0, ZoneInput{Name: model.Ptr("wg.example.com")}))
|
||||
z := env.state().FindZone("wg.example.com.", 0)
|
||||
if z.SOA.MName != "ns1.example.com." {
|
||||
env.submit(env.admin, NewRequest(OpZoneUpdate, z.ID, ZoneInput{SOA: &SOAInput{MName: model.Ptr("ns1.example.com.")}}))
|
||||
z = env.state().FindZone("wg.example.com.", 0)
|
||||
}
|
||||
cs, err := env.e.CreateChangeset(env.ctx, env.admin, "web", "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// The settings form saved as it was.
|
||||
same := ZoneInput{Comment: model.Ptr(z.Comment), DefaultTTL: model.Ptr(z.DefaultTTL), SerialPolicy: model.Ptr(z.SerialPolicy),
|
||||
SOA: &SOAInput{RName: model.Ptr("hostmaster@example.com"), Refresh: model.Ptr(z.SOA.Refresh), Retry: model.Ptr(z.SOA.Retry),
|
||||
Expire: model.Ptr(z.SOA.Expire), Minimum: model.Ptr(z.SOA.Minimum)}}
|
||||
if z.SOA.RName != "hostmaster.example.com." {
|
||||
same.SOA.RName = nil
|
||||
}
|
||||
_, err = env.e.Stage(env.ctx, cs.ID, []Request{NewRequest(OpZoneUpdate, z.ID, same)}, env.admin)
|
||||
if e, ok := AsError(err); !ok || e.Kind != KindBadRequest || e.Message != "nothing to change" {
|
||||
t.Fatalf("unchanged update: %v", err)
|
||||
}
|
||||
if e := env.submitErr(env.admin, NewRequest(OpZoneUpdate, z.ID, same)); e.Message != "nothing to do" {
|
||||
t.Errorf("unchanged update applied: %+v", e)
|
||||
}
|
||||
// A real change still stages, and an absolute MNAME stays absolute.
|
||||
if _, err := env.e.Stage(env.ctx, cs.ID, []Request{NewRequest(OpZoneUpdate, z.ID, ZoneInput{Comment: model.Ptr("the VPN")})}, env.admin); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := env.state().FindZone("wg.example.com.", 0).SOA.MName; got != "ns1.example.com." {
|
||||
t.Errorf("mname = %q", got)
|
||||
}
|
||||
}
|
||||
@@ -21,19 +21,26 @@ const (
|
||||
KindBIND ErrorKind = "bind_failed" // named-checkconf or rndc failed
|
||||
KindDrift ErrorKind = "drift" // files were changed outside Jarl
|
||||
KindNotAdopted ErrorKind = "not_adopted" // named does not load Jarl's config yet
|
||||
KindRisk ErrorKind = "confirm_risk" // the change opens the server up and needs confirming
|
||||
)
|
||||
|
||||
// Error is an engine error with details for the caller.
|
||||
type Error struct {
|
||||
Kind ErrorKind `json:"code"`
|
||||
Message string `json:"message"`
|
||||
OpSeq int `json:"op,omitempty"` // the operation that failed
|
||||
Problems []validate.Problem `json:"problems,omitempty"`
|
||||
Check []CheckMessage `json:"check,omitempty"`
|
||||
Diff string `json:"diff,omitempty"`
|
||||
Kind ErrorKind `json:"code"`
|
||||
Message string `json:"message"`
|
||||
OpSeq int `json:"op,omitempty"` // the operation that failed
|
||||
// OpSummary describes that operation, so people can find it in the
|
||||
// changeset (record IDs mean little to them).
|
||||
OpSummary string `json:"op_summary,omitempty"`
|
||||
Problems []validate.Problem `json:"problems,omitempty"`
|
||||
Check []CheckMessage `json:"check,omitempty"`
|
||||
Diff string `json:"diff,omitempty"`
|
||||
}
|
||||
|
||||
func (e *Error) Error() string {
|
||||
if e.OpSeq > 0 && e.OpSummary != "" {
|
||||
return fmt.Sprintf("operation %d (%s): %s", e.OpSeq, e.OpSummary, e.Message)
|
||||
}
|
||||
if e.OpSeq > 0 {
|
||||
return fmt.Sprintf("operation %d: %s", e.OpSeq, e.Message)
|
||||
}
|
||||
|
||||
+705
-97
@@ -5,6 +5,8 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/netip"
|
||||
"reflect"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -27,6 +29,15 @@ type expander struct {
|
||||
p *auth.Principal
|
||||
now time.Time
|
||||
warnings []string
|
||||
// exposures classifies the views as public or private (nil in tests:
|
||||
// every view private).
|
||||
exposures func(*model.State) []validate.Exposure
|
||||
// ptrForced are the address sets whose PTR records the request being
|
||||
// expanded asked to update (ptrForce).
|
||||
ptrForced map[rrKey]bool
|
||||
// sharing are the local sets the request deletes so that the shared
|
||||
// version reaches them (dropOverrides).
|
||||
sharing map[rrKey]bool
|
||||
}
|
||||
|
||||
func (x *expander) ids(n int) ([]int64, error) {
|
||||
@@ -65,11 +76,13 @@ func (x *expander) resolveZone(zoneID int64, zone, view string) (*model.Zone, er
|
||||
if zone == "" {
|
||||
return nil, errf(KindBadRequest, "the zone is missing (zone_id or zone)")
|
||||
}
|
||||
return FindZoneRef(x.st, zone, view)
|
||||
return FindZoneRef(x.st, zone, view, func(z *model.Zone) bool { return x.p.CanSeeZone(z.Name, x.st.ViewName(z)) })
|
||||
}
|
||||
|
||||
// FindZoneRef finds a zone by name and optional view name.
|
||||
func FindZoneRef(st *model.State, zone, view string) (*model.Zone, error) {
|
||||
// FindZoneRef finds a zone by name and optional view name. Without a
|
||||
// view, a name in several views needs one, counting only the zones sees
|
||||
// allows (nil: all): a token for one view finds that view's zone.
|
||||
func FindZoneRef(st *model.State, zone, view string, sees func(*model.Zone) bool) (*model.Zone, error) {
|
||||
name, err := ParseZoneName(zone)
|
||||
if err != nil {
|
||||
return nil, errf(KindBadRequest, "%v", err)
|
||||
@@ -86,6 +99,16 @@ func FindZoneRef(st *model.State, zone, view string) (*model.Zone, error) {
|
||||
return z, nil
|
||||
}
|
||||
zs := st.ZonesNamed(name)
|
||||
if sees != nil && len(zs) > 1 {
|
||||
var vis []*model.Zone
|
||||
for _, z := range zs {
|
||||
if sees(z) {
|
||||
vis = append(vis, z)
|
||||
}
|
||||
}
|
||||
zs = vis // none: not found, without naming the views
|
||||
}
|
||||
st.SortByView(zs)
|
||||
switch len(zs) {
|
||||
case 0:
|
||||
return nil, errf(KindNotFound, "zone %s does not exist", dnsname.Display(name))
|
||||
@@ -131,9 +154,37 @@ func dateSerial(now time.Time) uint32 {
|
||||
}
|
||||
|
||||
func (x *expander) expand(req Request) ([]model.Op, error) {
|
||||
x.ptrForced, x.sharing = nil, nil
|
||||
ops, err := x.expandOne(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
switch req.Op {
|
||||
case OpRecordCreate, OpRecordUpdate, OpRecordDelete, OpRRSetReplace, OpRecordDisable, OpRecordEnable:
|
||||
// Changes to linked zones reach the other zones of the link, then
|
||||
// PTR records follow the addresses in each view.
|
||||
if ops, err = x.linkSync(ops); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return x.ptrSync(ops)
|
||||
case OpZoneFillPTR:
|
||||
return x.linkSync(ops)
|
||||
}
|
||||
return ops, nil
|
||||
}
|
||||
|
||||
func (x *expander) expandOne(req Request) ([]model.Op, error) {
|
||||
switch req.Op {
|
||||
case OpZoneLink:
|
||||
return x.zoneLink(req)
|
||||
case OpZoneUnlink:
|
||||
return x.zoneUnlink(req)
|
||||
case OpZoneCreate:
|
||||
return x.zoneCreate(req)
|
||||
case OpZoneFillPTR:
|
||||
return x.zoneFillPTR(req)
|
||||
case OpReverseDismiss, OpReverseUndismiss:
|
||||
return x.reverseDismiss(req, req.Op == OpReverseDismiss)
|
||||
case OpZoneUpdate, OpZoneDelete:
|
||||
z := x.st.Zones[req.ID]
|
||||
if z == nil {
|
||||
@@ -145,8 +196,36 @@ func (x *expander) expand(req Request) ([]model.Op, error) {
|
||||
if n := len(x.st.ZoneRecords(z.ID)); n > 0 {
|
||||
verb += fmt.Sprintf(" (with its %d records)", n)
|
||||
}
|
||||
} else if d := zoneToggle(req, z); d != nil {
|
||||
if *d {
|
||||
x.warnings = append(x.warnings, disabledZoneEffect(x.st, z))
|
||||
return []model.Op{x.versioned(req, z.Version, "disable zone "+zoneDesc(x.st, z))}, nil
|
||||
}
|
||||
return []model.Op{x.versioned(req, z.Version, "enable zone "+zoneDesc(x.st, z))}, nil
|
||||
}
|
||||
return []model.Op{x.versioned(req, z.Version, "zone "+verb+" "+zoneDesc(x.st, z))}, nil
|
||||
ops := []model.Op{x.versioned(req, z.Version, "zone "+verb+" "+zoneDesc(x.st, z))}
|
||||
if req.Op == OpZoneDelete {
|
||||
if rest := x.st.LinkedWith(z); len(rest) == 1 {
|
||||
ops = append(ops, x.unlinkOps(rest[0], "unlink zone "+zoneDesc(x.st, rest[0])+": the zone it was linked with is deleted")...)
|
||||
}
|
||||
} else {
|
||||
var in ZoneInput
|
||||
if err := decode(req.Data, &in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if in.LinkGroup != nil && *in.LinkGroup != z.LinkGroup {
|
||||
return nil, errf(KindBadRequest, "link zones with zone.link and zone.unlink (jarl zone link / unlink)")
|
||||
}
|
||||
if in.LinkWith != "" {
|
||||
return nil, errf(KindBadRequest, "link_with is for new zones; link existing ones with zone.link (jarl zone link)")
|
||||
}
|
||||
// An update that leaves the zone as it is stages nothing (a
|
||||
// settings form saved without changes).
|
||||
if zoneUnchanged(x.st, z, &in) {
|
||||
return nil, nil
|
||||
}
|
||||
}
|
||||
return ops, nil
|
||||
case OpRecordCreate:
|
||||
return x.recordCreate(req)
|
||||
case OpRecordUpdate:
|
||||
@@ -222,6 +301,8 @@ func (x *expander) expand(req Request) ([]model.Op, error) {
|
||||
return []model.Op{x.versioned(req, o.Version, summary)}, nil
|
||||
case OpSettingsUpdate:
|
||||
return []model.Op{newOpRaw(OpSettingsUpdate, 0, 0, req.Data, "update settings")}, nil
|
||||
case OpExposureAck, OpExposureUnack:
|
||||
return x.exposureAck(req, req.Op == OpExposureAck)
|
||||
case OpBlocklistCreate:
|
||||
return x.blocklistCreate(req)
|
||||
case OpBlocklistUpdate, OpBlocklistDelete:
|
||||
@@ -242,6 +323,8 @@ func (x *expander) expand(req Request) ([]model.Op, error) {
|
||||
summary := "resume blocking"
|
||||
if in.Until != nil {
|
||||
summary = "pause blocking until " + in.Until.Local().Format("2006-01-02 15:04")
|
||||
} else if in.Indefinitely {
|
||||
summary = "pause blocking until resumed"
|
||||
}
|
||||
return []model.Op{newOpRaw(OpBlockingPause, 0, 0, req.Data, summary)}, nil
|
||||
case OpBlockRuleCreate:
|
||||
@@ -256,6 +339,30 @@ func (x *expander) expand(req Request) ([]model.Op, error) {
|
||||
verb = "delete rule: "
|
||||
}
|
||||
return []model.Op{x.versioned(req, r.Version, verb+ruleLabel(r))}, nil
|
||||
case OpBlockStepCreate:
|
||||
return x.blockStepCreate(req)
|
||||
case OpBlockStepUpdate, OpBlockStepDelete:
|
||||
s := x.st.BlockSteps[req.ID]
|
||||
if s == nil {
|
||||
return nil, errf(KindNotFound, "blocking step %d does not exist", req.ID)
|
||||
}
|
||||
verb := "change blocking step "
|
||||
if req.Op == OpBlockStepDelete {
|
||||
verb = "delete blocking step "
|
||||
}
|
||||
return []model.Op{x.versioned(req, s.Version, verb+s.Name)}, nil
|
||||
case OpClientGroupCreate:
|
||||
return x.clientGroupCreate(req)
|
||||
case OpClientGroupUpdate, OpClientGroupDelete:
|
||||
g := x.st.ClientGroups[req.ID]
|
||||
if g == nil {
|
||||
return nil, errf(KindNotFound, "client group %d does not exist", req.ID)
|
||||
}
|
||||
verb := "change client group "
|
||||
if req.Op == OpClientGroupDelete {
|
||||
verb = "delete client group "
|
||||
}
|
||||
return []model.Op{x.versioned(req, g.Version, verb+g.Name)}, nil
|
||||
}
|
||||
return nil, errf(KindBadRequest, "unknown operation %q", req.Op)
|
||||
}
|
||||
@@ -307,6 +414,7 @@ func (x *expander) zoneCreate(req Request) ([]model.Op, error) {
|
||||
return nil, errf(KindBadRequest, "%v", err)
|
||||
}
|
||||
in.Type = &zt
|
||||
viewGiven := in.View != nil || in.ViewID != nil
|
||||
if in.View != nil {
|
||||
if *in.View != "" {
|
||||
v := x.st.ViewByName(*in.View)
|
||||
@@ -317,11 +425,102 @@ func (x *expander) zoneCreate(req Request) ([]model.Op, error) {
|
||||
}
|
||||
in.View = nil
|
||||
}
|
||||
if !viewGiven && in.LinkWith == "" && in.CloneFrom == 0 && dnsname.IsReverse(name) {
|
||||
// A reverse zone belongs in the private view whose records use
|
||||
// its addresses (docs/reverse.md).
|
||||
v, err := reverseViewFor(x.st, x.publicViews(), name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if v != nil {
|
||||
in.ViewID = &v.ID
|
||||
}
|
||||
}
|
||||
var moveOps []model.Op
|
||||
var moved []*model.Record
|
||||
if zt == model.ZonePrimary {
|
||||
// The records a parent zone in the view has under the new zone
|
||||
// move into it: named answers those names from the new zone, so
|
||||
// left behind they would never be served again.
|
||||
vid := int64(0)
|
||||
if in.ViewID != nil {
|
||||
vid = *in.ViewID
|
||||
}
|
||||
if parent := x.parentZone(name, vid); parent != nil {
|
||||
moved = x.recordsUnder(parent, name)
|
||||
switch {
|
||||
case len(moved) == 0:
|
||||
case parent.LinkGroup != 0:
|
||||
x.warnings = append(x.warnings, fmt.Sprintf("zone %s is linked, so its %d records under %s stay there (hidden by the new zone): move them by hand", zoneDesc(x.st, parent), len(moved), dnsname.Display(name)))
|
||||
moved = nil
|
||||
default:
|
||||
for _, r := range moved {
|
||||
ri := RecordInput{Name: model.Ptr(r.Name), Type: model.Ptr(r.Type), Data: model.Ptr(r.Data)}
|
||||
if r.TTL != nil {
|
||||
ri.TTL = model.Some(*r.TTL)
|
||||
}
|
||||
if r.Comment != "" {
|
||||
ri.Comment = model.Ptr(r.Comment)
|
||||
}
|
||||
if r.Disabled {
|
||||
ri.Disabled = model.Ptr(true)
|
||||
}
|
||||
in.Records = append(in.Records, ri)
|
||||
moveOps = append(moveOps, newOp(OpRecordDelete, r.ID, r.Version, nil, fmt.Sprintf("move %s %s %s from zone %s into the new zone", r.Name, r.Type, r.Data, zoneDesc(x.st, parent))))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
fill, fillOther := in.Fill, in.FillOtherViews
|
||||
in.Fill, in.FillOtherViews = false, false
|
||||
if fill {
|
||||
if zt != model.ZonePrimary {
|
||||
return nil, errf(KindBadRequest, "fill only applies to primary zones")
|
||||
}
|
||||
if _, ok := dnsname.PrefixFromReverse(name); !ok {
|
||||
return nil, errf(KindBadRequest, "fill only applies to reverse zones of whole octets or nibbles (like 10.0.10.0/24)")
|
||||
}
|
||||
vid := int64(0)
|
||||
if in.ViewID != nil {
|
||||
vid = *in.ViewID
|
||||
}
|
||||
fills := PTRFills(x.st, vid, name, moved, fillOther)
|
||||
if len(fills) == 0 {
|
||||
x.warnings = append(x.warnings, fmt.Sprintf("no A or AAAA record of %s has an address in %s, so there was nothing to fill it with", viewOrServer(x.st, vid), dnsname.Display(name)))
|
||||
}
|
||||
if !fillOther {
|
||||
if h := otherViewsHint(x.st, vid, name, moved, len(fills), "to add PTR records for them in this view too: jarl zone fill-ptr ZONE --other-views"); h != "" {
|
||||
x.warnings = append(x.warnings, h)
|
||||
}
|
||||
}
|
||||
x.warnings = append(x.warnings, fillWarnings(fills)...)
|
||||
for _, f := range fills {
|
||||
in.Records = append(in.Records, RecordInput{Name: model.Ptr(f.Name), Type: model.Ptr("PTR"), Data: model.Ptr(f.Target)})
|
||||
}
|
||||
}
|
||||
nameservers := in.Nameservers
|
||||
records := in.Records
|
||||
cloneFrom := in.CloneFrom
|
||||
zoneOpts := in.Options
|
||||
in.Nameservers, in.Records, in.CloneFrom, in.Options = nil, nil, 0, nil
|
||||
linkWith := in.LinkWith
|
||||
in.Nameservers, in.Records, in.CloneFrom, in.Options, in.LinkWith, in.LinkGroup = nil, nil, 0, nil, "", nil
|
||||
if linkWith != "" {
|
||||
if cloneFrom != 0 {
|
||||
return nil, errf(KindBadRequest, "give clone_from or link_with, not both")
|
||||
}
|
||||
v := x.st.ViewByName(linkWith)
|
||||
if v == nil {
|
||||
return nil, errf(KindNotFound, "view %q does not exist", linkWith)
|
||||
}
|
||||
o := x.st.FindZone(name, v.ID)
|
||||
if o == nil {
|
||||
return nil, errf(KindNotFound, "view %s has no zone %s to link with", v.Name, dnsname.Display(name))
|
||||
}
|
||||
if o.Type != model.ZonePrimary || zt != model.ZonePrimary {
|
||||
return nil, errf(KindBadRequest, "only primary zones can be linked")
|
||||
}
|
||||
cloneFrom = o.ID
|
||||
}
|
||||
|
||||
set := x.st.Settings
|
||||
var src *model.Zone
|
||||
@@ -446,9 +645,30 @@ func (x *expander) zoneCreate(req Request) ([]model.Op, error) {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
var linkOps []model.Op
|
||||
if src != nil && linkWith != "" {
|
||||
// The new zone joins src's link, or starts one with src: then
|
||||
// all of src's records become shared.
|
||||
group := src.LinkGroup
|
||||
if group == 0 {
|
||||
group = src.ID
|
||||
linkOps = x.shareAllOps(src, group)
|
||||
}
|
||||
in.LinkGroup = &group
|
||||
}
|
||||
if src != nil {
|
||||
for _, r := range x.st.ZoneRecords(src.ID) {
|
||||
if linkWith != "" && src.LinkGroup != 0 && r.SharedID == 0 {
|
||||
continue // local to src's view
|
||||
}
|
||||
ri := RecordInput{Name: model.Ptr(r.Name), Type: model.Ptr(r.Type), Data: model.Ptr(r.Data), Comment: model.Ptr(r.Comment)}
|
||||
if linkWith != "" {
|
||||
id := r.SharedID
|
||||
if id == 0 {
|
||||
id = r.ID
|
||||
}
|
||||
ri.SharedID = &id
|
||||
}
|
||||
if r.Disabled {
|
||||
ri.Disabled = model.Ptr(true)
|
||||
}
|
||||
@@ -478,10 +698,12 @@ func (x *expander) zoneCreate(req Request) ([]model.Op, error) {
|
||||
summary += " in view " + v.Name
|
||||
}
|
||||
}
|
||||
if src != nil {
|
||||
if src != nil && linkWith != "" {
|
||||
summary += " (linked with " + zoneDesc(x.st, src) + ")"
|
||||
} else if src != nil {
|
||||
summary += " (copy of " + zoneDesc(x.st, src) + ")"
|
||||
}
|
||||
ops := []model.Op{newOp(OpZoneCreate, ids[0], 0, in, summary)}
|
||||
ops := append(linkOps, newOp(OpZoneCreate, ids[0], 0, in, summary))
|
||||
for i, zo := range zoneOpts {
|
||||
oi := OptionInput{Scope: model.Ptr(model.ScopeZone), ScopeID: model.Ptr(ids[0]), Name: model.Ptr(zo.Name), Value: model.Ptr(zo.Value)}
|
||||
if zo.Comment != "" {
|
||||
@@ -493,7 +715,45 @@ func (x *expander) zoneCreate(req Request) ([]model.Op, error) {
|
||||
r.in.ZoneID = ids[0]
|
||||
ops = append(ops, newOp(OpRecordCreate, ids[1+i], 0, r.in, r.summary))
|
||||
}
|
||||
return ops, nil
|
||||
return append(ops, moveOps...), nil
|
||||
}
|
||||
|
||||
// parentZone returns the most specific primary zone in a view that
|
||||
// contains name (not name itself).
|
||||
func (x *expander) parentZone(name string, viewID int64) *model.Zone {
|
||||
var best *model.Zone
|
||||
for _, z := range x.st.ZonesInView(viewID) {
|
||||
if z.Type == model.ZonePrimary && dnsname.IsStrictSubdomain(name, z.Name) && (best == nil || len(z.Name) > len(best.Name)) {
|
||||
best = z
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
// recordsUnder returns a zone's records at or below name, except a
|
||||
// delegation's NS and DS records and its glue, which belong to the parent.
|
||||
func (x *expander) recordsUnder(z *model.Zone, name string) []*model.Record {
|
||||
all := x.st.ZoneRecords(z.ID)
|
||||
var ns []string
|
||||
for _, r := range all {
|
||||
if r.Name == name && r.Type == "NS" {
|
||||
ns = append(ns, strings.ToLower(r.Data))
|
||||
}
|
||||
}
|
||||
var out []*model.Record
|
||||
for _, r := range all {
|
||||
if !dnsname.IsSubdomain(r.Name, name) {
|
||||
continue
|
||||
}
|
||||
if r.Name == name && (r.Type == "NS" || r.Type == "DS") {
|
||||
continue
|
||||
}
|
||||
if (r.Type == "A" || r.Type == "AAAA") && slices.Contains(ns, strings.ToLower(r.Name)) {
|
||||
continue // glue
|
||||
}
|
||||
out = append(out, r)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func deref(s *string) string {
|
||||
@@ -527,58 +787,247 @@ func addrOf(p *rr.Parsed) (netip.Addr, bool) {
|
||||
return netip.Addr{}, false
|
||||
}
|
||||
|
||||
// ptrOps returns operations creating the PTR record for an address record.
|
||||
func (x *expander) ptrCreate(z *model.Zone, owner string, p *rr.Parsed) ([]model.Op, error) {
|
||||
addr, ok := addrOf(p)
|
||||
if !ok {
|
||||
return nil, errf(KindBadRequest, "ptr only applies to A and AAAA records")
|
||||
// PTR records follow address records per view (docs/reverse.md): after a
|
||||
// request's operations (and those keeping linked zones in step), ptrSync
|
||||
// compares each touched A or AAAA set before and after, in every view it
|
||||
// is in, and changes the PTR records of that view's reverse zone to match.
|
||||
// So a record local to one view of a linked zone changes PTR records in
|
||||
// that view only, and an address that goes from one view keeps its PTR
|
||||
// record in the views that still have it.
|
||||
|
||||
// ptrForce marks a name and type whose PTR records the request asked to
|
||||
// update (ptr: true), in its zone and the zones linked with it.
|
||||
func (x *expander) ptrForce(z *model.Zone, name, typ string) {
|
||||
if x.ptrForced == nil {
|
||||
x.ptrForced = map[rrKey]bool{}
|
||||
}
|
||||
rz, ptrName := reverseZoneFor(x.st, z.ViewID, addr)
|
||||
if rz == nil {
|
||||
x.warnings = append(x.warnings, fmt.Sprintf("no reverse zone for %s in this view, so no PTR record was added", addr))
|
||||
return nil, nil
|
||||
x.ptrForced[rrKey{z.ID, name, typ}] = true
|
||||
for _, o := range x.st.LinkedWith(z) {
|
||||
x.ptrForced[rrKey{o.ID, name, typ}] = true
|
||||
}
|
||||
for _, r := range x.st.ZoneRecords(rz.ID) {
|
||||
if r.Name == ptrName && r.Type == "PTR" {
|
||||
if strings.EqualFold(r.Data, owner) {
|
||||
if r.Disabled {
|
||||
in := RecordInput{Disabled: model.Ptr(false)}
|
||||
return []model.Op{newOp(OpRecordUpdate, r.ID, r.Version, in, fmt.Sprintf("enable %s PTR %s", r.Name, r.Data))}, nil
|
||||
}
|
||||
return nil, nil // already there
|
||||
}
|
||||
x.warnings = append(x.warnings, fmt.Sprintf("%s already has a PTR record (%s); adding another one", ptrName, r.Data))
|
||||
}
|
||||
|
||||
// Whether a set has an address: none, only in disabled records, enabled.
|
||||
const (
|
||||
addrNone = iota
|
||||
addrDisabled
|
||||
addrEnabled
|
||||
)
|
||||
|
||||
// addrStates returns the addresses of a zone's A or AAAA set.
|
||||
func addrStates(st *model.State, k rrKey) map[netip.Addr]int {
|
||||
out := map[netip.Addr]int{}
|
||||
for _, r := range st.RRset(k.zone, k.name, k.typ) {
|
||||
a, err := netip.ParseAddr(r.Data)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
s := addrDisabled
|
||||
if !r.Disabled {
|
||||
s = addrEnabled
|
||||
}
|
||||
out[a.Unmap()] = max(out[a.Unmap()], s)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ptrSync returns ops followed by the PTR changes they call for: for the
|
||||
// zone's auto_ptr, or because the request asked (ptrForce).
|
||||
func (x *expander) ptrSync(ops []model.Op) ([]model.Op, error) {
|
||||
forced := x.ptrForced
|
||||
x.ptrForced = nil
|
||||
work := x.st.Clone()
|
||||
c := &applyCtx{p: x.p, now: x.now}
|
||||
for _, op := range ops {
|
||||
if err := applyOp(work, op, c); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
keys := affectedKeys(x.st, work, ops)
|
||||
for _, k := range sortedForced(forced) {
|
||||
if !slices.Contains(keys, k) {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
}
|
||||
var extra []model.Op
|
||||
for _, k := range keys {
|
||||
z := work.Zones[k.zone]
|
||||
if z == nil || (k.typ != "A" && k.typ != "AAAA") || strings.HasPrefix(k.name, "*.") {
|
||||
continue
|
||||
}
|
||||
force := forced[k]
|
||||
if !force && !z.AutoPTR {
|
||||
continue
|
||||
}
|
||||
before, after := addrStates(x.st, k), addrStates(work, k)
|
||||
var addrs []netip.Addr
|
||||
for _, m := range []map[netip.Addr]int{before, after} {
|
||||
for a := range m {
|
||||
if !slices.Contains(addrs, a) {
|
||||
addrs = append(addrs, a)
|
||||
}
|
||||
}
|
||||
}
|
||||
slices.SortFunc(addrs, func(a, b netip.Addr) int { return a.Compare(b) })
|
||||
for _, a := range addrs {
|
||||
was, is := before[a], after[a]
|
||||
if !force && (was == is || was == addrNone && is == addrDisabled) {
|
||||
continue
|
||||
}
|
||||
rz, ptrName := reverseZoneFor(work, z.ViewID, a)
|
||||
if rz == nil {
|
||||
if force && is == addrEnabled {
|
||||
x.warnings = append(x.warnings, fmt.Sprintf("no reverse zone for %s in %s, so no PTR record was added there (create one: jarl zone create %s/%d%s)",
|
||||
a, viewOrServer(work, z.ViewID), a, a.BitLen(), viewFlag(work, z.ViewID)))
|
||||
}
|
||||
continue
|
||||
}
|
||||
more, err := x.ptrChange(work, rz, ptrName, k.name, is, !force)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, op := range more {
|
||||
if err := applyOp(work, op, c); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
extra = append(extra, more...)
|
||||
}
|
||||
}
|
||||
if len(extra) == 0 {
|
||||
return ops, nil
|
||||
}
|
||||
// The PTR records are local (see ptrChange), so this only keeps a
|
||||
// linked reverse zone consistent.
|
||||
extra, err := x.linkSync(extra)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return append(ops, extra...), nil
|
||||
}
|
||||
|
||||
func sortedForced(m map[rrKey]bool) []rrKey {
|
||||
var out []rrKey
|
||||
for k := range m {
|
||||
out = append(out, k)
|
||||
}
|
||||
slices.SortFunc(out, func(a, b rrKey) int {
|
||||
if a.zone != b.zone {
|
||||
return int(a.zone - b.zone)
|
||||
}
|
||||
if a.name != b.name {
|
||||
return strings.Compare(a.name, b.name)
|
||||
}
|
||||
return strings.Compare(a.typ, b.typ)
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
func viewFlag(st *model.State, viewID int64) string {
|
||||
if v := st.Views[viewID]; v != nil {
|
||||
return " --view " + v.Name
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// ptrChange returns the operations that give reverse zone rz's PTR
|
||||
// records at ptrName for host owner the state of its address: none
|
||||
// (deleted), disabled, or enabled (added when missing).
|
||||
//
|
||||
// In a linked reverse zone the PTR records are local to each view, since
|
||||
// each view's follow its own address records: a shared set at ptrName
|
||||
// is made local in every view of the link first, as each view has it.
|
||||
func (x *expander) ptrChange(st *model.State, rz *model.Zone, ptrName, owner string, state int, auto bool) ([]model.Op, error) {
|
||||
nwarn := len(x.warnings)
|
||||
ops, err := x.ptrChangeIn(st, rz, ptrName, owner, state, auto)
|
||||
if err != nil || len(ops) == 0 || rz.LinkGroup == 0 {
|
||||
return ops, err
|
||||
}
|
||||
var local []model.Op
|
||||
for _, m := range st.LinkMembers(rz.LinkGroup) {
|
||||
for _, r := range st.RRset(m.ID, ptrName, "PTR") {
|
||||
if r.SharedID != 0 {
|
||||
local = append(local, newOp(OpRecordUpdate, r.ID, r.Version, RecordInput{SharedID: model.Ptr(int64(0))},
|
||||
fmt.Sprintf("linked: %s PTR %s becomes local to %s (PTR records follow each view's own address records)", r.Name, r.Data, zoneDesc(st, m))))
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(local) == 0 {
|
||||
return ops, nil
|
||||
}
|
||||
// Again on the local records (their versions changed).
|
||||
scratch := st.Clone()
|
||||
c := &applyCtx{p: x.p, now: x.now}
|
||||
for _, op := range local {
|
||||
if err := applyOp(scratch, op, c); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
x.warnings = x.warnings[:nwarn]
|
||||
ops, err = x.ptrChangeIn(scratch, rz, ptrName, owner, state, auto)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return append(local, ops...), nil
|
||||
}
|
||||
|
||||
func (x *expander) ptrChangeIn(st *model.State, rz *model.Zone, ptrName, owner string, state int, auto bool) ([]model.Op, error) {
|
||||
var ops []model.Op
|
||||
switch state {
|
||||
case addrNone:
|
||||
for _, r := range st.RRset(rz.ID, ptrName, "PTR") {
|
||||
if strings.EqualFold(r.Data, owner) {
|
||||
ops = append(ops, newOp(OpRecordDelete, r.ID, r.Version, nil, fmt.Sprintf("delete %s PTR %s", r.Name, r.Data)))
|
||||
}
|
||||
}
|
||||
case addrDisabled:
|
||||
for _, r := range st.RRset(rz.ID, ptrName, "PTR") {
|
||||
if strings.EqualFold(r.Data, owner) && !r.Disabled {
|
||||
ops = append(ops, newOp(OpRecordUpdate, r.ID, r.Version, RecordInput{Disabled: model.Ptr(true)}, fmt.Sprintf("disable %s PTR %s", r.Name, r.Data)))
|
||||
}
|
||||
}
|
||||
default:
|
||||
return x.ptrCreateIn(st, rz, ptrName, owner, auto)
|
||||
}
|
||||
return ops, nil
|
||||
}
|
||||
|
||||
// ptrCreateIn returns operations creating the PTR record ptrName -> owner
|
||||
// in reverse zone rz (none when it is there; enabling it when disabled).
|
||||
// auto is set when the zone's auto_ptr asked rather than the request: an
|
||||
// address whose PTR record names another host keeps it then.
|
||||
func (x *expander) ptrCreateIn(st *model.State, rz *model.Zone, ptrName, owner string, auto bool) ([]model.Op, error) {
|
||||
var other string
|
||||
for _, r := range st.RRset(rz.ID, ptrName, "PTR") {
|
||||
if strings.EqualFold(r.Data, owner) {
|
||||
if r.Disabled {
|
||||
in := RecordInput{Disabled: model.Ptr(false)}
|
||||
return []model.Op{newOp(OpRecordUpdate, r.ID, r.Version, in, fmt.Sprintf("enable %s PTR %s", r.Name, r.Data))}, nil
|
||||
}
|
||||
return nil, nil // already there
|
||||
}
|
||||
if !r.Disabled {
|
||||
other = r.Data
|
||||
}
|
||||
}
|
||||
if other != "" {
|
||||
if auto {
|
||||
// One name per address: the one it has stays.
|
||||
return nil, nil
|
||||
}
|
||||
x.warnings = append(x.warnings, fmt.Sprintf("%s already has a PTR record (%s); adding another one", ptrName, other))
|
||||
}
|
||||
ids, err := x.ids(1)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
in := RecordInput{ZoneID: rz.ID, Name: model.Ptr(ptrName), Type: model.Ptr("PTR"), Data: model.Ptr(owner)}
|
||||
if rz.LinkGroup != 0 {
|
||||
in.SharedID = model.Ptr(int64(0))
|
||||
}
|
||||
return []model.Op{newOp(OpRecordCreate, ids[0], 0, in, fmt.Sprintf("add %s IN PTR %s", ptrName, owner))}, nil
|
||||
}
|
||||
|
||||
// ptrSetDisabled returns operations disabling or enabling the PTR record
|
||||
// of an address record.
|
||||
func (x *expander) ptrSetDisabled(z *model.Zone, owner string, p *rr.Parsed, disabled bool) []model.Op {
|
||||
addr, ok := addrOf(p)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
rz, ptrName := reverseZoneFor(x.st, z.ViewID, addr)
|
||||
if rz == nil {
|
||||
return nil
|
||||
}
|
||||
var ops []model.Op
|
||||
for _, r := range x.st.ZoneRecords(rz.ID) {
|
||||
if r.Name == ptrName && r.Type == "PTR" && strings.EqualFold(r.Data, owner) && r.Disabled != disabled {
|
||||
in := RecordInput{Disabled: model.Ptr(disabled)}
|
||||
ops = append(ops, newOp(OpRecordUpdate, r.ID, r.Version, in, fmt.Sprintf("%s %s PTR %s", enableWord(disabled), r.Name, r.Data)))
|
||||
}
|
||||
}
|
||||
return ops
|
||||
}
|
||||
|
||||
func enableWord(disabled bool) string {
|
||||
if disabled {
|
||||
return "disable"
|
||||
@@ -586,19 +1035,6 @@ func enableWord(disabled bool) string {
|
||||
return "enable"
|
||||
}
|
||||
|
||||
func (x *expander) ptrDelete(z *model.Zone, owner string, p *rr.Parsed) []model.Op {
|
||||
addr, ok := addrOf(p)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
rz, ptrName := reverseZoneFor(x.st, z.ViewID, addr)
|
||||
if rz == nil {
|
||||
return nil
|
||||
}
|
||||
in := RecordInput{ZoneID: rz.ID, Name: model.Ptr(ptrName), Type: model.Ptr("PTR"), Data: model.Ptr(owner), MissingOK: true}
|
||||
return []model.Op{newOp(OpRecordDelete, 0, 0, in, fmt.Sprintf("delete %s IN PTR %s (if present)", ptrName, owner))}
|
||||
}
|
||||
|
||||
// parseWarnings keeps what parsing noticed (e.g. a name read as absolute).
|
||||
// The stored operation holds the canonical data, so applying it doesn't
|
||||
// notice again.
|
||||
@@ -625,22 +1061,40 @@ func (x *expander) recordCreate(req Request) ([]model.Op, error) {
|
||||
return nil, err
|
||||
}
|
||||
x.parseWarnings(owner, p)
|
||||
ptr := in.PTR
|
||||
if in.PTR {
|
||||
if _, ok := addrOf(p); !ok {
|
||||
return nil, errf(KindBadRequest, "ptr only applies to A and AAAA records")
|
||||
}
|
||||
x.ptrForce(z, owner, p.Type)
|
||||
}
|
||||
in.ZoneID, in.Zone, in.View, in.PTR = z.ID, "", "", false
|
||||
in.Name, in.Type, in.Data = &owner, &p.Type, &p.Data
|
||||
ids, err := x.ids(1)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ops := []model.Op{newOp(OpRecordCreate, ids[0], 0, in, fmt.Sprintf("add %s%s IN %s %s", owner, ttlText(in.TTL), p.Type, p.Data))}
|
||||
if ptr {
|
||||
more, err := x.ptrCreate(z, owner, p)
|
||||
summary := fmt.Sprintf("add %s%s IN %s %s", owner, ttlText(in.TTL), p.Type, p.Data)
|
||||
in.SharedID = nil
|
||||
if z.LinkGroup != 0 {
|
||||
local, err := x.localFor(z, owner, p.Type, in.Local)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ops = append(ops, more...)
|
||||
if local {
|
||||
in.SharedID = model.Ptr(int64(0))
|
||||
summary += " (local to view " + x.st.ViewName(z) + ")"
|
||||
} else {
|
||||
if x.st.HasLocal(z.ID, owner, p.Type) {
|
||||
return nil, errf(KindConflict, "%s %s is local in view %s; share the set first (jarl record update ID --shared) or add this one as local", dnsname.Display(owner), p.Type, x.st.ViewName(z))
|
||||
}
|
||||
in.SharedID = &ids[0]
|
||||
}
|
||||
} else if in.Local != nil && !*in.Local {
|
||||
return nil, notLinked(x.st, z)
|
||||
}
|
||||
return ops, nil
|
||||
// local on a zone that isn't linked: its records are local anyway.
|
||||
in.Local = nil
|
||||
return []model.Op{newOp(OpRecordCreate, ids[0], 0, in, summary)}, nil
|
||||
}
|
||||
|
||||
func (x *expander) recordUpdate(req Request) ([]model.Op, error) {
|
||||
@@ -656,6 +1110,36 @@ func (x *expander) recordUpdate(req Request) ([]model.Op, error) {
|
||||
ptr := in.PTR
|
||||
in.PTR = false
|
||||
in.Zone, in.View = "", ""
|
||||
if in.SharedID != nil {
|
||||
return nil, errf(KindBadRequest, "shared_id is managed by Jarl; use local: true or false to make a record local to its view or shared")
|
||||
}
|
||||
var pre []model.Op
|
||||
if in.Local != nil {
|
||||
if z.LinkGroup == 0 && !*in.Local {
|
||||
return nil, notLinked(x.st, z)
|
||||
}
|
||||
if want := *in.Local; z.LinkGroup != 0 && want != (r.SharedID == 0) {
|
||||
// The whole set of this name and type changes sides.
|
||||
for _, o := range x.st.RRset(z.ID, r.Name, r.Type) {
|
||||
id := int64(0)
|
||||
what := fmt.Sprintf("make %s %s local to view %s", o.Name, o.Type, x.st.ViewName(z))
|
||||
if !want {
|
||||
id = o.ID
|
||||
what = fmt.Sprintf("share %s %s %s with the linked views", o.Name, o.Type, o.Data)
|
||||
}
|
||||
pre = append(pre, newOp(OpRecordUpdate, o.ID, o.Version, RecordInput{SharedID: model.Ptr(id)}, what))
|
||||
}
|
||||
}
|
||||
if z.LinkGroup != 0 && !*in.Local {
|
||||
// Sharing a version makes it every view's, also when it is
|
||||
// shared already and another view overrides it.
|
||||
pre = append(pre, x.dropOverrides(z, r.Name, r.Type)...)
|
||||
}
|
||||
in.Local = nil
|
||||
if string(mustJSON(in)) == "{}" {
|
||||
return pre, nil
|
||||
}
|
||||
}
|
||||
name, typ, data := r.Name, r.Type, r.Data
|
||||
if in.Name != nil {
|
||||
name = *in.Name
|
||||
@@ -717,33 +1201,36 @@ func (x *expander) recordUpdate(req Request) ([]model.Op, error) {
|
||||
case len(changes) > 0:
|
||||
summary += ": " + strings.Join(changes, ", ")
|
||||
}
|
||||
if z.LinkGroup != 0 && r.SharedID != 0 && len(pre) == 0 && (owner != r.Name || p.Type != r.Type) && x.st.HasLocal(z.ID, owner, p.Type) {
|
||||
return nil, errf(KindConflict, "%s %s is local in view %s, so a shared record can't move there", dnsname.Display(owner), p.Type, x.st.ViewName(z))
|
||||
}
|
||||
base := req.BaseVersion
|
||||
if base == 0 {
|
||||
base = r.Version
|
||||
}
|
||||
ops := []model.Op{newOp(OpRecordUpdate, r.ID, base, in, summary)}
|
||||
disabled := r.Disabled
|
||||
if in.Disabled != nil {
|
||||
disabled = *in.Disabled
|
||||
if len(pre) > 0 {
|
||||
base = 0 // the record was just updated by pre
|
||||
}
|
||||
moved := p.Data != r.Data || owner != r.Name
|
||||
if ptr && moved {
|
||||
ops := pre
|
||||
if string(mustJSON(in)) != "{}" || !ptr {
|
||||
ops = append(ops, newOp(OpRecordUpdate, r.ID, base, in, summary))
|
||||
}
|
||||
if ptr {
|
||||
// The PTR records of the old and the new record, whichever is an
|
||||
// address (ptrSync).
|
||||
_, isAddr := addrOf(p)
|
||||
if old, err := rr.Parse(z.Name, r.Name, r.Type, r.Data); err == nil {
|
||||
ops = append(ops, x.ptrDelete(z, r.Name, old)...)
|
||||
}
|
||||
if !disabled {
|
||||
more, err := x.ptrCreate(z, owner, p)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
if _, ok := addrOf(old); ok {
|
||||
isAddr = true
|
||||
x.ptrForce(z, r.Name, r.Type)
|
||||
}
|
||||
ops = append(ops, more...)
|
||||
}
|
||||
}
|
||||
if ptr && !moved && toggled {
|
||||
if _, ok := addrOf(p); !ok {
|
||||
if !isAddr {
|
||||
return nil, errf(KindBadRequest, "ptr only applies to A and AAAA records")
|
||||
}
|
||||
ops = append(ops, x.ptrSetDisabled(z, owner, p, disabled)...)
|
||||
if _, ok := addrOf(p); ok {
|
||||
x.ptrForce(z, owner, p.Type)
|
||||
}
|
||||
}
|
||||
return ops, nil
|
||||
}
|
||||
@@ -767,10 +1254,7 @@ func (x *expander) recordDelete(req Request) ([]model.Op, error) {
|
||||
}
|
||||
ops := []model.Op{newOp(OpRecordDelete, r.ID, base, nil, fmt.Sprintf("delete %s %s %s", r.Name, r.Type, r.Data))}
|
||||
if in.PTR {
|
||||
z := x.st.Zones[r.ZoneID]
|
||||
if p, err := rr.Parse(z.Name, r.Name, r.Type, r.Data); err == nil {
|
||||
ops = append(ops, x.ptrDelete(z, r.Name, p)...)
|
||||
}
|
||||
x.ptrForce(x.st.Zones[r.ZoneID], r.Name, r.Type)
|
||||
}
|
||||
return ops, nil
|
||||
}
|
||||
@@ -806,14 +1290,12 @@ func (x *expander) recordDelete(req Request) ([]model.Op, error) {
|
||||
if in.MissingOK {
|
||||
what += " (if present)"
|
||||
}
|
||||
ptr := in.PTR
|
||||
if in.PTR && parsed != nil {
|
||||
x.ptrForce(z, owner, typ)
|
||||
}
|
||||
in.ZoneID, in.Zone, in.View, in.PTR = z.ID, "", "", false
|
||||
in.Name, in.Type = &owner, &typ
|
||||
ops := []model.Op{newOp(OpRecordDelete, 0, 0, in, "delete "+what)}
|
||||
if ptr && parsed != nil {
|
||||
ops = append(ops, x.ptrDelete(z, owner, parsed)...)
|
||||
}
|
||||
return ops, nil
|
||||
return []model.Op{newOp(OpRecordDelete, 0, 0, in, "delete "+what)}, nil
|
||||
}
|
||||
|
||||
// recordSetDisabled expands record.disable and record.enable: the record
|
||||
@@ -919,17 +1401,35 @@ func (x *expander) rrsetReplace(req Request) ([]model.Op, error) {
|
||||
canon = append(canon, p.Data)
|
||||
}
|
||||
in.ZoneID, in.Zone, in.View, in.Name, in.Type, in.Data = z.ID, "", "", owner, typ, canon
|
||||
where := ""
|
||||
var pre []model.Op
|
||||
if z.LinkGroup != 0 {
|
||||
local, err := x.localFor(z, owner, typ, in.Local)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
in.Shared = model.Ptr(!local)
|
||||
if local {
|
||||
where = " (local to view " + x.st.ViewName(z) + ")"
|
||||
} else if in.Local != nil {
|
||||
// Explicitly shared: this version becomes every view's.
|
||||
pre = x.dropOverrides(z, owner, typ)
|
||||
}
|
||||
} else if in.Local != nil && !*in.Local {
|
||||
return nil, notLinked(x.st, z)
|
||||
}
|
||||
in.Local = nil
|
||||
if len(canon) > 0 {
|
||||
in.NewIDs, err = x.ids(len(canon))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
summary := fmt.Sprintf("set %s%s IN %s to [%s]", owner, ttlText(in.TTL), typ, strings.Join(canon, ", "))
|
||||
summary := fmt.Sprintf("set %s%s IN %s to [%s]%s", owner, ttlText(in.TTL), typ, strings.Join(canon, ", "), where)
|
||||
if len(canon) == 0 {
|
||||
summary = fmt.Sprintf("delete all %s records of %s", typ, owner)
|
||||
summary = fmt.Sprintf("delete all %s records of %s%s", typ, owner, where)
|
||||
}
|
||||
return []model.Op{newOp(OpRRSetReplace, 0, 0, in, summary)}, nil
|
||||
return append(pre, newOp(OpRRSetReplace, 0, 0, in, summary)), nil
|
||||
}
|
||||
|
||||
func (x *expander) viewCreate(req Request) ([]model.Op, error) {
|
||||
@@ -1029,3 +1529,111 @@ func RedactOps(ops []model.Op) []model.Op {
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// zoneToggle returns the new disabled state when a zone update only
|
||||
// disables or enables the zone (nil otherwise).
|
||||
func zoneToggle(req Request, z *model.Zone) *bool {
|
||||
var in ZoneInput
|
||||
if decode(req.Data, &in) != nil || in.Disabled == nil || *in.Disabled == z.Disabled {
|
||||
return nil
|
||||
}
|
||||
only := ZoneInput{Disabled: in.Disabled}
|
||||
if string(mustJSON(in)) != string(mustJSON(only)) {
|
||||
return nil
|
||||
}
|
||||
return in.Disabled
|
||||
}
|
||||
|
||||
// disabledZoneEffect says what clients get once named no longer loads a
|
||||
// zone: in a view that offers recursion, answers from recursion (the
|
||||
// internet's version of the zone, if it has one); elsewhere, REFUSED.
|
||||
func disabledZoneEffect(st *model.State, z *model.Zone) string {
|
||||
where := "this server"
|
||||
if v := st.ViewName(z); v != "" {
|
||||
where = "view " + v
|
||||
}
|
||||
if st.RecursionOn(z.ViewID) {
|
||||
return fmt.Sprintf("once zone %s is disabled, clients of %s that may use recursion get its names from recursion: the internet's version of the zone, if there is one", dnsname.Display(z.Name), where)
|
||||
}
|
||||
return fmt.Sprintf("once zone %s is disabled, %s answers REFUSED for its names", dnsname.Display(z.Name), where)
|
||||
}
|
||||
|
||||
// exposureAck acknowledges an exposure finding of a view or the server,
|
||||
// or takes the acknowledgement back: the view's (or the settings')
|
||||
// acknowledgements are updated as a whole.
|
||||
func (x *expander) exposureAck(req Request, ack bool) ([]model.Op, error) {
|
||||
var in AckInput
|
||||
if err := decode(req.Data, &in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
in.Code = strings.TrimSpace(in.Code)
|
||||
if !validate.Ackable(in.Code) {
|
||||
return nil, errf(KindBadRequest, "%q is not an exposure finding that can be acknowledged (exposure_... or security_open_...)", in.Code)
|
||||
}
|
||||
if ack && strings.TrimSpace(in.Reason) == "" {
|
||||
return nil, errf(KindBadRequest, "say why %s is accepted (a reason is required)", in.Code)
|
||||
}
|
||||
var v *model.View
|
||||
switch {
|
||||
case in.ViewID != 0:
|
||||
v = x.st.Views[in.ViewID]
|
||||
case in.View != "":
|
||||
v = x.st.ViewByName(in.View)
|
||||
}
|
||||
if (in.ViewID != 0 || in.View != "") && v == nil {
|
||||
return nil, errf(KindNotFound, "no view %s", firstNonEmptyStr(in.View, fmt.Sprint(in.ViewID)))
|
||||
}
|
||||
acks := x.st.Settings.Acknowledged
|
||||
where := "the server"
|
||||
if v != nil {
|
||||
acks, where = v.Acknowledged, "view "+v.Name
|
||||
}
|
||||
var out []model.Acknowledgement
|
||||
for _, a := range acks {
|
||||
if a.Code != in.Code {
|
||||
out = append(out, a)
|
||||
}
|
||||
}
|
||||
summary := "acknowledge " + in.Code + " for " + where + ": " + strings.TrimSpace(in.Reason)
|
||||
if ack {
|
||||
out = append(out, model.Acknowledgement{Code: in.Code, Reason: strings.TrimSpace(in.Reason), By: x.p.Name(), At: x.now})
|
||||
} else {
|
||||
if len(out) == len(acks) {
|
||||
return nil, errf(KindConflict, "%s isn't acknowledged for %s", in.Code, where)
|
||||
}
|
||||
summary = "take back the acknowledgement of " + in.Code + " for " + where
|
||||
}
|
||||
if out == nil {
|
||||
out = []model.Acknowledgement{}
|
||||
}
|
||||
if v != nil {
|
||||
return []model.Op{newOp(OpViewUpdate, v.ID, v.Version, ViewInput{Acknowledged: &out}, summary)}, nil
|
||||
}
|
||||
return []model.Op{newOp(OpSettingsUpdate, 0, 0, SettingsInput{Acknowledged: &out}, summary)}, nil
|
||||
}
|
||||
|
||||
func firstNonEmptyStr(a, b string) string {
|
||||
if a != "" {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// notLinked refuses sharing a record of a zone that isn't linked.
|
||||
func notLinked(st *model.State, z *model.Zone) error {
|
||||
return errf(KindBadRequest, "zone %s isn't linked with other views, so there is nothing to share its records with (link it first: jarl zone link)", zoneDesc(st, z))
|
||||
}
|
||||
|
||||
// zoneUnchanged reports whether an update would leave the zone as it is.
|
||||
// Errors are left for applying the update to report.
|
||||
func zoneUnchanged(st *model.State, z *model.Zone, in *ZoneInput) bool {
|
||||
if len(in.Nameservers) > 0 || len(in.Records) > 0 || in.CloneFrom != 0 || len(in.Options) > 0 {
|
||||
return false
|
||||
}
|
||||
c := st.Clone()
|
||||
n := c.Zones[z.ID]
|
||||
if err := setZoneFields(c, n, in, false); err != nil {
|
||||
return false
|
||||
}
|
||||
return reflect.DeepEqual(n, z)
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"net/netip"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"jarl/internal/model"
|
||||
"jarl/internal/namedconf"
|
||||
)
|
||||
|
||||
// ExposureFix returns the requests that fix an exposure finding of a view
|
||||
// (viewID 0: the server), and what they do, or nil when there is no
|
||||
// automatic fix: what to allow instead (your secondaries, your hosts)
|
||||
// is for you to decide. The requests go through a changeset like any
|
||||
// edit (docs/exposure.md).
|
||||
func ExposureFix(st *model.State, viewID int64, code string) ([]Request, string) {
|
||||
v := st.Views[viewID]
|
||||
scope, scopeID := model.ScopeOptions, int64(0)
|
||||
if v != nil {
|
||||
scope, scopeID = model.ScopeView, v.ID
|
||||
}
|
||||
set := func(name, value string) Request {
|
||||
if o := st.FindOption(scope, scopeID, name); o != nil {
|
||||
return NewRequest(OpOptionUpdate, o.ID, OptionInput{Value: &value})
|
||||
}
|
||||
return NewRequest(OpOptionCreate, 0, OptionInput{Scope: &scope, ScopeID: &scopeID, Name: &name, Value: &value})
|
||||
}
|
||||
server := func(name, value string) Request {
|
||||
if o := st.FindOption(model.ScopeOptions, 0, name); o != nil {
|
||||
return NewRequest(OpOptionUpdate, o.ID, OptionInput{Value: &value})
|
||||
}
|
||||
return NewRequest(OpOptionCreate, 0, OptionInput{Scope: model.Ptr(model.ScopeOptions), Name: &name, Value: &value})
|
||||
}
|
||||
switch code {
|
||||
case "exposure_rate_limit":
|
||||
value := RecommendedRateLimit(st, v)
|
||||
return []Request{set("rate-limit", value)}, "start rate limiting in log-only mode: rate-limit " + value
|
||||
case "exposure_rate_limit_log_only":
|
||||
o := st.FindOption(scope, scopeID, "rate-limit")
|
||||
if o == nil {
|
||||
o = st.FindOption(model.ScopeOptions, 0, "rate-limit")
|
||||
}
|
||||
if o == nil {
|
||||
return nil, ""
|
||||
}
|
||||
value := withoutLogOnly(o.Value)
|
||||
return []Request{NewRequest(OpOptionUpdate, o.ID, OptionInput{Value: &value})}, "start enforcing the rate limit: rate-limit " + value
|
||||
case "exposure_minimal_any":
|
||||
return []Request{set("minimal-any", "yes")}, "answer ANY queries minimally: minimal-any yes"
|
||||
case "exposure_cache_snooping":
|
||||
return []Request{set("allow-query-cache", "{ none; }")}, "close the cache to everyone: allow-query-cache { none; }"
|
||||
case "exposure_version":
|
||||
return []Request{server("version", "none")}, "hide BIND's version: version none"
|
||||
case "exposure_hostname":
|
||||
return []Request{server("hostname", "none")}, "hide the host name: hostname none"
|
||||
case "security_open_resolver":
|
||||
if v == nil {
|
||||
return nil, "" // a server without views is also your own resolver
|
||||
}
|
||||
return []Request{set("recursion", "no")}, "turn recursion off in view " + v.Name + ": recursion no"
|
||||
}
|
||||
return nil, ""
|
||||
}
|
||||
|
||||
// RecommendedRateLimit is the rate-limit block Jarl suggests for a public
|
||||
// view (v nil: the server): ISC's example of 10 identical responses per
|
||||
// second per client network, in log-only mode until you have looked at
|
||||
// what it would limit, with your secondaries exempt so zone transfers
|
||||
// and their queries are never limited.
|
||||
func RecommendedRateLimit(st *model.State, v *model.View) string {
|
||||
parts := []string{"responses-per-second 10;", "log-only yes;"}
|
||||
if ex := secondaries(st, v); len(ex) > 0 {
|
||||
parts = append(parts, "exempt-clients { "+strings.Join(ex, "; ")+"; };")
|
||||
}
|
||||
return "{ " + strings.Join(parts, " ") + " }"
|
||||
}
|
||||
|
||||
// secondaries returns the addresses the view's zones notify and allow
|
||||
// transfers to (ACLs resolved), the servers that rate limiting must leave
|
||||
// alone.
|
||||
func secondaries(st *model.State, v *model.View) []string {
|
||||
var opts []*model.Option
|
||||
add := func(scope string, id int64) {
|
||||
for _, name := range []string{"also-notify", "allow-transfer"} {
|
||||
if o := st.FindOption(scope, id, name); o != nil {
|
||||
opts = append(opts, o)
|
||||
}
|
||||
}
|
||||
}
|
||||
add(model.ScopeOptions, 0)
|
||||
vid := int64(0)
|
||||
if v != nil {
|
||||
vid = v.ID
|
||||
add(model.ScopeView, v.ID)
|
||||
}
|
||||
for _, z := range st.SortedZones() {
|
||||
if z.ViewID == vid {
|
||||
add(model.ScopeZone, z.ID)
|
||||
}
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
var out []string
|
||||
var addElems func(list []string, depth int)
|
||||
addElems = func(list []string, depth int) {
|
||||
for _, s := range list {
|
||||
e, err := namedconf.ParseAMElement(s)
|
||||
if err != nil || e.Negated {
|
||||
continue
|
||||
}
|
||||
switch e.Kind {
|
||||
case namedconf.AMPrefix, namedconf.AMRange:
|
||||
if t := e.String(); !seen[t] {
|
||||
seen[t] = true
|
||||
out = append(out, t)
|
||||
}
|
||||
case namedconf.AMName:
|
||||
if a := st.ACLByName(e.Name); a != nil && depth < 10 {
|
||||
addElems(a.Elements, depth+1)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, o := range opts {
|
||||
stmt, err := namedconf.ParseOptionValue(o.Name, o.Value)
|
||||
if err != nil || stmt.FirstBlock() == nil {
|
||||
continue
|
||||
}
|
||||
var list []string
|
||||
for _, s := range stmt.FirstBlock().Stmts {
|
||||
if o.Name == "also-notify" {
|
||||
// "ADDRESS [port N] [key K]": the address only.
|
||||
if len(s.Items) > 0 && s.Items[0].Value != nil {
|
||||
if _, err := netip.ParseAddr(s.Items[0].Value.Text); err == nil {
|
||||
list = append(list, s.Items[0].Value.Text)
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
list = append(list, namedconf.Compact(s.Items))
|
||||
}
|
||||
addElems(list, 0)
|
||||
}
|
||||
slices.Sort(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// withoutLogOnly removes "log-only ...;" from a rate-limit block.
|
||||
func withoutLogOnly(value string) string {
|
||||
st, err := namedconf.ParseOptionValue("rate-limit", value)
|
||||
if err != nil || st.FirstBlock() == nil {
|
||||
return value
|
||||
}
|
||||
b := st.FirstBlock()
|
||||
var keep []*namedconf.Stmt
|
||||
for _, s := range b.Stmts {
|
||||
if s.Keyword() != "log-only" {
|
||||
keep = append(keep, s)
|
||||
}
|
||||
}
|
||||
b.Stmts = keep
|
||||
return namedconf.Compact(st.Items[1:])
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"net/netip"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"jarl/internal/model"
|
||||
"jarl/internal/validate"
|
||||
)
|
||||
|
||||
func problemFor(ps []validate.Problem, code, view string) *validate.Problem {
|
||||
for i, p := range ps {
|
||||
if p.Code == code && (view == "" || p.View == view) {
|
||||
return &ps[i]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestExposure(t *testing.T) {
|
||||
env := newEnv(t)
|
||||
setupZone(env)
|
||||
env.submit(env.admin,
|
||||
// An internal network with a global IPv6 prefix is still yours.
|
||||
NewRequest(OpACLCreate, 0, ACLInput{Name: model.Ptr("internal-clients"), Elements: &[]string{"2a02:8013:7f3d:10::/64", "192.168.1.0/24"}}),
|
||||
optionReq("allow-transfer", "{ 192.0.2.53; }"),
|
||||
)
|
||||
env.submit(env.admin,
|
||||
NewRequest(OpViewCreate, 0, ViewInput{Name: model.Ptr("internal"), MatchClients: &[]string{"internal-clients", "localhost"}, AdoptZones: true}),
|
||||
NewRequest(OpViewCreate, 0, ViewInput{Name: model.Ptr("external"), MatchClients: &[]string{"!internal-clients", "any"}}),
|
||||
NewRequest(OpViewCreate, 0, ViewInput{Name: model.Ptr("dmz"), MatchClients: &[]string{"any"}, MatchDestinations: &[]string{"10.9.0.53"}}),
|
||||
)
|
||||
st := env.state()
|
||||
exps := map[string]validate.Exposure{}
|
||||
for _, x := range env.e.Exposures(st) {
|
||||
exps[x.View] = x
|
||||
}
|
||||
if x := exps["internal"]; x.Public || !strings.Contains(x.Reason, "no catch-all") {
|
||||
t.Errorf("internal = %+v", x)
|
||||
}
|
||||
if x := exps["external"]; !x.Public || x.Reason != "match-clients ends in any" {
|
||||
t.Errorf("external = %+v", x)
|
||||
}
|
||||
if x := exps["dmz"]; x.Public || !strings.Contains(x.Reason, "match-destinations") {
|
||||
t.Errorf("dmz = %+v", x)
|
||||
}
|
||||
|
||||
ps := env.e.validate(st)
|
||||
for _, code := range []string{"exposure_rate_limit", "exposure_minimal_any"} {
|
||||
if problemFor(ps, code, "external") == nil {
|
||||
t.Errorf("no %s for external", code)
|
||||
}
|
||||
if problemFor(ps, code, "internal") != nil || problemFor(ps, code, "dmz") != nil {
|
||||
t.Errorf("%s for a private view", code)
|
||||
}
|
||||
}
|
||||
for _, code := range []string{"exposure_version", "exposure_hostname"} {
|
||||
if problemFor(ps, code, "") == nil {
|
||||
t.Errorf("no %s", code)
|
||||
}
|
||||
}
|
||||
|
||||
// The rate limit fix starts in log-only mode and exempts the
|
||||
// secondaries.
|
||||
ext := st.ViewByName("external")
|
||||
reqs, desc := ExposureFix(st, ext.ID, "exposure_rate_limit")
|
||||
if len(reqs) != 1 || !strings.Contains(desc, "{ responses-per-second 10; log-only yes; exempt-clients { 192.0.2.53; }; }") {
|
||||
t.Fatalf("fix = %v %q", reqs, desc)
|
||||
}
|
||||
env.submit(env.admin, reqs...)
|
||||
ps = env.e.validate(env.state())
|
||||
if problemFor(ps, "exposure_rate_limit", "external") != nil {
|
||||
t.Error("rate limit warning stays after the fix")
|
||||
}
|
||||
if p := problemFor(ps, "exposure_rate_limit_log_only", "external"); p == nil || p.Severity != validate.Info {
|
||||
t.Errorf("log-only = %+v", p)
|
||||
}
|
||||
reqs, desc = ExposureFix(env.state(), ext.ID, "exposure_rate_limit_log_only")
|
||||
if !strings.Contains(desc, "{ responses-per-second 10; exempt-clients { 192.0.2.53; }; }") {
|
||||
t.Errorf("enforce = %q", desc)
|
||||
}
|
||||
env.submit(env.admin, reqs...)
|
||||
if p := problemFor(env.e.validate(env.state()), "exposure_rate_limit_log_only", "external"); p != nil {
|
||||
t.Errorf("still log-only: %+v", p)
|
||||
}
|
||||
|
||||
// Opening the server up needs confirming.
|
||||
open := optionReqView("allow-recursion", "{ any; }", ext.ID)
|
||||
if e := env.submitErr(env.admin, open); e.Kind != KindRisk || !strings.Contains(e.Message, "open resolver") {
|
||||
t.Fatalf("open resolver without confirming: %+v", e)
|
||||
}
|
||||
if _, err := env.e.Submit(env.ctx, []Request{open}, env.admin, "test", "", ApplyOptions{AcceptRisk: true}); err != nil {
|
||||
t.Fatalf("with accept risk: %v", err)
|
||||
}
|
||||
// Acknowledged, it stays as info, with who and why.
|
||||
env.submit(env.admin, NewRequest(OpExposureAck, 0, AckInput{View: "external", Code: "security_open_resolver", Reason: "public resolver for the club"}))
|
||||
p := problemFor(env.e.validate(env.state()), "security_open_resolver", "external")
|
||||
if p == nil || p.Severity != validate.Info || p.Acknowledged == nil || !strings.Contains(p.Message, "acknowledged by admin on 7 Oct 2026: public resolver for the club") {
|
||||
t.Errorf("acknowledged = %+v", p)
|
||||
}
|
||||
if e := env.submitErr(env.admin, NewRequest(OpExposureAck, 0, AckInput{View: "external", Code: "security_open_resolver"})); e.Kind != KindBadRequest {
|
||||
t.Errorf("ack without a reason: %+v", e)
|
||||
}
|
||||
// Reverting the acknowledgement brings the warning back.
|
||||
env.submit(env.admin, NewRequest(OpExposureUnack, 0, AckInput{View: "external", Code: "security_open_resolver"}))
|
||||
if p := problemFor(env.e.validate(env.state()), "security_open_resolver", "external"); p == nil || p.Severity != validate.Warning {
|
||||
t.Errorf("after unack = %+v", p)
|
||||
}
|
||||
// Marking a view private takes it out of the checks.
|
||||
env.submit(env.admin, NewRequest(OpViewUpdate, ext.ID, ViewInput{Exposure: model.Ptr("private")}))
|
||||
if p := problemFor(env.e.validate(env.state()), "security_open_resolver", "external"); p != nil {
|
||||
t.Errorf("private view still checked: %+v", p)
|
||||
}
|
||||
if e := env.submitErr(env.admin, NewRequest(OpViewUpdate, ext.ID, ViewInput{Exposure: model.Ptr("internet")})); e.Kind != KindBadRequest {
|
||||
t.Errorf("bad exposure: %+v", e)
|
||||
}
|
||||
}
|
||||
|
||||
// A server without views is public only when named listens on an
|
||||
// address that isn't private.
|
||||
func TestServerExposure(t *testing.T) {
|
||||
env := newEnv(t)
|
||||
setupZone(env)
|
||||
if x := env.e.Exposures(env.state())[0]; x.Public {
|
||||
t.Errorf("no addresses known = %+v", x)
|
||||
}
|
||||
env.e.cfg.LocalAddrs = func() []netip.Addr {
|
||||
return []netip.Addr{netip.MustParseAddr("192.168.1.2"), netip.MustParseAddr("2a02:8013:7f3d:10::53")}
|
||||
}
|
||||
if x := env.e.Exposures(env.state())[0]; !x.Public || !strings.Contains(x.Reason, "2a02:8013:7f3d:10::53") {
|
||||
t.Errorf("GUA address = %+v", x)
|
||||
}
|
||||
env.submit(env.admin, NewRequest(OpSettingsUpdate, 0, SettingsInput{Exposure: model.Ptr("private")}))
|
||||
if x := env.e.Exposures(env.state())[0]; x.Public || x.Override != "private" {
|
||||
t.Errorf("marked private = %+v", x)
|
||||
}
|
||||
}
|
||||
|
||||
func optionReqView(name, value string, viewID int64) Request {
|
||||
return NewRequest(OpOptionCreate, 0, OptionInput{Scope: model.Ptr(model.ScopeView), ScopeID: &viewID, Name: &name, Value: &value})
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io/fs"
|
||||
)
|
||||
|
||||
// WriteFile writes a file outside the rendered configuration (a
|
||||
// certificate for named, docs/certificates.md) the way deployed files are
|
||||
// written: atomically, with bind.file_group, creating its directory.
|
||||
func (e *Engine) WriteFile(path string, content []byte, mode fs.FileMode) error {
|
||||
return e.deploy.writeAtomic(path, content, mode)
|
||||
}
|
||||
|
||||
// Reconfig makes named reread its configuration, and with it the files
|
||||
// its tls blocks name (nothing with bind.no_reload).
|
||||
func (e *Engine) Reconfig(ctx context.Context) error {
|
||||
if e.cfg.NoReload {
|
||||
return nil
|
||||
}
|
||||
return e.tools.Reconfig(ctx)
|
||||
}
|
||||
+138
-2
@@ -39,18 +39,60 @@ const (
|
||||
OpBlockRuleDelete = "blockrule.delete"
|
||||
OpBlockingPause = "blocking.pause"
|
||||
|
||||
OpBlockStepCreate = "blockstep.create"
|
||||
OpBlockStepUpdate = "blockstep.update"
|
||||
OpBlockStepDelete = "blockstep.delete"
|
||||
OpClientGroupCreate = "clientgroup.create"
|
||||
OpClientGroupUpdate = "clientgroup.update"
|
||||
OpClientGroupDelete = "clientgroup.delete"
|
||||
|
||||
// Requests only: disabling and enabling records, by ID or by match
|
||||
// (zone, name, type and optionally data), become record.update
|
||||
// operations.
|
||||
OpRecordDisable = "record.disable"
|
||||
OpRecordEnable = "record.enable"
|
||||
// Acknowledging an exposure finding (docs/exposure.md), and taking it
|
||||
// back, become view.update or settings.update operations.
|
||||
OpExposureAck = "exposure.ack"
|
||||
OpExposureUnack = "exposure.unack"
|
||||
// Linked zones (docs/views.md#linked-zones): requests that expand
|
||||
// into zone and record updates.
|
||||
OpZoneLink = "zone.link"
|
||||
OpZoneUnlink = "zone.unlink"
|
||||
// Filling a reverse zone with the PTR records of its view's A and
|
||||
// AAAA records (docs/reverse.md) becomes record.create operations.
|
||||
OpZoneFillPTR = "zone.fill_ptr"
|
||||
// Dismissing a reverse zone suggestion, and taking that back, become
|
||||
// view.update or settings.update operations.
|
||||
OpReverseDismiss = "reverse.dismiss"
|
||||
OpReverseUndismiss = "reverse.undismiss"
|
||||
)
|
||||
|
||||
// DismissInput dismisses a reverse zone suggestion: a network in a view
|
||||
// (by ID or name), or the server's (neither given).
|
||||
type DismissInput struct {
|
||||
ViewID int64 `json:"view_id,omitempty"`
|
||||
View string `json:"view,omitempty"`
|
||||
Prefix string `json:"prefix,omitempty"`
|
||||
// Prefixes dismisses (or brings back) several at once.
|
||||
Prefixes []string `json:"prefixes,omitempty"`
|
||||
}
|
||||
|
||||
// AckInput acknowledges (or un-acknowledges) an exposure finding of a view
|
||||
// (by ID or name), or of the server (neither given).
|
||||
type AckInput struct {
|
||||
ViewID int64 `json:"view_id,omitempty"`
|
||||
View string `json:"view,omitempty"`
|
||||
Code string `json:"code"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
}
|
||||
|
||||
// OpKinds lists all operation kinds.
|
||||
var OpKinds = []string{OpZoneCreate, OpZoneUpdate, OpZoneDelete, OpRecordCreate, OpRecordUpdate, OpRecordDelete, OpRRSetReplace,
|
||||
OpViewCreate, OpViewUpdate, OpViewDelete, OpViewReorder, OpACLCreate, OpACLUpdate, OpACLDelete,
|
||||
OpTSIGCreate, OpTSIGUpdate, OpTSIGDelete, OpOptionCreate, OpOptionUpdate, OpOptionDelete, OpSettingsUpdate,
|
||||
OpBlocklistCreate, OpBlocklistUpdate, OpBlocklistDelete, OpBlockRuleCreate, OpBlockRuleUpdate, OpBlockRuleDelete, OpBlockingPause}
|
||||
OpBlocklistCreate, OpBlocklistUpdate, OpBlocklistDelete, OpBlockRuleCreate, OpBlockRuleUpdate, OpBlockRuleDelete, OpBlockingPause,
|
||||
OpBlockStepCreate, OpBlockStepUpdate, OpBlockStepDelete, OpClientGroupCreate, OpClientGroupUpdate, OpClientGroupDelete}
|
||||
|
||||
// Request is an operation as submitted by a client. Requests are expanded
|
||||
// into one or more stored operations (for example an A record with "ptr"
|
||||
@@ -108,6 +150,24 @@ type ZoneInput struct {
|
||||
// finished, off ("").
|
||||
DNSSECPolicy *string `json:"dnssec_policy,omitempty"`
|
||||
Comment *string `json:"comment,omitempty"`
|
||||
// Disabled keeps the zone in Jarl but out of named's configuration.
|
||||
Disabled *bool `json:"disabled,omitempty"`
|
||||
// LinkGroup is the zone's link group (0: not linked). Set by zone.link
|
||||
// and zone.unlink; requests use those.
|
||||
LinkGroup *int64 `json:"link_group,omitempty"`
|
||||
// LinkWith (create only) links the new zone with the zone of the same
|
||||
// name in this view, copying its shared records (or all of its
|
||||
// records, when it isn't linked yet).
|
||||
LinkWith string `json:"link_with,omitempty"`
|
||||
// AutoPTR keeps the PTR records of the zone's A and AAAA records in
|
||||
// step (docs/reverse.md).
|
||||
AutoPTR *bool `json:"auto_ptr,omitempty"`
|
||||
// Fill (create only, reverse zones) adds a PTR record for every
|
||||
// address of the view's A and AAAA records in the zone's range;
|
||||
// FillOtherViews also for the addresses only other views' records
|
||||
// use, named from there.
|
||||
Fill bool `json:"fill,omitempty"`
|
||||
FillOtherViews bool `json:"fill_other_views,omitempty"`
|
||||
|
||||
// Create only: name servers for the apex NS records (default: the
|
||||
// configured default name servers), additional records, and a zone to
|
||||
@@ -140,10 +200,38 @@ type RecordInput struct {
|
||||
// Disabled keeps the record in Jarl but out of the zone.
|
||||
Disabled *bool `json:"disabled,omitempty"`
|
||||
// PTR also creates (or deletes, disables or enables) the matching PTR
|
||||
// record in the reverse zone, for A and AAAA records.
|
||||
// record in the reverse zone, for A and AAAA records. On an update that
|
||||
// changes nothing else it adds the PTR record if it is missing. In a
|
||||
// linked zone, every linked view's reverse zone counts.
|
||||
PTR bool `json:"ptr,omitempty"`
|
||||
// MissingOK makes a delete-by-match succeed when nothing matches.
|
||||
MissingOK bool `json:"missing_ok,omitempty"`
|
||||
// Local (requests, linked zones): true keeps the record in this view
|
||||
// only (an override: the whole name and type becomes local here);
|
||||
// false shares it with the linked zones. Unset: like the records of
|
||||
// the same name and type, shared when there are none.
|
||||
Local *bool `json:"local,omitempty"`
|
||||
// SharedID (stored operations) ties the copies of a shared record
|
||||
// together; 0 makes it local.
|
||||
SharedID *int64 `json:"shared_id,omitempty"`
|
||||
}
|
||||
|
||||
// LinkInput links zones of the same name in several views, or (unlink)
|
||||
// takes one out of its link.
|
||||
type LinkInput struct {
|
||||
ZoneID int64 `json:"zone_id,omitempty"`
|
||||
Zone string `json:"zone,omitempty"`
|
||||
View string `json:"view,omitempty"`
|
||||
// With are the views whose zone of the same name to link with (link
|
||||
// only; default: every view that has one).
|
||||
With []string `json:"with,omitempty"`
|
||||
}
|
||||
|
||||
// FillPTRInput is zone.fill_ptr: OtherViews also fills the addresses only
|
||||
// other views' records use, naming them from there.
|
||||
type FillPTRInput struct {
|
||||
ZoneID int64 `json:"zone_id,omitempty"`
|
||||
OtherViews bool `json:"other_views,omitempty"`
|
||||
}
|
||||
|
||||
// RRSetInput replaces all records of one name and type.
|
||||
@@ -158,6 +246,11 @@ type RRSetInput struct {
|
||||
Comment *string `json:"comment,omitempty"`
|
||||
// NewIDs are allocated for records that need to be created.
|
||||
NewIDs []int64 `json:"new_ids,omitempty"`
|
||||
// Local (requests): like RecordInput.Local, for the whole set.
|
||||
Local *bool `json:"local,omitempty"`
|
||||
// Shared (stored operations, linked zones): the records are shared
|
||||
// (true) or local (false).
|
||||
Shared *bool `json:"shared,omitempty"`
|
||||
}
|
||||
|
||||
// ViewInput creates or updates a view.
|
||||
@@ -174,6 +267,14 @@ type ViewInput struct {
|
||||
AdoptZones bool `json:"adopt_zones,omitempty"`
|
||||
// Cascade (delete) also deletes the view's zones.
|
||||
Cascade bool `json:"cascade,omitempty"`
|
||||
// Exposure is "auto" (or ""), "public" or "private" (docs/exposure.md).
|
||||
Exposure *string `json:"exposure,omitempty"`
|
||||
Acknowledged *[]model.Acknowledgement `json:"acknowledged,omitempty"`
|
||||
// ReverseSuggestions turns the reverse zone suggestions for the
|
||||
// view's addresses on or off (docs/reverse.md).
|
||||
ReverseSuggestions *bool `json:"reverse_suggestions,omitempty"`
|
||||
// DismissedReverse are the networks whose suggestion was dismissed.
|
||||
DismissedReverse *[]string `json:"dismissed_reverse,omitempty"`
|
||||
}
|
||||
|
||||
// ReorderInput sets the order of views.
|
||||
@@ -217,6 +318,14 @@ type SettingsInput struct {
|
||||
Expire *uint32 `json:"expire,omitempty"`
|
||||
NegativeTTL *uint32 `json:"negative_ttl,omitempty"`
|
||||
SerialPolicy *string `json:"serial_policy,omitempty"`
|
||||
// Exposure and Acknowledged are the server's (docs/exposure.md).
|
||||
Exposure *string `json:"exposure,omitempty"`
|
||||
Acknowledged *[]model.Acknowledgement `json:"acknowledged,omitempty"`
|
||||
// DismissedReverse are dismissed reverse zone suggestions (without
|
||||
// views).
|
||||
DismissedReverse *[]string `json:"dismissed_reverse,omitempty"`
|
||||
// EncryptedDNSHosts are the encrypted DNS setup's host names.
|
||||
EncryptedDNSHosts *[]string `json:"encrypted_dns_hosts,omitempty"`
|
||||
}
|
||||
|
||||
// DeleteInput carries options for delete operations.
|
||||
@@ -234,6 +343,31 @@ type BlocklistInput struct {
|
||||
ViewIDs *[]int64 `json:"view_ids,omitempty"`
|
||||
Disabled *bool `json:"disabled,omitempty"`
|
||||
Comment *string `json:"comment,omitempty"`
|
||||
// Step is the step the list starts at, by name ("normal" or a step
|
||||
// above it) or ID (StepID; 0 is normal).
|
||||
Step *string `json:"step,omitempty"`
|
||||
StepID *int64 `json:"step_id,omitempty"`
|
||||
}
|
||||
|
||||
// BlockStepInput creates or updates a blocking step above Normal.
|
||||
// Position orders the steps (new steps go on top).
|
||||
type BlockStepInput struct {
|
||||
Name *string `json:"name,omitempty"`
|
||||
Position *int `json:"position,omitempty"`
|
||||
Comment *string `json:"comment,omitempty"`
|
||||
}
|
||||
|
||||
// ClientGroupInput creates or updates a client group. Members are
|
||||
// addresses, prefixes or ACL names; the step is given by name ("off",
|
||||
// "normal" or a step) or ID; views as for BlocklistInput.
|
||||
type ClientGroupInput struct {
|
||||
Name *string `json:"name,omitempty"`
|
||||
Members *[]string `json:"members,omitempty"`
|
||||
Step *string `json:"step,omitempty"`
|
||||
StepID *int64 `json:"step_id,omitempty"`
|
||||
Views *[]string `json:"views,omitempty"`
|
||||
ViewIDs *[]int64 `json:"view_ids,omitempty"`
|
||||
Comment *string `json:"comment,omitempty"`
|
||||
}
|
||||
|
||||
// BlockRuleInput creates or updates a block or allow rule. Views as for
|
||||
@@ -250,6 +384,8 @@ type BlockRuleInput struct {
|
||||
// BlockingPauseInput pauses blocking until a time, or resumes it (null).
|
||||
type BlockingPauseInput struct {
|
||||
Until *time.Time `json:"until"`
|
||||
// Indefinitely pauses until someone resumes blocking (Until is nil).
|
||||
Indefinitely bool `json:"indefinitely,omitempty"`
|
||||
}
|
||||
|
||||
// MaxBlockingPause is the longest pause: blocking shouldn't stay off by
|
||||
|
||||
@@ -0,0 +1,514 @@
|
||||
package engine
|
||||
|
||||
// Linked zones (docs/views.md#linked-zones). Zones of the same name in
|
||||
// different views can be linked: their shared records are kept in step,
|
||||
// and each zone can override a name and type with records local to its
|
||||
// view. Every zone keeps its own copy of each shared record (with the
|
||||
// same SharedID), so the rest of Jarl sees ordinary zones.
|
||||
//
|
||||
// Keeping them in step happens when requests are expanded: the request's
|
||||
// operations are applied to a scratch copy, and linkSync adds explicit
|
||||
// operations for the other zones of the link. So a change to a shared
|
||||
// record is one changeset with one operation per zone: the diff, the
|
||||
// permission checks, history and undo all see every zone.
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"jarl/internal/dnsname"
|
||||
"jarl/internal/model"
|
||||
)
|
||||
|
||||
type rrKey struct {
|
||||
zone int64
|
||||
name, typ string
|
||||
}
|
||||
|
||||
// linkSync returns ops followed by the operations that bring the other
|
||||
// zones of each affected link in step.
|
||||
func (x *expander) linkSync(ops []model.Op) ([]model.Op, error) {
|
||||
if !x.st.AnyLinked() {
|
||||
return ops, nil
|
||||
}
|
||||
work := x.st.Clone()
|
||||
c := &applyCtx{p: x.p, now: x.now}
|
||||
for _, op := range ops {
|
||||
if err := applyOp(work, op, c); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
keys := affectedKeys(x.st, work, ops)
|
||||
var extra []model.Op
|
||||
for _, k := range keys {
|
||||
z := work.Zones[k.zone]
|
||||
if z == nil || z.LinkGroup == 0 {
|
||||
continue
|
||||
}
|
||||
more, err := x.syncKey(work, z, k.name, k.typ)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, op := range more {
|
||||
if err := applyOp(work, op, c); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
extra = append(extra, more...)
|
||||
}
|
||||
return append(ops, extra...), nil
|
||||
}
|
||||
|
||||
// affectedKeys lists the (zone, name, type) sets that ops touch, before
|
||||
// and after.
|
||||
func affectedKeys(pre, post *model.State, ops []model.Op) []rrKey {
|
||||
var out []rrKey
|
||||
seen := map[rrKey]bool{}
|
||||
add := func(zone int64, name, typ string) {
|
||||
k := rrKey{zone, name, typ}
|
||||
if zone != 0 && name != "" && !seen[k] {
|
||||
seen[k] = true
|
||||
out = append(out, k)
|
||||
}
|
||||
}
|
||||
fromRecord := func(id int64) {
|
||||
for _, st := range []*model.State{pre, post} {
|
||||
if r := st.Records[id]; r != nil {
|
||||
add(r.ZoneID, r.Name, r.Type)
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, op := range ops {
|
||||
switch op.Kind {
|
||||
case OpRecordCreate, OpRecordUpdate, OpRecordDelete:
|
||||
if op.ID != 0 {
|
||||
fromRecord(op.ID)
|
||||
}
|
||||
if op.Kind == OpRecordDelete && op.ID == 0 {
|
||||
var in RecordInput
|
||||
if decode(op.Data, &in) == nil && in.Name != nil && in.Type != nil {
|
||||
add(in.ZoneID, *in.Name, *in.Type)
|
||||
}
|
||||
}
|
||||
case OpRRSetReplace:
|
||||
var in RRSetInput
|
||||
if decode(op.Data, &in) == nil {
|
||||
add(in.ZoneID, in.Name, in.Type)
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// syncKey brings the other zones of z's link in step with z for one name
|
||||
// and type, after z was changed:
|
||||
//
|
||||
// - z overrides it (local records): z's shared copies go; the others
|
||||
// keep theirs.
|
||||
// - z's override was just removed: z goes without the name and type,
|
||||
// and the other zones' shared copies become local to each.
|
||||
// - otherwise z's shared records are the truth: every zone that doesn't
|
||||
// override the name and type gets the same (created, updated or
|
||||
// deleted to match).
|
||||
func (x *expander) syncKey(st *model.State, z *model.Zone, name, typ string) ([]model.Op, error) {
|
||||
var ops []model.Op
|
||||
where := func(o *model.Zone) string { return zoneDesc(st, o) }
|
||||
if st.HasLocal(z.ID, name, typ) {
|
||||
for _, r := range st.SharedRecords(z.ID, name, typ) {
|
||||
ops = append(ops, newOp(OpRecordDelete, r.ID, r.Version, nil,
|
||||
fmt.Sprintf("linked: %s %s is local in %s now, so its shared %s goes there", name, typ, where(z), r.Data)))
|
||||
}
|
||||
return ops, nil
|
||||
}
|
||||
truth := st.SharedRecords(z.ID, name, typ)
|
||||
members := st.LinkedWith(z)
|
||||
if len(truth) == 0 && x.st.HasLocal(z.ID, name, typ) && x.sharing[rrKey{z.ID, name, typ}] {
|
||||
// Another view's version was shared (dropOverrides): z gets it.
|
||||
for _, o := range members {
|
||||
if src := st.SharedRecords(o.ID, name, typ); len(src) > 0 && !st.HasLocal(o.ID, name, typ) {
|
||||
for _, r := range src {
|
||||
op, err := x.copyRecord(st, z, r, "linked: "+name+" "+typ+" is shared again in "+where(z)+": add "+r.Data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ops = append(ops, op)
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
return ops, nil
|
||||
}
|
||||
if len(truth) == 0 && x.st.HasLocal(z.ID, name, typ) {
|
||||
// z's local records are gone, and with them the name and type from
|
||||
// z: the other views keep theirs, each its own now (a shared set
|
||||
// would be in z too). Share sets one version for all again.
|
||||
for _, o := range members {
|
||||
if st.HasLocal(o.ID, name, typ) {
|
||||
continue
|
||||
}
|
||||
var more []model.Op
|
||||
for _, r := range st.SharedRecords(o.ID, name, typ) {
|
||||
more = append(more, newOp(OpRecordUpdate, r.ID, r.Version, RecordInput{SharedID: model.Ptr(int64(0))},
|
||||
fmt.Sprintf("linked: %s %s %s stays in %s, local to it now (%s has none)", name, typ, r.Data, where(o), where(z))))
|
||||
}
|
||||
if len(more) > 0 && !x.p.Can(recordAction(st, o, name, typ, true)) {
|
||||
return nil, errf(KindForbidden, "deleting %s %s from %s makes it local in %s, which you may not change; ask someone who may to delete it, or share %s's version (jarl record update ID --shared)",
|
||||
dnsname.Display(name), typ, where(z), where(o), where(o))
|
||||
}
|
||||
ops = append(ops, more...)
|
||||
}
|
||||
return ops, nil
|
||||
}
|
||||
for _, o := range members {
|
||||
if st.HasLocal(o.ID, name, typ) {
|
||||
continue // o overrides this name and type
|
||||
}
|
||||
copies := map[int64]*model.Record{}
|
||||
for _, r := range st.SharedRecords(o.ID, name, typ) {
|
||||
copies[r.SharedID] = r
|
||||
}
|
||||
var more []model.Op
|
||||
for _, t := range truth {
|
||||
cp := copies[t.SharedID]
|
||||
delete(copies, t.SharedID)
|
||||
if cp == nil {
|
||||
op, err := x.copyRecord(st, o, t, fmt.Sprintf("linked: add %s %s %s in %s", name, typ, t.Data, where(o)))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
more = append(more, op)
|
||||
continue
|
||||
}
|
||||
in, changes := recordDiff(cp, t)
|
||||
if len(changes) > 0 {
|
||||
more = append(more, newOp(OpRecordUpdate, cp.ID, cp.Version, in,
|
||||
fmt.Sprintf("linked: change %s %s %s in %s: %s", name, typ, cp.Data, where(o), strings.Join(changes, ", "))))
|
||||
}
|
||||
}
|
||||
for _, id := range sortedKeys(copies) {
|
||||
cp := copies[id]
|
||||
more = append(more, newOp(OpRecordDelete, cp.ID, cp.Version, nil, fmt.Sprintf("linked: delete %s %s %s in %s", name, typ, cp.Data, where(o))))
|
||||
}
|
||||
if len(more) > 0 && !x.p.Can(recordAction(st, o, name, typ, true)) {
|
||||
return nil, errf(KindForbidden, "%s %s is shared with %s, which you may not change; to change it in %s only, make it local to that view (local: true, or --local)",
|
||||
dnsname.Display(name), typ, where(o), where(z))
|
||||
}
|
||||
ops = append(ops, more...)
|
||||
}
|
||||
return ops, nil
|
||||
}
|
||||
|
||||
// copyRecord returns an operation that creates a copy of shared record r
|
||||
// in zone z.
|
||||
func (x *expander) copyRecord(st *model.State, z *model.Zone, r *model.Record, summary string) (model.Op, error) {
|
||||
if !x.p.Can(recordAction(st, z, r.Name, r.Type, true)) {
|
||||
return model.Op{}, errf(KindForbidden, "%s %s is shared with %s, which you may not change", dnsname.Display(r.Name), r.Type, zoneDesc(st, z))
|
||||
}
|
||||
ids, err := x.ids(1)
|
||||
if err != nil {
|
||||
return model.Op{}, err
|
||||
}
|
||||
return newOp(OpRecordCreate, ids[0], 0, recordCopyInput(z.ID, r), summary), nil
|
||||
}
|
||||
|
||||
func recordCopyInput(zoneID int64, r *model.Record) RecordInput {
|
||||
in := RecordInput{ZoneID: zoneID, Name: model.Ptr(r.Name), Type: model.Ptr(r.Type), Data: model.Ptr(r.Data),
|
||||
Comment: model.Ptr(r.Comment), Disabled: model.Ptr(r.Disabled), SharedID: model.Ptr(r.SharedID)}
|
||||
if r.TTL != nil {
|
||||
in.TTL = model.Some(*r.TTL)
|
||||
} else {
|
||||
in.TTL = model.Opt[uint32]{Set: true, Null: true}
|
||||
}
|
||||
return in
|
||||
}
|
||||
|
||||
// recordDiff returns the update that makes cp like t, and what changes.
|
||||
func recordDiff(cp, t *model.Record) (RecordInput, []string) {
|
||||
var in RecordInput
|
||||
var changes []string
|
||||
if cp.Data != t.Data {
|
||||
in.Data = model.Ptr(t.Data)
|
||||
changes = append(changes, "data "+t.Data)
|
||||
}
|
||||
if !sameTTL(cp.TTL, t.TTL) {
|
||||
if t.TTL != nil {
|
||||
in.TTL = model.Some(*t.TTL)
|
||||
changes = append(changes, fmt.Sprintf("ttl %d", *t.TTL))
|
||||
} else {
|
||||
in.TTL = model.Opt[uint32]{Set: true, Null: true}
|
||||
changes = append(changes, "ttl default")
|
||||
}
|
||||
}
|
||||
if cp.Comment != t.Comment {
|
||||
in.Comment = model.Ptr(t.Comment)
|
||||
changes = append(changes, "comment")
|
||||
}
|
||||
if cp.Disabled != t.Disabled {
|
||||
in.Disabled = model.Ptr(t.Disabled)
|
||||
changes = append(changes, enableWord(t.Disabled)+"d")
|
||||
}
|
||||
return in, changes
|
||||
}
|
||||
|
||||
// linkTarget resolves the zone a link request names.
|
||||
func (x *expander) linkTarget(in LinkInput) (*model.Zone, error) {
|
||||
z, err := x.resolveZone(in.ZoneID, in.Zone, in.View)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if z.Type != model.ZonePrimary {
|
||||
return nil, errf(KindBadRequest, "only primary zones can be linked (%s is %s)", zoneDesc(x.st, z), z.Type)
|
||||
}
|
||||
return z, nil
|
||||
}
|
||||
|
||||
// zoneLink links zones of the same name in several views. It never
|
||||
// changes what named answers: a name and type with the same records in
|
||||
// every zone becomes shared; anything that differs stays local to each
|
||||
// view (and can be shared later, from the view whose version is right).
|
||||
func (x *expander) zoneLink(req Request) ([]model.Op, error) {
|
||||
var in LinkInput
|
||||
if err := decode(req.Data, &in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
z, err := x.linkTarget(in)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
members := []*model.Zone{z}
|
||||
add := func(o *model.Zone) {
|
||||
if !slices.ContainsFunc(members, func(m *model.Zone) bool { return m.ID == o.ID }) {
|
||||
members = append(members, o)
|
||||
}
|
||||
}
|
||||
if len(in.With) == 0 {
|
||||
for _, o := range x.st.ZonesNamed(z.Name) {
|
||||
if o.Type == model.ZonePrimary && o.ID != z.ID {
|
||||
add(o)
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, vn := range in.With {
|
||||
v := x.st.ViewByName(vn)
|
||||
if v == nil {
|
||||
return nil, errf(KindNotFound, "view %q does not exist", vn)
|
||||
}
|
||||
o := x.st.FindZone(z.Name, v.ID)
|
||||
if o == nil {
|
||||
return nil, errf(KindNotFound, "view %s has no zone %s (create it linked: jarl zone create %s --view %s --link-with %s)", v.Name, dnsname.Display(z.Name), dnsname.Display(z.Name), v.Name, x.st.ViewName(z))
|
||||
}
|
||||
if o.Type != model.ZonePrimary {
|
||||
return nil, errf(KindBadRequest, "only primary zones can be linked (%s is %s)", zoneDesc(x.st, o), o.Type)
|
||||
}
|
||||
add(o)
|
||||
}
|
||||
// Zones already linked with any of them join too.
|
||||
for _, m := range slices.Clone(members) {
|
||||
for _, o := range x.st.LinkMembers(m.LinkGroup) {
|
||||
add(o)
|
||||
}
|
||||
}
|
||||
if len(members) < 2 {
|
||||
return nil, errf(KindBadRequest, "zone %s exists in no other view to link it with", dnsname.Display(z.Name))
|
||||
}
|
||||
x.st.SortByView(members)
|
||||
group := members[0].ID
|
||||
for _, m := range members {
|
||||
if m.LinkGroup != 0 {
|
||||
group = m.LinkGroup
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// What is the same everywhere?
|
||||
type key struct{ name, typ string }
|
||||
sig := func(rs []*model.Record) string {
|
||||
var parts []string
|
||||
for _, r := range rs {
|
||||
ttl := "-"
|
||||
if r.TTL != nil {
|
||||
ttl = fmt.Sprint(*r.TTL)
|
||||
}
|
||||
parts = append(parts, fmt.Sprintf("%s|%s|%v", strings.ToLower(r.Data), ttl, r.Disabled))
|
||||
}
|
||||
slices.Sort(parts)
|
||||
return strings.Join(parts, "\n")
|
||||
}
|
||||
keys := map[key]bool{}
|
||||
var order []key
|
||||
for _, m := range members {
|
||||
for _, r := range x.st.ZoneRecords(m.ID) {
|
||||
k := key{r.Name, r.Type}
|
||||
if !keys[k] {
|
||||
keys[k] = true
|
||||
order = append(order, k)
|
||||
}
|
||||
}
|
||||
}
|
||||
var ops []model.Op
|
||||
var local []string
|
||||
shared := 0
|
||||
for _, k := range order {
|
||||
first := x.st.RRset(members[0].ID, k.name, k.typ)
|
||||
same := len(first) > 0
|
||||
for _, m := range members[1:] {
|
||||
if rs := x.st.RRset(m.ID, k.name, k.typ); len(rs) == 0 || sig(rs) != sig(first) {
|
||||
same = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if same {
|
||||
shared++
|
||||
} else {
|
||||
var in []string
|
||||
for _, m := range members {
|
||||
if len(x.st.RRset(m.ID, k.name, k.typ)) > 0 {
|
||||
in = append(in, x.st.ViewName(m))
|
||||
}
|
||||
}
|
||||
local = append(local, fmt.Sprintf("%s %s (%s)", dnsname.Display(k.name), k.typ, strings.Join(in, ", ")))
|
||||
}
|
||||
// Pair the copies: record i of the first zone's set (by data) with
|
||||
// the same data in the others.
|
||||
byData := func(rs []*model.Record) []*model.Record {
|
||||
out := slices.Clone(rs)
|
||||
slices.SortStableFunc(out, func(a, b *model.Record) int { return strings.Compare(strings.ToLower(a.Data), strings.ToLower(b.Data)) })
|
||||
return out
|
||||
}
|
||||
ref := byData(first)
|
||||
for _, m := range members {
|
||||
rs := byData(x.st.RRset(m.ID, k.name, k.typ))
|
||||
for i, r := range rs {
|
||||
want := int64(0)
|
||||
var comment *string
|
||||
if same {
|
||||
want = ref[i].ID
|
||||
if r.Comment != ref[i].Comment && ref[i].Comment != "" {
|
||||
comment = model.Ptr(ref[i].Comment)
|
||||
}
|
||||
}
|
||||
if r.SharedID == want && comment == nil {
|
||||
continue
|
||||
}
|
||||
in := RecordInput{SharedID: model.Ptr(want), Comment: comment}
|
||||
what := "local to view " + x.st.ViewName(m)
|
||||
if same {
|
||||
what = "shared"
|
||||
}
|
||||
ops = append(ops, newOp(OpRecordUpdate, r.ID, r.Version, in, fmt.Sprintf("%s %s %s: %s", r.Name, r.Type, r.Data, what)))
|
||||
}
|
||||
}
|
||||
}
|
||||
var views []string
|
||||
for _, m := range members {
|
||||
views = append(views, x.st.ViewName(m))
|
||||
}
|
||||
var zoneOps []model.Op
|
||||
for _, m := range members {
|
||||
if m.LinkGroup != group {
|
||||
zoneOps = append(zoneOps, newOp(OpZoneUpdate, m.ID, m.Version, ZoneInput{LinkGroup: model.Ptr(group)},
|
||||
fmt.Sprintf("link zone %s in views %s", zoneDesc(x.st, m), strings.Join(views, ", "))))
|
||||
}
|
||||
}
|
||||
if len(local) > 0 {
|
||||
x.warnings = append(x.warnings, fmt.Sprintf("%d names differ between the views and stay local to each: %s. Share one from the view whose version is right (jarl record update ID --shared, or Share in the web UI).", len(local), strings.Join(local, "; ")))
|
||||
}
|
||||
x.warnings = append(x.warnings, fmt.Sprintf("%d names and types are shared between views %s", shared, strings.Join(views, ", ")))
|
||||
return append(zoneOps, ops...), nil
|
||||
}
|
||||
|
||||
// zoneUnlink takes a zone out of its link: its records stay as they are,
|
||||
// all local. A link left with one zone is dissolved.
|
||||
func (x *expander) zoneUnlink(req Request) ([]model.Op, error) {
|
||||
var in LinkInput
|
||||
if err := decode(req.Data, &in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
z, err := x.resolveZone(in.ZoneID, in.Zone, in.View)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if z.LinkGroup == 0 {
|
||||
return nil, errf(KindConflict, "zone %s isn't linked", zoneDesc(x.st, z))
|
||||
}
|
||||
out := []*model.Zone{z}
|
||||
if rest := x.st.LinkedWith(z); len(rest) == 1 {
|
||||
out = append(out, rest[0])
|
||||
}
|
||||
var ops []model.Op
|
||||
for _, m := range out {
|
||||
ops = append(ops, x.unlinkOps(m, "unlink zone "+zoneDesc(x.st, m))...)
|
||||
}
|
||||
return ops, nil
|
||||
}
|
||||
|
||||
// unlinkOps returns the operations that take zone m out of its link.
|
||||
func (x *expander) unlinkOps(m *model.Zone, summary string) []model.Op {
|
||||
ops := []model.Op{newOp(OpZoneUpdate, m.ID, m.Version, ZoneInput{LinkGroup: model.Ptr(int64(0))}, summary)}
|
||||
for _, r := range x.st.ZoneRecords(m.ID) {
|
||||
if r.SharedID != 0 {
|
||||
ops = append(ops, newOp(OpRecordUpdate, r.ID, r.Version, RecordInput{SharedID: model.Ptr(int64(0))}, fmt.Sprintf("%s %s %s: no longer shared", r.Name, r.Type, r.Data)))
|
||||
}
|
||||
}
|
||||
return ops
|
||||
}
|
||||
|
||||
// localFor works out whether a new record (or set) in zone z is local:
|
||||
// as asked, or like the records of the same name and type already there.
|
||||
// A new name and type is shared, unless the principal may not change it
|
||||
// in every linked view (an ACME token for the external view only): then
|
||||
// it is local to z's view, which is all they may change.
|
||||
func (x *expander) localFor(z *model.Zone, name, typ string, want *bool) (bool, error) {
|
||||
if want != nil {
|
||||
return *want, nil
|
||||
}
|
||||
if x.st.HasLocal(z.ID, name, typ) {
|
||||
return true, nil
|
||||
}
|
||||
if len(x.st.SharedRecords(z.ID, name, typ)) > 0 {
|
||||
return false, nil
|
||||
}
|
||||
var not []string
|
||||
for _, o := range x.st.LinkedWith(z) {
|
||||
if !x.p.Can(recordAction(x.st, o, name, typ, true)) {
|
||||
not = append(not, x.st.ViewName(o))
|
||||
}
|
||||
}
|
||||
if len(not) > 0 {
|
||||
x.warnings = append(x.warnings, fmt.Sprintf("%s %s is local to view %s: you may not change it in %s", dnsname.Display(name), typ, x.st.ViewName(z), strings.Join(not, ", ")))
|
||||
return true, nil
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// shareAllOps returns the operations that start a link with zone z: it
|
||||
// joins group and all its records become shared.
|
||||
func (x *expander) shareAllOps(z *model.Zone, group int64) []model.Op {
|
||||
ops := []model.Op{newOp(OpZoneUpdate, z.ID, z.Version, ZoneInput{LinkGroup: model.Ptr(group)}, "link zone "+zoneDesc(x.st, z))}
|
||||
for _, r := range x.st.ZoneRecords(z.ID) {
|
||||
if r.SharedID == 0 {
|
||||
ops = append(ops, newOp(OpRecordUpdate, r.ID, r.Version, RecordInput{SharedID: model.Ptr(r.ID)}, fmt.Sprintf("%s %s %s: shared", r.Name, r.Type, r.Data)))
|
||||
}
|
||||
}
|
||||
return ops
|
||||
}
|
||||
|
||||
// dropOverrides returns the operations that delete the other linked
|
||||
// zones' local records of a name and type, so the shared version reaches
|
||||
// them (sharing a version makes it every view's).
|
||||
func (x *expander) dropOverrides(z *model.Zone, name, typ string) []model.Op {
|
||||
var ops []model.Op
|
||||
for _, o := range x.st.LinkedWith(z) {
|
||||
for _, r := range x.st.RRset(o.ID, name, typ) {
|
||||
if r.SharedID == 0 {
|
||||
if x.sharing == nil {
|
||||
x.sharing = map[rrKey]bool{}
|
||||
}
|
||||
x.sharing[rrKey{o.ID, name, typ}] = true
|
||||
ops = append(ops, newOp(OpRecordDelete, r.ID, r.Version, nil, fmt.Sprintf("linked: %s %s %s in %s is replaced by the shared version", r.Name, r.Type, r.Data, zoneDesc(x.st, o))))
|
||||
}
|
||||
}
|
||||
}
|
||||
return ops
|
||||
}
|
||||
@@ -0,0 +1,449 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"jarl/internal/auth"
|
||||
"jarl/internal/model"
|
||||
"jarl/internal/validate"
|
||||
)
|
||||
|
||||
// linkedEnv has views internal and external and zone example.com in
|
||||
// internal with a few records.
|
||||
func linkedEnv(t *testing.T) *testEnv {
|
||||
env := newEnv(t)
|
||||
env.submit(env.admin, NewRequest(OpSettingsUpdate, 0, SettingsInput{Nameservers: &[]string{"ns1.example.net."}}))
|
||||
env.submit(env.admin,
|
||||
NewRequest(OpViewCreate, 0, ViewInput{Name: model.Ptr("internal"), MatchClients: &[]string{"10.0.0.0/8"}}),
|
||||
NewRequest(OpViewCreate, 0, ViewInput{Name: model.Ptr("external"), MatchClients: &[]string{"any"}}))
|
||||
env.submit(env.admin, NewRequest(OpZoneCreate, 0, ZoneInput{Name: model.Ptr("example.com"), View: model.Ptr("internal")}))
|
||||
env.submit(env.admin,
|
||||
recIn("internal", "www", "A", "192.0.2.10"),
|
||||
recIn("internal", "mail", "A", "192.0.2.25"),
|
||||
recIn("internal", "@", "MX", "10 mail"))
|
||||
return env
|
||||
}
|
||||
|
||||
func recIn(view, name, typ, data string) Request {
|
||||
return NewRequest(OpRecordCreate, 0, RecordInput{Zone: "example.com", View: view, Name: &name, Type: &typ, Data: &data})
|
||||
}
|
||||
|
||||
// view returns a view's records of example.com as "name type data S|L",
|
||||
// S for shared, L for local.
|
||||
func (env *testEnv) view(name string) []string {
|
||||
env.t.Helper()
|
||||
s := env.state()
|
||||
v := s.ViewByName(name)
|
||||
z := s.FindZone("example.com.", v.ID)
|
||||
var out []string
|
||||
for _, r := range s.ZoneRecords(z.ID) {
|
||||
if r.Type == "NS" {
|
||||
continue
|
||||
}
|
||||
mark := "L"
|
||||
if r.SharedID != 0 {
|
||||
mark = "S"
|
||||
}
|
||||
if r.Disabled {
|
||||
mark += " disabled"
|
||||
}
|
||||
out = append(out, strings.TrimSuffix(r.Name, ".example.com.")+" "+r.Type+" "+r.Data+" "+mark)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (env *testEnv) recordID(view, name, typ string) int64 {
|
||||
env.t.Helper()
|
||||
s := env.state()
|
||||
z := s.FindZone("example.com.", s.ViewByName(view).ID)
|
||||
for _, r := range s.ZoneRecords(z.ID) {
|
||||
if r.Name == name+".example.com." && r.Type == typ {
|
||||
return r.ID
|
||||
}
|
||||
}
|
||||
env.t.Fatalf("no %s %s in %s", name, typ, view)
|
||||
return 0
|
||||
}
|
||||
|
||||
func want(t *testing.T, what string, got []string, w ...string) {
|
||||
t.Helper()
|
||||
if !slices.Equal(got, w) {
|
||||
t.Errorf("%s:\n got %q\n want %q", what, got, w)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLinkedZones(t *testing.T) {
|
||||
env := linkedEnv(t)
|
||||
// A zone created linked copies everything; both are linked and shared.
|
||||
env.submit(env.admin, NewRequest(OpZoneCreate, 0, ZoneInput{Name: model.Ptr("example.com"), View: model.Ptr("external"), LinkWith: "internal"}))
|
||||
all := []string{"mail A 192.0.2.25 S", "www A 192.0.2.10 S", "example.com. MX 10 mail.example.com. S"}
|
||||
slices.Sort(all)
|
||||
got := env.view("external")
|
||||
slices.Sort(got)
|
||||
want(t, "linked copy", got, all...)
|
||||
s := env.state()
|
||||
if zs := s.ZonesNamed("example.com."); zs[0].LinkGroup == 0 || zs[0].LinkGroup != zs[1].LinkGroup {
|
||||
t.Fatalf("not linked: %+v %+v", zs[0], zs[1])
|
||||
}
|
||||
|
||||
// Adding, changing, disabling and deleting a shared record reaches the other view.
|
||||
env.submit(env.admin, recIn("external", "ftp", "CNAME", "www"))
|
||||
if !slices.Contains(env.view("internal"), "ftp CNAME www.example.com. S") {
|
||||
t.Errorf("shared add didn't reach internal: %q", env.view("internal"))
|
||||
}
|
||||
env.submit(env.admin, NewRequest(OpRecordUpdate, env.recordID("internal", "mail", "A"), RecordInput{Data: model.Ptr("192.0.2.26")}))
|
||||
if !slices.Contains(env.view("external"), "mail A 192.0.2.26 S") {
|
||||
t.Errorf("shared change didn't reach external: %q", env.view("external"))
|
||||
}
|
||||
env.submit(env.admin, NewRequest(OpRecordDisable, 0, RecordInput{Zone: "example.com", View: "external", Name: model.Ptr("ftp"), Type: model.Ptr("CNAME")}))
|
||||
if !slices.Contains(env.view("internal"), "ftp CNAME www.example.com. S disabled") {
|
||||
t.Errorf("disable didn't reach internal: %q", env.view("internal"))
|
||||
}
|
||||
env.submit(env.admin, NewRequest(OpRecordDelete, 0, RecordInput{Zone: "example.com", View: "internal", Name: model.Ptr("ftp"), Type: model.Ptr("CNAME")}))
|
||||
for _, v := range []string{"internal", "external"} {
|
||||
for _, r := range env.view(v) {
|
||||
if strings.HasPrefix(r, "ftp ") {
|
||||
t.Errorf("delete by match left %s in %s", r, v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A local override: www in internal points elsewhere; external keeps
|
||||
// the shared one.
|
||||
res := env.submit(env.admin, NewRequest(OpRecordCreate, 0, RecordInput{Zone: "example.com", View: "internal", Name: model.Ptr("www"), Type: model.Ptr("A"), Data: model.Ptr("10.0.0.10"), Local: model.Ptr(true)}))
|
||||
if !slices.Contains(env.view("internal"), "www A 10.0.0.10 L") || slices.Contains(env.view("internal"), "www A 192.0.2.10 S") {
|
||||
t.Errorf("override: internal %q", env.view("internal"))
|
||||
}
|
||||
if !slices.Contains(env.view("external"), "www A 192.0.2.10 S") {
|
||||
t.Errorf("override changed external: %q", env.view("external"))
|
||||
}
|
||||
_ = res
|
||||
// A new record of an overridden set joins it as local.
|
||||
env.submit(env.admin, recIn("internal", "www", "A", "10.0.0.11"))
|
||||
if !slices.Contains(env.view("internal"), "www A 10.0.0.11 L") || len(env.view("external")) != 3 {
|
||||
t.Errorf("join override: internal %q external %q", env.view("internal"), env.view("external"))
|
||||
}
|
||||
// Changing the shared www in external doesn't touch internal's override.
|
||||
env.submit(env.admin, NewRequest(OpRRSetReplace, 0, RRSetInput{Zone: "example.com", View: "external", Name: "www", Type: "A", Data: []string{"192.0.2.11"}}))
|
||||
if !slices.Contains(env.view("external"), "www A 192.0.2.11 S") || slices.Contains(env.view("internal"), "www A 192.0.2.11 S") {
|
||||
t.Errorf("shared set with an override elsewhere: internal %q external %q", env.view("internal"), env.view("external"))
|
||||
}
|
||||
// Removing the override removes www A from internal only: external
|
||||
// keeps its record, local now. Sharing it brings it back to internal.
|
||||
env.submit(env.admin, NewRequest(OpRRSetReplace, 0, RRSetInput{Zone: "example.com", View: "internal", Name: "www", Type: "A", Data: []string{}}))
|
||||
if slices.ContainsFunc(env.view("internal"), func(s string) bool { return strings.HasPrefix(s, "www A ") }) || !slices.Contains(env.view("external"), "www A 192.0.2.11 L") {
|
||||
t.Errorf("override removed: internal %q external %q", env.view("internal"), env.view("external"))
|
||||
}
|
||||
env.submit(env.admin, NewRequest(OpRecordUpdate, env.recordID("external", "www", "A"), RecordInput{Local: model.Ptr(false)}))
|
||||
if !slices.Contains(env.view("internal"), "www A 192.0.2.11 S") || !slices.Contains(env.view("external"), "www A 192.0.2.11 S") {
|
||||
t.Errorf("shared again: internal %q external %q", env.view("internal"), env.view("external"))
|
||||
}
|
||||
|
||||
// Make a shared set local, then share another view's version.
|
||||
env.submit(env.admin, NewRequest(OpRecordUpdate, env.recordID("external", "mail", "A"), RecordInput{Local: model.Ptr(true)}))
|
||||
env.submit(env.admin, NewRequest(OpRecordUpdate, env.recordID("external", "mail", "A"), RecordInput{Data: model.Ptr("198.51.100.25")}))
|
||||
if !slices.Contains(env.view("external"), "mail A 198.51.100.25 L") || !slices.Contains(env.view("internal"), "mail A 192.0.2.26 S") {
|
||||
t.Errorf("local edit: internal %q external %q", env.view("internal"), env.view("external"))
|
||||
}
|
||||
env.submit(env.admin, NewRequest(OpRecordUpdate, env.recordID("external", "mail", "A"), RecordInput{Local: model.Ptr(false)}))
|
||||
if !slices.Contains(env.view("internal"), "mail A 198.51.100.25 S") || slices.Contains(env.view("internal"), "mail A 192.0.2.26 S") {
|
||||
t.Errorf("share external's version: internal %q", env.view("internal"))
|
||||
}
|
||||
if probs := env.e.Validate(env.state()); hasCode(probs, "linked_out_of_sync") || hasCode(probs, "linked_mixed") {
|
||||
t.Errorf("validation: %+v", probs)
|
||||
}
|
||||
|
||||
// Undoing a changeset undoes it in both views.
|
||||
r := env.submit(env.admin, NewRequest(OpRecordUpdate, env.recordID("internal", "mail", "A"), RecordInput{Data: model.Ptr("198.51.100.99")}))
|
||||
cs, err := env.e.Revert(env.ctx, r.ChangesetID, env.admin)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := env.e.Apply(env.ctx, cs.ID, env.admin, ApplyOptions{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, v := range []string{"internal", "external"} {
|
||||
if !slices.Contains(env.view(v), "mail A 198.51.100.25 S") {
|
||||
t.Errorf("revert in %s: %q", v, env.view(v))
|
||||
}
|
||||
}
|
||||
|
||||
// Unlinking keeps every record, all local.
|
||||
env.submit(env.admin, NewRequest(OpZoneUnlink, 0, LinkInput{Zone: "example.com", View: "external"}))
|
||||
for _, z := range env.state().ZonesNamed("example.com.") {
|
||||
if z.LinkGroup != 0 {
|
||||
t.Errorf("still linked: %s", zoneDesc(env.state(), z))
|
||||
}
|
||||
}
|
||||
for _, v := range []string{"internal", "external"} {
|
||||
for _, r := range env.view(v) {
|
||||
if strings.HasSuffix(r, " S") {
|
||||
t.Errorf("still shared after unlink in %s: %s", v, r)
|
||||
}
|
||||
}
|
||||
}
|
||||
env.submit(env.admin, recIn("internal", "solo", "A", "10.0.0.5"))
|
||||
if slices.Contains(env.view("external"), "solo A 10.0.0.5 L") {
|
||||
t.Error("an unlinked zone was changed")
|
||||
}
|
||||
|
||||
// Linking again works out what is shared: same in both, or not.
|
||||
env.submit(env.admin, NewRequest(OpZoneLink, 0, LinkInput{Zone: "example.com", View: "internal"}))
|
||||
in, ex := env.view("internal"), env.view("external")
|
||||
if !slices.Contains(in, "mail A 198.51.100.25 S") || !slices.Contains(ex, "mail A 198.51.100.25 S") {
|
||||
t.Errorf("relink: same records not shared: internal %q external %q", in, ex)
|
||||
}
|
||||
if !slices.Contains(in, "solo A 10.0.0.5 L") {
|
||||
t.Errorf("relink: internal-only record should be local: %q", in)
|
||||
}
|
||||
if probs := env.e.Validate(env.state()); hasCode(probs, "linked_out_of_sync") || hasCode(probs, "linked_mixed") {
|
||||
t.Errorf("validation after relink: %+v", probs)
|
||||
}
|
||||
|
||||
// Deleting one zone of two dissolves the link.
|
||||
z := env.state().FindZone("example.com.", env.state().ViewByName("external").ID)
|
||||
env.submit(env.admin, NewRequest(OpZoneDelete, z.ID, nil))
|
||||
if zs := env.state().ZonesNamed("example.com."); len(zs) != 1 || zs[0].LinkGroup != 0 {
|
||||
t.Errorf("after deleting external: %+v", zs)
|
||||
}
|
||||
for _, r := range env.view("internal") {
|
||||
if strings.HasSuffix(r, " S") {
|
||||
t.Errorf("shared record left in an unlinked zone: %s", r)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func hasCode(probs []validate.Problem, code string) bool {
|
||||
for _, p := range probs {
|
||||
if p.Code == code {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func TestLinkedPermissions(t *testing.T) {
|
||||
env := linkedEnv(t)
|
||||
env.submit(env.admin, NewRequest(OpZoneCreate, 0, ZoneInput{Name: model.Ptr("example.com"), View: model.Ptr("external"), LinkWith: "internal"}))
|
||||
u := &model.User{Username: "lan", Grants: []model.Grant{{Resource: auth.ResRecords, Access: "write", Views: []string{"internal"}}, {Resource: auth.ResZones, Access: "read"}}}
|
||||
if err := env.st.CreateUser(env.ctx, u); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
lan := &auth.Principal{User: u, Method: "token"}
|
||||
// A shared change would reach external, which this user may not change.
|
||||
e := env.submitErr(lan, NewRequest(OpRecordUpdate, env.recordID("internal", "www", "A"), RecordInput{Data: model.Ptr("10.9.9.9")}))
|
||||
if e.Kind != KindForbidden || !strings.Contains(e.Message, "shared with example.com [external]") || !strings.Contains(e.Message, "local") {
|
||||
t.Errorf("shared change: %v", e)
|
||||
}
|
||||
// A local override is theirs to make.
|
||||
env.submit(lan, NewRequest(OpRecordCreate, 0, RecordInput{Zone: "example.com", View: "internal", Name: model.Ptr("www"), Type: model.Ptr("A"), Data: model.Ptr("10.9.9.9"), Local: model.Ptr(true)}))
|
||||
if !slices.Contains(env.view("internal"), "www A 10.9.9.9 L") || !slices.Contains(env.view("external"), "www A 192.0.2.10 S") {
|
||||
t.Errorf("override by a view-restricted user: internal %q external %q", env.view("internal"), env.view("external"))
|
||||
}
|
||||
// Records of a zone that isn't linked can't be "local".
|
||||
e = env.submitErr(env.admin, NewRequest(OpZoneCreate, 0, ZoneInput{Name: model.Ptr("example.org"), View: model.Ptr("internal"), LinkWith: "external"}))
|
||||
if e.Kind != KindNotFound {
|
||||
t.Errorf("link_with a view without the zone: %v", e)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLinkExisting(t *testing.T) {
|
||||
env := linkedEnv(t)
|
||||
// external was made by hand: the same apart from www.
|
||||
env.submit(env.admin, NewRequest(OpZoneCreate, 0, ZoneInput{Name: model.Ptr("example.com"), View: model.Ptr("external")}))
|
||||
env.submit(env.admin,
|
||||
recIn("external", "www", "A", "203.0.113.10"),
|
||||
recIn("external", "mail", "A", "192.0.2.25"),
|
||||
recIn("external", "@", "MX", "10 mail"))
|
||||
before := []string{}
|
||||
for _, v := range []string{"internal", "external"} {
|
||||
before = append(before, strings.Join(env.view(v), ";"))
|
||||
}
|
||||
res := env.submit(env.admin, NewRequest(OpZoneLink, 0, LinkInput{Zone: "example.com", View: "external", With: []string{"internal"}}))
|
||||
in, ex := env.view("internal"), env.view("external")
|
||||
if !slices.Contains(in, "www A 192.0.2.10 L") || !slices.Contains(ex, "www A 203.0.113.10 L") {
|
||||
t.Errorf("different www should stay local: internal %q external %q", in, ex)
|
||||
}
|
||||
if !slices.Contains(in, "mail A 192.0.2.25 S") || !slices.Contains(ex, "mail A 192.0.2.25 S") {
|
||||
t.Errorf("same mail should be shared: internal %q external %q", in, ex)
|
||||
}
|
||||
if !strings.Contains(strings.Join(res.Warnings, " "), "www.example.com A (internal, external)") {
|
||||
t.Errorf("warnings: %q", res.Warnings)
|
||||
}
|
||||
// Linking never changes what named answers: the same data as before.
|
||||
for i, v := range []string{"internal", "external"} {
|
||||
var data []string
|
||||
for _, r := range env.view(v) {
|
||||
data = append(data, strings.TrimSuffix(strings.TrimSuffix(r, " S"), " L"))
|
||||
}
|
||||
var was []string
|
||||
for _, r := range strings.Split(before[i], ";") {
|
||||
was = append(was, strings.TrimSuffix(strings.TrimSuffix(r, " S"), " L"))
|
||||
}
|
||||
want(t, "answers in "+v, data, was...)
|
||||
}
|
||||
// Sharing internal's www replaces external's.
|
||||
env.submit(env.admin, NewRequest(OpRecordUpdate, env.recordID("internal", "www", "A"), RecordInput{Local: model.Ptr(false)}))
|
||||
if !slices.Contains(env.view("external"), "www A 192.0.2.10 S") {
|
||||
t.Errorf("shared from internal: external %q", env.view("external"))
|
||||
}
|
||||
}
|
||||
|
||||
// --local on a zone that isn't linked is fine (its records are local
|
||||
// anyway); --shared there is refused.
|
||||
func TestLocalOnUnlinkedZone(t *testing.T) {
|
||||
env := linkedEnv(t)
|
||||
local, shared := true, false
|
||||
env.submit(env.admin, NewRequest(OpRecordCreate, 0, RecordInput{Zone: "example.com", View: "internal", Name: model.Ptr("a"), Type: model.Ptr("A"), Data: model.Ptr("10.0.0.1"), Local: &local}))
|
||||
env.submit(env.admin, NewRequest(OpRRSetReplace, 0, RRSetInput{Zone: "example.com", View: "internal", Name: "b", Type: "A", Data: []string{"10.0.0.2"}, Local: &local}))
|
||||
env.submit(env.admin, NewRequest(OpRecordUpdate, env.recordID("internal", "a", "A"), RecordInput{Data: model.Ptr("10.0.0.3"), Local: &local}))
|
||||
if v := env.view("internal"); !slices.Contains(v, "a A 10.0.0.3 L") || !slices.Contains(v, "b A 10.0.0.2 L") {
|
||||
t.Errorf("records: %q", v)
|
||||
}
|
||||
if e := env.submitErr(env.admin, NewRequest(OpRecordCreate, 0, RecordInput{Zone: "example.com", View: "internal", Name: model.Ptr("c"), Type: model.Ptr("A"), Data: model.Ptr("10.0.0.4"), Local: &shared})); !strings.Contains(e.Message, "nothing to share") {
|
||||
t.Errorf("--shared on an unlinked zone: %v", e)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompareZones(t *testing.T) {
|
||||
env := linkedEnv(t)
|
||||
env.submit(env.admin, NewRequest(OpZoneCreate, 0, ZoneInput{Name: model.Ptr("example.com"), View: model.Ptr("external"), LinkWith: "internal"}))
|
||||
env.submit(env.admin, NewRequest(OpRRSetReplace, 0, RRSetInput{Zone: "example.com", View: "external", Name: "www", Type: "A", Data: []string{"203.0.113.10"}, Local: model.Ptr(true)}))
|
||||
env.submit(env.admin, NewRequest(OpRecordCreate, 0, RecordInput{Zone: "example.com", View: "internal", Name: model.Ptr("nas"), Type: model.Ptr("A"), Data: model.Ptr("10.0.0.20"), Local: model.Ptr(true)}))
|
||||
st := env.state()
|
||||
zones := st.ZonesNamed("example.com.")
|
||||
st.SortByView(zones)
|
||||
diff := map[string][]string{}
|
||||
same := 0
|
||||
for _, row := range CompareZones(st, zones) {
|
||||
if row.Same {
|
||||
same++
|
||||
continue
|
||||
}
|
||||
var cells []string
|
||||
for _, vs := range row.Views {
|
||||
cells = append(cells, vs.View+"="+strings.Join(vs.Records, ",")+"/"+vs.Linked)
|
||||
}
|
||||
diff[row.Name+" "+row.Type] = cells
|
||||
}
|
||||
want := map[string][]string{
|
||||
"www A": {"internal=192.0.2.10/shared", "external=203.0.113.10/local"},
|
||||
"nas A": {"internal=10.0.0.20/local", "external=/"},
|
||||
}
|
||||
for k, v := range want {
|
||||
if !slices.Equal(diff[k], v) {
|
||||
t.Errorf("%s: %q, want %q", k, diff[k], v)
|
||||
}
|
||||
}
|
||||
if len(diff) != 2 || same < 3 {
|
||||
t.Errorf("differ %v, same %d", diff, same)
|
||||
}
|
||||
// "Use this version" on the shared one replaces external's override.
|
||||
env.submit(env.admin, NewRequest(OpRecordUpdate, env.recordID("internal", "www", "A"), RecordInput{Local: model.Ptr(false)}))
|
||||
if v := env.view("external"); !slices.Contains(v, "www A 192.0.2.10 S") || slices.Contains(v, "www A 203.0.113.10 L") {
|
||||
t.Errorf("sharing the shared version: external %q", v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompareReport(t *testing.T) {
|
||||
env := linkedEnv(t)
|
||||
env.submit(env.admin, NewRequest(OpZoneCreate, 0, ZoneInput{Name: model.Ptr("example.com"), View: model.Ptr("external"), LinkWith: "internal"}))
|
||||
env.submit(env.admin,
|
||||
NewRequest(OpRRSetReplace, 0, RRSetInput{Zone: "example.com", View: "external", Name: "www", Type: "A", Data: []string{"203.0.113.10"}, Local: model.Ptr(true)}),
|
||||
NewRequest(OpRecordCreate, 0, RecordInput{Zone: "example.com", View: "internal", Name: model.Ptr("nas"), Type: model.Ptr("A"), Data: model.Ptr("10.0.0.20"), Local: model.Ptr(true)}),
|
||||
NewRequest(OpRecordCreate, 0, RecordInput{Zone: "example.com", View: "internal", Name: model.Ptr("printer"), Type: model.Ptr("A"), Data: model.Ptr("10.0.0.30"), Local: model.Ptr(true)}),
|
||||
NewRequest(OpRecordCreate, 0, RecordInput{Zone: "example.com", View: "external", Name: model.Ptr("shop"), Type: model.Ptr("CNAME"), Data: model.Ptr("shops.example.net."), Local: model.Ptr(true)}))
|
||||
st := env.state()
|
||||
zones := []*model.Zone{st.FindZone("example.com.", st.ViewByName("internal").ID), st.FindZone("example.com.", st.ViewByName("external").ID)}
|
||||
|
||||
rep := Compare(st, zones, CompareFilter{})
|
||||
if rep.Base != "internal" || !rep.Linked || !slices.Equal(rep.Views, []string{"internal", "external"}) {
|
||||
t.Errorf("report %+v", rep)
|
||||
}
|
||||
if s := rep.Summary[0]; s.View != "external" || s.Different != 1 || s.OnlyBase != 2 || s.OnlyOther != 1 || s.Same != 2 {
|
||||
t.Errorf("summary %+v", s)
|
||||
}
|
||||
if len(rep.Different) != 1 || rep.Different[0].Name != "www" {
|
||||
t.Errorf("different %+v", rep.Different)
|
||||
}
|
||||
var only []string
|
||||
for _, o := range rep.Only {
|
||||
var names []string
|
||||
for _, r := range o.Rows {
|
||||
names = append(names, r.Name)
|
||||
}
|
||||
only = append(only, strings.Join(o.Views, "+")+": "+strings.Join(names, ","))
|
||||
}
|
||||
want(t, "only", only, "internal: nas,printer", "external: shop")
|
||||
if rep.Same != 2 || rep.SameRows != nil {
|
||||
t.Errorf("same %d, rows %v", rep.Same, rep.SameRows)
|
||||
}
|
||||
|
||||
// One name: just its rows, the same ones included.
|
||||
rep = Compare(st, zones, CompareFilter{Name: "www"})
|
||||
if len(rep.Different) != 1 || len(rep.Only) != 0 || len(rep.SameRows) != 0 {
|
||||
t.Errorf("www: %+v", rep)
|
||||
}
|
||||
rep = Compare(st, zones, CompareFilter{Name: "mail.example.com.", Type: "A"})
|
||||
if len(rep.SameRows) != 1 || rep.SameRows[0].Name != "mail" || len(rep.Different)+len(rep.Only) != 0 {
|
||||
t.Errorf("mail A: %+v", rep)
|
||||
}
|
||||
}
|
||||
|
||||
// A shared record made local in one view, then deleted there, is gone from
|
||||
// that view only; the other view keeps it, local now.
|
||||
func TestDeleteLocalOverride(t *testing.T) {
|
||||
env := linkedEnv(t)
|
||||
env.submit(env.admin, NewRequest(OpZoneCreate, 0, ZoneInput{Name: model.Ptr("example.com"), View: model.Ptr("external"), LinkWith: "internal"}))
|
||||
env.submit(env.admin, recIn("internal", "a", "A", "192.0.2.50"))
|
||||
if !slices.Contains(env.view("external"), "a A 192.0.2.50 S") {
|
||||
t.Fatalf("not shared: %q", env.view("external"))
|
||||
}
|
||||
env.submit(env.admin, NewRequest(OpRecordUpdate, env.recordID("internal", "a", "A"), RecordInput{Local: model.Ptr(true)}))
|
||||
env.submit(env.admin, NewRequest(OpRecordDelete, env.recordID("internal", "a", "A"), nil))
|
||||
if slices.ContainsFunc(env.view("internal"), func(s string) bool { return strings.HasPrefix(s, "a A ") }) {
|
||||
t.Errorf("internal still has a: %q", env.view("internal"))
|
||||
}
|
||||
if !slices.Contains(env.view("external"), "a A 192.0.2.50 L") {
|
||||
t.Errorf("external: %q", env.view("external"))
|
||||
}
|
||||
if probs := env.e.Validate(env.state()); hasCode(probs, "linked_out_of_sync") || hasCode(probs, "linked_mixed") {
|
||||
t.Errorf("validation: %+v", probs)
|
||||
}
|
||||
}
|
||||
|
||||
// An ACME token for one view of a linked zone: its challenge records are
|
||||
// local to that view, the zone is found without naming the view, and they
|
||||
// go again without touching the other view.
|
||||
func TestLinkedACMEToken(t *testing.T) {
|
||||
env := linkedEnv(t)
|
||||
env.submit(env.admin, NewRequest(OpZoneCreate, 0, ZoneInput{Name: model.Ptr("example.com"), View: model.Ptr("external"), LinkWith: "internal"}))
|
||||
u := &model.User{Username: "certbot", Grants: []model.Grant{{Resource: auth.ResRecords, Access: "write", Zones: []string{"example.com"}, Views: []string{"external"}, Names: []string{"_acme-challenge.*"}, Types: []string{"TXT"}}}}
|
||||
if err := env.st.CreateUser(env.ctx, u); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
bot := &auth.Principal{User: u, Method: "token"}
|
||||
name, typ, data := "_acme-challenge", "TXT", "token-value"
|
||||
res := env.submit(bot, NewRequest(OpRecordCreate, 0, RecordInput{Zone: "example.com", Name: &name, Type: &typ, Data: &data}))
|
||||
if !slices.Contains(env.view("external"), "_acme-challenge TXT \"token-value\" L") || slices.ContainsFunc(env.view("internal"), func(s string) bool { return strings.HasPrefix(s, "_acme-challenge") }) {
|
||||
t.Errorf("challenge: internal %q external %q", env.view("internal"), env.view("external"))
|
||||
}
|
||||
if !slices.ContainsFunc(res.Warnings, func(w string) bool { return strings.Contains(w, "local to view external") }) {
|
||||
t.Errorf("warnings %q", res.Warnings)
|
||||
}
|
||||
env.submit(bot, NewRequest(OpRecordDelete, 0, RecordInput{Zone: "example.com", Name: &name, Type: &typ, Data: &data}))
|
||||
if slices.ContainsFunc(env.view("external"), func(s string) bool { return strings.HasPrefix(s, "_acme-challenge") }) {
|
||||
t.Errorf("not deleted: %q", env.view("external"))
|
||||
}
|
||||
// The admin, who may change both, still gets a shared record.
|
||||
env.submit(env.admin, NewRequest(OpRecordCreate, 0, RecordInput{Zone: "example.com", View: "internal", Name: model.Ptr("both"), Type: model.Ptr("A"), Data: model.Ptr("192.0.2.7")}))
|
||||
if !slices.Contains(env.view("external"), "both A 192.0.2.7 S") {
|
||||
t.Errorf("admin: %q", env.view("external"))
|
||||
}
|
||||
// Without a view, the admin is still asked which.
|
||||
if e := env.submitErr(env.admin, NewRequest(OpRecordCreate, 0, RecordInput{Zone: "example.com", Name: model.Ptr("x"), Type: model.Ptr("A"), Data: model.Ptr("192.0.2.8")})); !strings.Contains(e.Message, "several views (internal, external)") {
|
||||
t.Errorf("admin without a view: %v", e)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"jarl/internal/model"
|
||||
"jarl/internal/render"
|
||||
"jarl/internal/validate"
|
||||
)
|
||||
|
||||
// namedCheckTTL is how long the result of checking named's running
|
||||
// configuration is kept when nothing changed.
|
||||
const namedCheckTTL = 10 * time.Minute
|
||||
|
||||
// namedCheck caches the warnings of named-checkconf on the configuration
|
||||
// named runs, keyed by what it depends on.
|
||||
type namedCheck struct {
|
||||
mu sync.Mutex
|
||||
key string
|
||||
at time.Time
|
||||
problems []validate.Problem
|
||||
}
|
||||
|
||||
// namedProblems runs named-checkconf on the configuration named runs
|
||||
// (without loading zones, which is quick) and returns what it reports as
|
||||
// problems. An apply shows these too, but they are named's own checks,
|
||||
// which Jarl doesn't repeat ("both recursion no and allow-recursion
|
||||
// active"): the dashboard lists them with Jarl's so they don't go unseen
|
||||
// after the apply. The result is reused while the deployed files and
|
||||
// named's main file are unchanged.
|
||||
func (e *Engine) namedProblems(ctx context.Context, st *model.State, deployed map[string]*model.DeployedFile) []validate.Problem {
|
||||
if e.tools == nil || e.cfg.SkipCheck || e.cfg.MainConfig == "" || len(deployed) == 0 {
|
||||
return nil
|
||||
}
|
||||
key := ""
|
||||
if fi, err := os.Stat(e.cfg.MainConfig); err == nil {
|
||||
key = fi.ModTime().String()
|
||||
}
|
||||
if f := deployed[e.cfg.Paths.MainConfig()]; f != nil {
|
||||
key += "|" + f.SHA256
|
||||
}
|
||||
c := &e.namedCheck
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if c.key == key && e.Now().Sub(c.at) < namedCheckTTL {
|
||||
return c.problems
|
||||
}
|
||||
res, err := e.tools.CheckConfig(ctx, e.cfg.MainConfig, false)
|
||||
if err != nil {
|
||||
return nil // named-checkconf couldn't run; an apply reports that
|
||||
}
|
||||
out := render.Render(st, e.cfg.Paths, e.extras(ctx))
|
||||
var ps []validate.Problem
|
||||
for _, m := range res.Messages {
|
||||
p := validate.Problem{Severity: validate.Warning, Code: "named_warning", View: m.View, Zone: m.Zone}
|
||||
if m.Error {
|
||||
p.Severity, p.Code = validate.Error, "named_error"
|
||||
}
|
||||
where := ""
|
||||
if m.File != "" {
|
||||
where = fmt.Sprintf(" (%s:%d)", m.File, m.Line)
|
||||
}
|
||||
if span, ok := out.Lookup(m.File, m.Line); ok {
|
||||
p.ObjectType, p.ObjectID = span.ObjectType, span.ObjectID
|
||||
where = " (" + span.Label + ", " + where[2:]
|
||||
}
|
||||
p.Message = "named-checkconf: " + strings.TrimSpace(m.Text) + where
|
||||
ps = append(ps, p)
|
||||
}
|
||||
c.key, c.at, c.problems = key, e.Now(), ps
|
||||
return ps
|
||||
}
|
||||
Loaded 100 of 228 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user