Compare commits
55
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
No files matched your search
@@ -3,3 +3,5 @@
|
|||||||
*.db
|
*.db
|
||||||
*.db-wal
|
*.db-wal
|
||||||
*.db-shm
|
*.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.
|
||||||
+379
@@ -6,8 +6,352 @@ All notable changes to Jarl are documented here. The format follows
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### 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
|
||||||
|
|
||||||
|
- 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
|
### 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
|
- New exposure warnings (docs/exposure.md) appear for views reachable from
|
||||||
the internet, e.g. "no rate-limit" or "version visible" on an external
|
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
|
view: fix them from the Exposure page, or acknowledge them. A change
|
||||||
@@ -18,8 +362,25 @@ All notable changes to Jarl are documented here. The format follows
|
|||||||
parser doesn't know them. Remove the ranges first if you need to
|
parser doesn't know them. Remove the ranges first if you need to
|
||||||
downgrade.
|
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
|
### 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
|
- 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
|
line wrapped back to the left edge, losing its indentation, and the
|
||||||
column changed width when Edit opened. Values now keep their layout
|
column changed width when Edit opened. Values now keep their layout
|
||||||
@@ -40,6 +401,24 @@ All notable changes to Jarl are documented here. The format follows
|
|||||||
|
|
||||||
### Added
|
### 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
|
- 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
|
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
|
records in step: a change in one view reaches the others in the same
|
||||||
|
|||||||
@@ -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:
|
uninstall:
|
||||||
rm -f $(DESTDIR)$(BINDIR)/jarld $(DESTDIR)$(BINDIR)/jarl $(DESTDIR)$(UNITDIR)/jarl.service \
|
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)$(UNITDIR)/jarl-backup.service $(DESTDIR)$(UNITDIR)/jarl-backup.timer \
|
||||||
$(DESTDIR)$(BASHCOMPDIR)/jarl $(DESTDIR)$(BASHCOMPDIR)/jarld \
|
$(DESTDIR)$(BASHCOMPDIR)/jarl $(DESTDIR)$(BASHCOMPDIR)/jarld \
|
||||||
$(DESTDIR)$(SYSUSERSDIR)/jarl.conf $(DESTDIR)$(TMPFILESDIR)/jarl.conf
|
$(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
|
## Features
|
||||||
|
|
||||||
* **Everything in one place**: primary, secondary, forward, stub, mirror,
|
* **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
|
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.
|
and top level, with the ARM's description and grammar built in.
|
||||||
* **Safe changes.** Every change is validated as a whole (CNAME rules,
|
* **Safe changes.** Every change is validated as a whole (CNAME rules,
|
||||||
@@ -97,6 +98,9 @@ error: www.adbraun.lol has a CNAME, so it can't have any other records (found A)
|
|||||||
serves the domain, decided at every renewal. Use them for the web UI
|
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
|
(HTTPS without a restart) and for named's DNS over TLS and HTTPS (named
|
||||||
rereads them on renewal), or bring your own.
|
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
|
* **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
|
shared records kept in step by Jarl and a few records of each view's own
|
||||||
(an internal address for a public name, say).
|
(an internal address for a public name, say).
|
||||||
@@ -141,8 +145,10 @@ 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/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/dnssec.md](docs/dnssec.md) | DNSSEC signing, going public with your own name servers, Porkbun |
|
||||||
| [docs/acme.md](docs/acme.md) | Let's Encrypt DNS-01 with certbot, acme.sh, lego and Traefik |
|
| [docs/acme.md](docs/acme.md) | Let's Encrypt DNS-01 with certbot, acme.sh, lego and Traefik |
|
||||||
|
| [docs/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/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/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/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/metrics.md](docs/metrics.md) | Prometheus metrics: access, what is exported, example alerts |
|
||||||
| [docs/blocking.md](docs/blocking.md) | Blocking ads, trackers and malware: blocklists, your own rules, views, memory |
|
| [docs/blocking.md](docs/blocking.md) | Blocking ads, trackers and malware: blocklists, your own rules, views, memory |
|
||||||
@@ -196,11 +202,23 @@ review step.
|
|||||||
|
|
||||||
## Status
|
## Status
|
||||||
|
|
||||||
Version 0.4.0 is the current release, and 0.4.1 is in testing
|
Version 0.6.0 is the current release, and 0.6.1 is in testing
|
||||||
(0.4.1-rc.3): it adds Prometheus metrics, address ranges in ACLs and
|
(0.6.1-rc.4): it gets certificates onto your other hosts (`jarl cert
|
||||||
other address match lists, safer changeset handling, internet exposure
|
get`: Jarl obtains and renews them, a host downloads them and runs a
|
||||||
checks with response rate limiting, TLS certificates Jarl obtains and
|
hook, a daily timer keeps them current), exports certificates, fixes
|
||||||
renews itself, and linked zones for split DNS. 0.4.0 added blocking (blocklists and
|
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
|
your own rules, like Pi-hole, done by named, with different blocking for
|
||||||
different devices), disabling zones, server statistics and view keys, on
|
different devices), disabling zones, server statistics and view keys, on
|
||||||
top of 0.3.0's disabled records, script-friendly CLI output and client
|
top of 0.3.0's disabled records, script-friendly CLI output and client
|
||||||
|
|||||||
@@ -378,6 +378,9 @@ func printUser(w io.Writer, u api.User) {
|
|||||||
if u.Comment != "" {
|
if u.Comment != "" {
|
||||||
fmt.Fprintf(w, "Comment:\t%s\n", 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))
|
fmt.Fprintf(w, "Last login:\t%s\n", fmtTime(u.LastLoginAt))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -444,6 +447,7 @@ func userCmd() *cobra.Command {
|
|||||||
}
|
}
|
||||||
var admin, noPassword, disabled bool
|
var admin, noPassword, disabled bool
|
||||||
var role, comment, rename string
|
var role, comment, rename string
|
||||||
|
var certLimit int
|
||||||
var grants []string
|
var grants []string
|
||||||
userBody := func(cmd *cobra.Command) (api.UserInput, error) {
|
userBody := func(cmd *cobra.Command) (api.UserInput, error) {
|
||||||
var in api.UserInput
|
var in api.UserInput
|
||||||
@@ -466,6 +470,9 @@ func userCmd() *cobra.Command {
|
|||||||
if f.Changed("rename") {
|
if f.Changed("rename") {
|
||||||
in.Username = &rename
|
in.Username = &rename
|
||||||
}
|
}
|
||||||
|
if f.Changed("cert-limit") {
|
||||||
|
in.CertLimit = &certLimit
|
||||||
|
}
|
||||||
return in, nil
|
return in, nil
|
||||||
}
|
}
|
||||||
addUserFlags := func(c *cobra.Command) {
|
addUserFlags := func(c *cobra.Command) {
|
||||||
@@ -473,6 +480,7 @@ func userCmd() *cobra.Command {
|
|||||||
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 or acme")
|
||||||
c.Flags().StringArrayVar(&grants, "grant", nil, `grant (repeatable), e.g. "records:write zones=example.com"`)
|
c.Flags().StringArrayVar(&grants, "grant", nil, `grant (repeatable), e.g. "records:write zones=example.com"`)
|
||||||
c.Flags().StringVar(&comment, "comment", "", "comment")
|
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{
|
create := &cobra.Command{
|
||||||
Use: "create NAME",
|
Use: "create NAME",
|
||||||
|
|||||||
@@ -0,0 +1,718 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 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.
|
||||||
|
|
||||||
|
--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
|
||||||
|
}
|
||||||
|
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, 6 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.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
|
||||||
|
}
|
||||||
+113
-2
@@ -45,10 +45,18 @@ type certInfo struct {
|
|||||||
NextAttempt *time.Time `json:"next_attempt,omitempty"`
|
NextAttempt *time.Time `json:"next_attempt,omitempty"`
|
||||||
IssuingSince *time.Time `json:"issuing_since,omitempty"`
|
IssuingSince *time.Time `json:"issuing_since,omitempty"`
|
||||||
Serving bool `json:"serving,omitempty"`
|
Serving bool `json:"serving,omitempty"`
|
||||||
|
NamedUse []string `json:"named_use,omitempty"`
|
||||||
Files *struct {
|
Files *struct {
|
||||||
Chain string `json:"chain"`
|
Chain string `json:"chain"`
|
||||||
Key string `json:"key"`
|
Key string `json:"key"`
|
||||||
} `json:"files,omitempty"`
|
} `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 {
|
type certAccount struct {
|
||||||
@@ -98,9 +106,10 @@ used too (--cert-file and --key-file): Jarl rereads it when it changes.`,
|
|||||||
jarl cert create own --cert-file /etc/ssl/ns1/fullchain.pem --key-file /etc/ssl/ns1/privkey.pem --use web
|
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 list
|
||||||
jarl cert issue ns1 # renew now
|
jarl cert issue ns1 # renew now
|
||||||
jarl cert update ns1 --challenge porkbun # pin the route (auto, jarl or porkbun)`,
|
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(), certDeleteCmd(), certAccountCmd())
|
cmd.AddCommand(certListCmd(), certShowCmd(), certCreateCmd(), certUpdateCmd(), certIssueCmd(), certCheckCmd(), certExportCmd(), certGetCmd(), certSavedCmd(), certForgetCmd(), certScheduleCmd(), certDeleteCmd(), certAccountCmd())
|
||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -173,9 +182,26 @@ func printCert(w io.Writer, ci certInfo) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
fmt.Fprintf(w, "Uses:\t%s\n", dash(strings.Join(uses, ", ")))
|
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 != "" {
|
if ci.Comment != "" {
|
||||||
fmt.Fprintf(w, "Comment:\t%s\n", 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
|
state := ci.Status.State
|
||||||
if ci.Status.Message != "" {
|
if ci.Status.Message != "" {
|
||||||
state += ": " + ci.Status.Message
|
state += ": " + ci.Status.Message
|
||||||
@@ -489,6 +515,91 @@ is published and the CA isn't asked.`,
|
|||||||
return cmd
|
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 {
|
func certDeleteCmd() *cobra.Command {
|
||||||
var yes bool
|
var yes bool
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
|
|||||||
+2
-2
@@ -272,9 +272,9 @@ func changesetCmd() *cobra.Command {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return output(cs, func(w io.Writer) {
|
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 {
|
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")
|
fmt.Fprintln(w, "\n#\tOPERATION")
|
||||||
for _, op := range cs.Ops {
|
for _, op := range cs.Ops {
|
||||||
|
|||||||
@@ -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())
|
||||||
|
}
|
||||||
|
}
|
||||||
+91
-8
@@ -30,14 +30,19 @@ func viewCmd() *cobra.Command {
|
|||||||
return output(views, func(w io.Writer) {
|
return output(views, func(w io.Writer) {
|
||||||
fmt.Fprintln(w, "#\tID\tVIEW\tMATCH-CLIENTS\tMATCH-DESTINATIONS\tZONES\tCOLOR")
|
fmt.Fprintln(w, "#\tID\tVIEW\tMATCH-CLIENTS\tMATCH-DESTINATIONS\tZONES\tCOLOR")
|
||||||
for i, v := range views {
|
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 clients, dests []string
|
||||||
var recursiveOnly, comment, exposure string
|
var recursiveOnly, comment, exposure string
|
||||||
var adopt, cascade, yes bool
|
var adopt, cascade, yes, reverseSuggest bool
|
||||||
viewBody := func(cmd *cobra.Command) map[string]any {
|
viewBody := func(cmd *cobra.Command) map[string]any {
|
||||||
body := map[string]any{}
|
body := map[string]any{}
|
||||||
f := cmd.Flags()
|
f := cmd.Flags()
|
||||||
@@ -63,6 +68,9 @@ func viewCmd() *cobra.Command {
|
|||||||
if f.Changed("exposure") {
|
if f.Changed("exposure") {
|
||||||
body["exposure"] = exposure
|
body["exposure"] = exposure
|
||||||
}
|
}
|
||||||
|
if f.Changed("reverse-suggestions") {
|
||||||
|
body["reverse_suggestions"] = reverseSuggest
|
||||||
|
}
|
||||||
return body
|
return body
|
||||||
}
|
}
|
||||||
addViewFlags := func(c *cobra.Command) {
|
addViewFlags := func(c *cobra.Command) {
|
||||||
@@ -71,6 +79,7 @@ func viewCmd() *cobra.Command {
|
|||||||
c.Flags().StringVar(&recursiveOnly, "recursive-only", "", "match-recursive-only: yes, no or default")
|
c.Flags().StringVar(&recursiveOnly, "recursive-only", "", "match-recursive-only: yes, no or default")
|
||||||
c.Flags().StringVar(&comment, "comment", "", "comment")
|
c.Flags().StringVar(&comment, "comment", "", "comment")
|
||||||
c.Flags().StringVar(&exposure, "exposure", "", "reachable from the internet: auto (worked out), public or private (docs/exposure.md)")
|
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{
|
create := &cobra.Command{
|
||||||
Use: "create NAME",
|
Use: "create NAME",
|
||||||
@@ -582,6 +591,12 @@ func optionCmd() *cobra.Command {
|
|||||||
Short: "Set an option (replaces an existing option of the same name)",
|
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.
|
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 recursion no
|
||||||
jarl option set allow-recursion '{ homelab; localhost; }'
|
jarl option set allow-recursion '{ homelab; localhost; }'
|
||||||
jarl option set forwarders '{ 1.1.1.1; 9.9.9.9; }'
|
jarl option set forwarders '{ 1.1.1.1; 9.9.9.9; }'
|
||||||
@@ -606,7 +621,11 @@ func optionCmd() *cobra.Command {
|
|||||||
if cmd.Flags().Changed("comment") {
|
if cmd.Flags().Changed("comment") {
|
||||||
body["comment"] = comment
|
body["comment"] = comment
|
||||||
}
|
}
|
||||||
|
label := optionLabel(strings.ToLower(args[0]), value)
|
||||||
for _, o := range opts {
|
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 {
|
if o.Name == strings.ToLower(args[0]) && !multiple {
|
||||||
resp, err := c.Mutate(ctx(), "PATCH", fmt.Sprintf("/options/%d", o.ID), nil, body)
|
resp, err := c.Mutate(ctx(), "PATCH", fmt.Sprintf("/options/%d", o.ID), nil, body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -627,7 +646,7 @@ func optionCmd() *cobra.Command {
|
|||||||
set.Flags().StringVar(&comment, "comment", "", "comment")
|
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)")
|
set.Flags().BoolVar(&multiple, "add", false, "add another instance instead of replacing (for options that may occur several times)")
|
||||||
unset := &cobra.Command{
|
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)",
|
Short: "Remove an option (BIND's default applies again)",
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
c, err := newClient()
|
c, err := newClient()
|
||||||
@@ -641,8 +660,8 @@ func optionCmd() *cobra.Command {
|
|||||||
}
|
}
|
||||||
return report(resp)
|
return report(resp)
|
||||||
}
|
}
|
||||||
if len(args) != 1 {
|
if len(args) < 1 || len(args) > 2 {
|
||||||
return fmt.Errorf("give the option name or --id")
|
return fmt.Errorf("give the option name (and the name after it, for tls NAME and the like) or --id")
|
||||||
}
|
}
|
||||||
sc, id, err := scope()
|
sc, id, err := scope()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -654,15 +673,24 @@ func optionCmd() *cobra.Command {
|
|||||||
}
|
}
|
||||||
var matches []api.Option
|
var matches []api.Option
|
||||||
for _, o := range opts {
|
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)
|
matches = append(matches, o)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
switch len(matches) {
|
switch len(matches) {
|
||||||
case 0:
|
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:
|
case 1:
|
||||||
default:
|
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))
|
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)
|
resp, err := c.Mutate(ctx(), "DELETE", fmt.Sprintf("/options/%d", matches[0].ID), nil, nil)
|
||||||
@@ -721,7 +749,35 @@ func optionCmd() *cobra.Command {
|
|||||||
}
|
}
|
||||||
scopeArgs(describe)
|
scopeArgs(describe)
|
||||||
describe.Flags().StringVar(&zoneType, "zone-type", "primary", "with --zone: the zone type")
|
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
|
return cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -815,3 +871,30 @@ func settingsCmd() *cobra.Command {
|
|||||||
cmd.AddCommand(show, set)
|
cmd.AddCommand(show, set)
|
||||||
return cmd
|
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.
|
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
|
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
|
the zone's DS record at the parent zone (your registrar for a domain, the
|
||||||
when that is safe. Turning it off goes through BIND's insecure policy, which
|
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.`,
|
keeps the zone signed until the DS is gone from the parent.`,
|
||||||
}
|
}
|
||||||
var view string
|
var view string
|
||||||
@@ -71,7 +71,7 @@ DS after that.`,
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if !flagQuiet {
|
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
|
return nil
|
||||||
},
|
},
|
||||||
@@ -87,8 +87,8 @@ DS after that.`,
|
|||||||
parent zone still has its DS record:
|
parent zone still has its DS record:
|
||||||
|
|
||||||
1. The first run switches the zone to BIND's insecure policy. named keeps it
|
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
|
signed and publishes a CDS "delete" record. Remove the DS record at the
|
||||||
registrar, then run 'jarl zone dnssec check-ds' so named knows. named
|
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
|
waits until resolvers have forgotten the DS (the parent's DS TTL, often a
|
||||||
day) and then removes the keys and signatures.
|
day) and then removes the keys and signatures.
|
||||||
2. Run it again when 'status' says turning off has finished: DNSSEC is then
|
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 != "" {
|
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))
|
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" {
|
} 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 {
|
} else {
|
||||||
fmt.Println("\nDNSSEC is off. The old keys stay in the key directory until you wipe them ('jarl zone dnssec wipe').")
|
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",
|
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
|
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
|
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.
|
moves to the next step only once it knows.
|
||||||
|
|
||||||
The parent is found through jarld's resolver, or --resolver. Exits with 2
|
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" {
|
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 != "" {
|
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)
|
fmt.Fprintf(w, "\nDS record for the parent zone (%s):\n", where)
|
||||||
if !r.Phase.CanPublishDS && r.Phase.DSReadyAt != nil {
|
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
|
// parentDSCommand is the jarl command that adds (or deletes) the zone's DS
|
||||||
// record in its parent zone in Jarl.
|
// record in its parent zone in Jarl.
|
||||||
func parentDSCommand(r *api.DNSSECResponse, verb, data string) string {
|
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
|
||||||
|
}
|
||||||
+81
-1
@@ -1,6 +1,7 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -42,6 +43,54 @@ type exposureReport struct {
|
|||||||
Count int64
|
Count int64
|
||||||
} `json:"names"`
|
} `json:"names"`
|
||||||
} `json:"rate_limiting"`
|
} `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
|
// exposureScope turns "server" (or "") into the server, anything else
|
||||||
@@ -115,6 +164,10 @@ fix Jarl can stage for each.`,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if r.Mail != nil {
|
||||||
|
fmt.Fprintln(w, "\nMAIL REVERSE DNS")
|
||||||
|
printMail(w, r.Mail)
|
||||||
|
}
|
||||||
if a := r.RateLimiting; a != nil && len(a.Counts) > 0 {
|
if a := r.RateLimiting; a != nil && len(a.Counts) > 0 {
|
||||||
fmt.Fprintf(w, "\nRATE LIMITING, LAST %s\n", a.Since)
|
fmt.Fprintf(w, "\nRATE LIMITING, LAST %s\n", a.Since)
|
||||||
for _, e := range a.Counts {
|
for _, e := range a.Counts {
|
||||||
@@ -186,6 +239,33 @@ fix Jarl can stage for each.`,
|
|||||||
return report(resp)
|
return report(resp)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
cmd.AddCommand(fix, ack, unack)
|
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
|
return cmd
|
||||||
}
|
}
|
||||||
+45
-10
@@ -9,6 +9,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"net/netip"
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -23,6 +24,7 @@ import (
|
|||||||
|
|
||||||
"jarl/internal/api"
|
"jarl/internal/api"
|
||||||
"jarl/internal/client"
|
"jarl/internal/client"
|
||||||
|
"jarl/internal/dnsname"
|
||||||
"jarl/internal/version"
|
"jarl/internal/version"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -101,17 +103,30 @@ func saveCLIConfig(c *cliConfig) error {
|
|||||||
return os.WriteFile(flagConfig, b, 0o600)
|
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()
|
cfg, err := loadCLIConfig()
|
||||||
if err != nil {
|
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))
|
ts, err := resolveTargets(cfg, targetSpec(cfg))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return connection{}, err
|
||||||
}
|
}
|
||||||
if len(ts) > 1 {
|
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]
|
t := ts[0]
|
||||||
// A named server's own token wins: a token from the environment
|
// 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)
|
sock, isSocket := client.SocketPath(t.URL)
|
||||||
if isSocket {
|
if isSocket {
|
||||||
if err := checkSocket(sock, t.Name == localName && flagServer == "" && os.Getenv("JARL_SERVER") == ""); err != nil {
|
if err := checkSocket(sock, t.Name == localName && flagServer == "" && os.Getenv("JARL_SERVER") == ""); err != nil {
|
||||||
return nil, err
|
return connection{}, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if token == "" && !isSocket {
|
if token == "" && !isSocket {
|
||||||
if t.Name != "" {
|
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, "@") {
|
if strings.HasPrefix(token, "@") {
|
||||||
b, err := os.ReadFile(token[1:])
|
b, err := os.ReadFile(token[1:])
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -139,7 +160,15 @@ func newClient() (*client.Client, error) {
|
|||||||
}
|
}
|
||||||
token = strings.TrimSpace(string(b))
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -165,7 +194,7 @@ func newClient() (*client.Client, error) {
|
|||||||
}
|
}
|
||||||
c.DryRun, c.Force, c.AcceptRisk = flagDryRun || c.Preflight, flagForce, flagRisk
|
c.DryRun, c.Force, c.AcceptRisk = flagDryRun || c.Preflight, flagForce, flagRisk
|
||||||
if !c.Preflight {
|
if !c.Preflight {
|
||||||
c.OnTokenExpiry = tokenExpiryWarning(t.Name)
|
c.OnTokenExpiry = tokenExpiryWarning(cn.Name)
|
||||||
}
|
}
|
||||||
return c, nil
|
return c, nil
|
||||||
}
|
}
|
||||||
@@ -259,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("dns", zoneCmd(), recordCmd(), viewCmd(), aclCmd(), tsigCmd(), optionCmd(), settingsCmd(), blockCmd(), blocklistCmd(), acmeCmd(), searchCmd(), statsCmd())
|
||||||
add("changes", applyCmd(), changesetCmd(), historyCmd())
|
add("changes", applyCmd(), changesetCmd(), historyCmd())
|
||||||
add("admin", statusCmd(), exposureCmd(), certCmd(), whoamiCmd(), userCmd(), tokenCmd(), auditCmd(), checkCmd(), resyncCmd(), configCmd())
|
add("admin", statusCmd(), exposureCmd(), certCmd(), encryptedDNSCmd(), whoamiCmd(), userCmd(), tokenCmd(), auditCmd(), checkCmd(), resyncCmd(), configCmd())
|
||||||
setupCompletion(root)
|
setupCompletion(root)
|
||||||
return root
|
return root
|
||||||
}
|
}
|
||||||
@@ -569,7 +598,13 @@ func q(kv ...string) url.Values {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// zonePath returns the API path for a zone given by name or ID.
|
// 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) {
|
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, ".")
|
z := strings.TrimSuffix(zone, ".")
|
||||||
if zone == "." {
|
if zone == "." {
|
||||||
z = "root"
|
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,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())
|
||||||
|
}
|
||||||
|
}
|
||||||
+36
-8
@@ -4,6 +4,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"math"
|
"math"
|
||||||
|
"net/url"
|
||||||
"slices"
|
"slices"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -21,8 +22,22 @@ func statsCmd() *cobra.Command {
|
|||||||
Long: `Query statistics collected from named (see docs/stats.md). They must be
|
Long: `Query statistics collected from named (see docs/stats.md). They must be
|
||||||
enabled in /etc/jarl/jarl.yaml (stats.enabled: true).
|
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
|
Ranges are given with --since: 90m, 24h, 7d, 2w. --view limits the figures
|
||||||
export the data.`,
|
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
|
// Each command has its own --since variable: their defaults differ, and
|
||||||
// a shared variable would take the last default for all of them.
|
// a shared variable would take the last default for all of them.
|
||||||
@@ -77,14 +92,27 @@ export the data.`,
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
var s stats.Summary
|
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 err
|
||||||
}
|
}
|
||||||
return output(s, func(w io.Writer) {
|
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, "Range:\t%s to %s\n", s.From.Local().Format("2006-01-02 15:04"), s.To.Local().Format("2006-01-02 15:04"))
|
||||||
|
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)
|
fmt.Fprintf(w, "Queries:\t%d (%.2f per second on average)\n", s.Queries, s.AvgQPS)
|
||||||
|
}
|
||||||
if s.Peak != nil {
|
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 {
|
if s.CacheHitRatio != nil {
|
||||||
fmt.Fprintf(w, "Cache hit ratio:\t%.1f%%\n", *s.CacheHitRatio*100)
|
fmt.Fprintf(w, "Cache hit ratio:\t%.1f%%\n", *s.CacheHitRatio*100)
|
||||||
@@ -157,7 +185,7 @@ export the data.`,
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
var es []stats.Entry
|
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 err
|
||||||
}
|
}
|
||||||
return output(es, func(w io.Writer) {
|
return output(es, func(w io.Writer) {
|
||||||
@@ -207,7 +235,7 @@ export the data.`,
|
|||||||
qv.Add("key", k)
|
qv.Add("key", k)
|
||||||
}
|
}
|
||||||
var tl stats.Timeline
|
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 err
|
||||||
}
|
}
|
||||||
return output(tl, func(w io.Writer) {
|
return output(tl, func(w io.Writer) {
|
||||||
@@ -256,7 +284,7 @@ export the data.`,
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
var hm stats.Heatmap
|
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 err
|
||||||
}
|
}
|
||||||
return output(hm, func(w io.Writer) {
|
return output(hm, func(w io.Writer) {
|
||||||
@@ -308,7 +336,7 @@ export the data.`,
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
var es []stats.Entry
|
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 err
|
||||||
}
|
}
|
||||||
return output(es, func(w io.Writer) {
|
return output(es, func(w io.Writer) {
|
||||||
|
|||||||
+453
-2
@@ -8,6 +8,7 @@ import (
|
|||||||
"net/netip"
|
"net/netip"
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
|
"slices"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"unicode/utf8"
|
"unicode/utf8"
|
||||||
@@ -93,6 +94,25 @@ func zoneCmd() *cobra.Command {
|
|||||||
if z.InView != "" {
|
if z.InView != "" {
|
||||||
fmt.Fprintf(w, "In view:\t%s\n", 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 {
|
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, ", "))
|
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, ", "))
|
||||||
}
|
}
|
||||||
@@ -110,17 +130,22 @@ func zoneCmd() *cobra.Command {
|
|||||||
var ns, records, primaries, forwarders, opts []string
|
var ns, records, primaries, forwarders, opts []string
|
||||||
var contact, comment, cloneFrom, file, inView, forward, linkWith string
|
var contact, comment, cloneFrom, file, inView, forward, linkWith string
|
||||||
var ttl uint32
|
var ttl uint32
|
||||||
|
var fill, fillOther bool
|
||||||
create := &cobra.Command{
|
create := &cobra.Command{
|
||||||
Use: "create NAME",
|
Use: "create NAME",
|
||||||
Short: "Create a zone",
|
Short: "Create a zone",
|
||||||
Long: `Create a zone. NAME may be a network (192.168.1.0/24, 2001:db8::/48) to
|
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,
|
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
|
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 example.com --ns ns1.example.com. --record "ns1 A 192.0.2.1"
|
||||||
jarl zone create 192.168.1.0/24
|
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.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 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 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
|
||||||
@@ -156,6 +181,12 @@ and a network between octet boundaries, like a /22, gets one zone per /24
|
|||||||
if linkWith != "" {
|
if linkWith != "" {
|
||||||
body["link_with"] = linkWith
|
body["link_with"] = linkWith
|
||||||
}
|
}
|
||||||
|
if fill || fillOther {
|
||||||
|
body["fill"] = true
|
||||||
|
}
|
||||||
|
if fillOther {
|
||||||
|
body["fill_other_views"] = true
|
||||||
|
}
|
||||||
var recs []map[string]any
|
var recs []map[string]any
|
||||||
for _, r := range records {
|
for _, r := range records {
|
||||||
f := strings.Fields(r)
|
f := strings.Fields(r)
|
||||||
@@ -228,9 +259,12 @@ and a network between octet boundaries, like a /22, gets one zone per /24
|
|||||||
create.Flags().StringVar(&inView, "in-view", "", "for in-view zones: the view to share the zone from")
|
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(&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().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 refresh, retry, expire, minimum, serial uint32
|
||||||
var mname, policy, moveView string
|
var mname, policy, moveView string
|
||||||
|
var autoPTR bool
|
||||||
update := &cobra.Command{
|
update := &cobra.Command{
|
||||||
Use: "update ZONE",
|
Use: "update ZONE",
|
||||||
Short: "Change a zone's settings (SOA, TTL, view, comment)",
|
Short: "Change a zone's settings (SOA, TTL, view, comment)",
|
||||||
@@ -272,6 +306,9 @@ and a network between octet boundaries, like a /22, gets one zone per /24
|
|||||||
if f.Changed("file") {
|
if f.Changed("file") {
|
||||||
body["file"] = file
|
body["file"] = file
|
||||||
}
|
}
|
||||||
|
if f.Changed("auto-ptr") {
|
||||||
|
body["auto_ptr"] = autoPTR
|
||||||
|
}
|
||||||
if len(body) == 0 {
|
if len(body) == 0 {
|
||||||
return fmt.Errorf("nothing to change (see --help)")
|
return fmt.Errorf("nothing to change (see --help)")
|
||||||
}
|
}
|
||||||
@@ -296,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(&comment, "comment", "", "comment")
|
||||||
update.Flags().StringVar(&moveView, "move-to-view", "", "move the zone to this view")
|
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().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
|
var yes bool
|
||||||
del := &cobra.Command{
|
del := &cobra.Command{
|
||||||
@@ -424,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(&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")
|
importCmd.Flags().BoolVar(&useSOA, "use-soa", false, "also take SOA timers and $TTL from the file")
|
||||||
|
|
||||||
cmd.AddCommand(list, show, create, update, del, toggle(true), toggle(false), fileCmd, importCmd, zoneSerialsCmd(), zoneDelegationCmd(), zoneDNSSECCmd(), zoneRegistrarCmd(), zoneLinkCmd(true), zoneLinkCmd(false))
|
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
|
return cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -937,3 +975,416 @@ zone is dissolved.`
|
|||||||
cmd.Example = " jarl zone unlink adbraun.lol --view vps"
|
cmd.Example = " jarl zone unlink adbraun.lol --view vps"
|
||||||
return cmd
|
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
|
||||||
|
}
|
||||||
@@ -151,6 +151,7 @@ func statsOptions(cfg *config.Config) stats.Options {
|
|||||||
sc := cfg.Stats
|
sc := cfg.Stats
|
||||||
o := stats.Options{QueryLog: sc.QueryLog, LogFile: sc.LogFile, StatisticsChannel: sc.StatisticsChannel, TopN: sc.TopN,
|
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),
|
RetainMinute: time.Duration(sc.Retention.Minute), RetainHour: time.Duration(sc.Retention.Hour), RetainDay: time.Duration(sc.Retention.Day),
|
||||||
|
RetainQueries: time.Duration(sc.Retention.Queries),
|
||||||
Anonymize: sc.AnonymizeClients, MainConfig: cfg.BIND.MainConfig}
|
Anonymize: sc.AnonymizeClients, MainConfig: cfg.BIND.MainConfig}
|
||||||
for _, p := range sc.ExcludeClients {
|
for _, p := range sc.ExcludeClients {
|
||||||
if pf, err := config.ParsePrefix(p); err == nil {
|
if pf, err := config.ParsePrefix(p); err == nil {
|
||||||
@@ -188,6 +189,7 @@ func renderExtras(cfg *config.Config) render.Extras {
|
|||||||
h := &render.StatsHooks{Channel: netip.MustParseAddrPort(cfg.Stats.StatisticsChannel)}
|
h := &render.StatsHooks{Channel: netip.MustParseAddrPort(cfg.Stats.StatisticsChannel)}
|
||||||
if cfg.Stats.QueryLog {
|
if cfg.Stats.QueryLog {
|
||||||
h.LogFile, h.LogVersions, h.LogSize = cfg.Stats.LogFile, cfg.Stats.LogVersions, cfg.Stats.LogSize
|
h.LogFile, h.LogVersions, h.LogSize = cfg.Stats.LogFile, cfg.Stats.LogVersions, cfg.Stats.LogSize
|
||||||
|
h.NoRateLimitLog = !cfg.Stats.RateLimitLog
|
||||||
}
|
}
|
||||||
x.Stats = h
|
x.Stats = h
|
||||||
}
|
}
|
||||||
@@ -316,6 +318,7 @@ func serveCmd() *cobra.Command {
|
|||||||
EABKeyID: a.cfg.ACME.Account.EABKeyID, EABHMAC: a.cfg.ACME.Account.EABHMAC,
|
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,
|
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,
|
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)
|
}, a.store, a.eng, a.log)
|
||||||
cm.Start(ctx)
|
cm.Start(ctx)
|
||||||
if a.cfg.ACME.Account.EABHMAC != "" {
|
if a.cfg.ACME.Account.EABHMAC != "" {
|
||||||
@@ -334,6 +337,8 @@ func serveCmd() *cobra.Command {
|
|||||||
Metrics: server.Metrics{Enabled: a.cfg.Metrics.Enabled, Listen: a.cfg.Metrics.Listen,
|
Metrics: server.Metrics{Enabled: a.cfg.Metrics.Enabled, Listen: a.cfg.Metrics.Listen,
|
||||||
Allow: a.cfg.Metrics.AllowPrefixes(), Token: a.cfg.Metrics.Token},
|
Allow: a.cfg.Metrics.AllowPrefixes(), Token: a.cfg.Metrics.Token},
|
||||||
Certs: cm,
|
Certs: cm,
|
||||||
|
Resolvers: a.cfg.ACME.Resolvers,
|
||||||
|
RateLimitLog: a.cfg.Stats.Enabled && a.cfg.Stats.QueryLog && a.cfg.Stats.RateLimitLog,
|
||||||
HTTPReq: server.HTTPReq{
|
HTTPReq: server.HTTPReq{
|
||||||
Enabled: a.cfg.ACME.HTTPReq.Enabled,
|
Enabled: a.cfg.ACME.HTTPReq.Enabled,
|
||||||
AllowPlainHTTP: !a.cfg.ACME.HTTPReq.RequireHTTPS,
|
AllowPlainHTTP: !a.cfg.ACME.HTTPReq.RequireHTTPS,
|
||||||
|
|||||||
@@ -38,6 +38,13 @@ if [ "$1" = configure ]; then
|
|||||||
if systemctl is-enabled --quiet jarl-backup.timer 2>/dev/null; then
|
if systemctl is-enabled --quiet jarl-backup.timer 2>/dev/null; then
|
||||||
systemctl start jarl-backup.timer || true
|
systemctl start jarl-backup.timer || true
|
||||||
fi
|
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
|
# An upgrade restarts a running jarld (its database is upgraded
|
||||||
# when it starts). A first install starts nothing: Jarl needs
|
# when it starts). A first install starts nothing: Jarl needs
|
||||||
# 'jarld init' and 'jarld adopt' first.
|
# 'jarld init' and 'jarld adopt' first.
|
||||||
|
|||||||
@@ -65,3 +65,5 @@ SystemCallErrorNumber=EPERM
|
|||||||
|
|
||||||
[Install]
|
[Install]
|
||||||
WantedBy=multi-user.target
|
WantedBy=multi-user.target
|
||||||
|
# 'systemctl restart jarld' works too (the daemon's binary is jarld).
|
||||||
|
Alias=jarld.service
|
||||||
+3
-1
@@ -48,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.
|
* The zone is found automatically: the longest matching primary zone.
|
||||||
* If the zone exists in several **views** (split DNS), the record is written
|
* 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
|
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
|
* 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
|
already there succeeds, so a retried hook doesn't fail, and removing a
|
||||||
value that isn't there succeeds as well.
|
value that isn't there succeeds as well.
|
||||||
|
|||||||
+51
-8
@@ -190,6 +190,8 @@ ZoneInput:
|
|||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"name": "adbraun.lol", // or a CIDR like "192.168.1.0/24" for a reverse zone
|
"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
|
"view": "internal", // or "view_id"; moving a zone = PATCH with another view
|
||||||
"type": "primary", // secondary, stub, static-stub, forward, mirror, hint, redirect, in-view
|
"type": "primary", // secondary, stub, static-stub, forward, mirror, hint, redirect, in-view
|
||||||
"in_view": "internal", // type in-view only
|
"in_view": "internal", // type in-view only
|
||||||
@@ -199,6 +201,7 @@ ZoneInput:
|
|||||||
"default_ttl": 3600,
|
"default_ttl": 3600,
|
||||||
"serial_policy": "date", // date, increment, unixtime
|
"serial_policy": "date", // date, increment, unixtime
|
||||||
"dnssec_policy": "default", // primary zones: "" (off), "default", a custom policy, "insecure" (turning off)
|
"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": "",
|
"comment": "",
|
||||||
// create only:
|
// create only:
|
||||||
"nameservers": ["ns1.adbraun.lol."], // default: settings
|
"nameservers": ["ns1.adbraun.lol."], // default: settings
|
||||||
@@ -208,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
|
`/serials` and `/delegation` send DNS queries from the server, and only
|
||||||
when called ([operations.md](operations.md#checking-name-servers-and-the-delegation)).
|
when called ([operations.md](operations.md#checking-name-servers-and-the-delegation)).
|
||||||
`/serials` works for primary, secondary and mirror zones:
|
`/serials` works for primary, secondary and mirror zones:
|
||||||
@@ -287,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": "..."},
|
"named": {"secure": true, "inline_signing": true, "serial": 2026100802, "signed_serial": 2026100804, "next_key_event": "..."},
|
||||||
"policies": ["default"],
|
"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
|
"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`,
|
`phase.code` is one of `off`, `leftover_keys`, `starting`, `signing`,
|
||||||
`ready`, `ds_pending`, `secure`, `removing_ds`, `ds_expiring`,
|
`ready`, `ds_pending`, `secure`, `removing_ds`, `ds_expiring`,
|
||||||
`removing_keys`, `unsigned` ([dnssec.md](dnssec.md#phases)).
|
`removing_keys`, `unsigned` ([dnssec.md](dnssec.md#phases)).
|
||||||
@@ -347,12 +368,19 @@ In a linked zone ([views.md](views.md#linked-zones)), records carry
|
|||||||
(`PATCH /records/{id}`) and setting (`PUT .../rrsets/...`) take `"local":
|
(`PATCH /records/{id}`) and setting (`PUT .../rrsets/...`) take `"local":
|
||||||
true` (this view only, an override) or `false` (shared; on an update or a
|
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`
|
set it makes this view's version every view's). Zones carry `link_group`
|
||||||
and `linked_with` (the other views).
|
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 |
|
| 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}/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}/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
|
`POST /zones` takes `"link_with": "VIEW"` to create a zone linked with
|
||||||
that view's zone of the same name.
|
that view's zone of the same name.
|
||||||
@@ -400,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
|
`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.
|
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`
|
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
|
### Views
|
||||||
|
|
||||||
@@ -469,7 +499,8 @@ listing with `?changeset=` marks them `pending`.
|
|||||||
|
|
||||||
| Method | Path | Body / query |
|
| 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}` |
|
| POST | `/options` | `{"scope": "zone", "scope_id": 11, "name": "allow-transfer", "value": "{ key xfer; }", "comment": "", "position": 0}` |
|
||||||
| GET / PATCH / DELETE | `/options/{id}` | |
|
| GET / PATCH / DELETE | `/options/{id}` | |
|
||||||
| GET / PATCH | `/settings` | `{"nameservers": [...], "rname": "...", "default_ttl": 3600, "refresh": ..., "retry": ..., "expire": ..., "negative_ttl": ..., "serial_policy": "date"}` |
|
| GET / PATCH | `/settings` | `{"nameservers": [...], "rname": "...", "default_ttl": 3600, "refresh": ..., "retry": ..., "expire": ..., "negative_ttl": ..., "serial_policy": "date"}` |
|
||||||
@@ -498,7 +529,7 @@ written in Jarl's fixed order (docs/concepts.md).
|
|||||||
| GET | `/changesets` | `?status=open\|applied\|discarded&mine=1&limit=100&before=ID` |
|
| GET | `/changesets` | `?status=open\|applied\|discarded&mine=1&limit=100&before=ID` |
|
||||||
| POST | `/changesets` | `{"description": "...", "ops": [...], "apply": false}`: create (and optionally stage or apply) |
|
| POST | `/changesets` | `{"description": "...", "ops": [...], "apply": false}`: create (and optionally stage or apply) |
|
||||||
| GET / PATCH | `/changesets/{id}` | PATCH `{"description": "..."}` |
|
| 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 |
|
| DELETE | `/changesets/{id}/ops/{seq}` | remove one staged operation |
|
||||||
| GET | `/changesets/{id}/preview` | dry run of an open changeset: problems, check messages, diff |
|
| GET | `/changesets/{id}/preview` | dry run of an open changeset: problems, check messages, diff |
|
||||||
| POST | `/changesets/{id}/apply` | `{"dry_run": false, "force": false}` |
|
| POST | `/changesets/{id}/apply` | `{"dry_run": false, "force": false}` |
|
||||||
@@ -539,23 +570,33 @@ in it and its records.
|
|||||||
|
|
||||||
### Query statistics
|
### 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
|
Needs `stats:read`. When statistics are disabled, every endpoint but
|
||||||
`status` answers 404 `stats_disabled`. See [stats.md](stats.md).
|
`status` answers 404 `stats_disabled`. See [stats.md](stats.md).
|
||||||
|
|
||||||
| Method | Path | Query | Returns |
|
| 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/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/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/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/heatmap` | `since` (default 28d), `key` (default queries) | `{key, from, avg[7][24], samples[7][24]}`, Monday first, local time, `-1` = no data |
|
||||||
| GET | `/stats/server` | | `{server: {rss, mem_in_use, cpu_percent, threads, pid, cache_rrsets, cache_mem, sockets_udp, sockets_tcp, recursion_highwater, tcp_highwater, version, boot_time, config_time, host: {mem_total, mem_available, load, cpus, uptime}}, named: {recursive_clients, tcp_clients, zones, cpus, worker_threads}, disks: [{path, holds, total, free}]}`; gauges over time: `/stats/timeline?key=g:named:rss` (averages, -1 without a sample) |
|
| GET | `/stats/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}]` |
|
| GET | `/stats/breakdown` | `group` (qtype, rcode, view, zone, answer, transport, rtt, dnssec, cache, ratelimit, blocklist), `since` | `[{key, count}]` |
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
curl -sf -H "$AUTH" "$API/stats/top?kind=nxdomain&since=7d&limit=10" | jq -r '.[] | "\(.count)\t\(.key)"'
|
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
|
### Certificates
|
||||||
|
|
||||||
Administrators only. See [certificates.md](certificates.md#api).
|
Administrators only. See [certificates.md](certificates.md#api).
|
||||||
@@ -568,6 +609,8 @@ Administrators only. See [certificates.md](certificates.md#api).
|
|||||||
| GET, PATCH, DELETE | `/certificates/{name}` | |
|
| 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}/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}/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`,
|
`GET /status` includes `certificates` for administrators: `total`,
|
||||||
`problems` (failing, expiring, expired), `next` and `next_after`,
|
`problems` (failing, expiring, expired), `next` and `next_after`,
|
||||||
@@ -594,7 +637,7 @@ Success is `200 {"ok": true, "fqdn": ..., "changeset_id": ..., "zones": [...]}`.
|
|||||||
|--------|------|------|
|
|--------|------|------|
|
||||||
| GET | `/users` | admin |
|
| 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) |
|
| 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 | `/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 / 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 |
|
| GET | `/tokens` | every token (admin); `?all=1` includes revoked tokens |
|
||||||
|
|||||||
+109
-6
@@ -183,16 +183,24 @@ configuration names them, Jarl runs `rndc reconfig`, and named serves the
|
|||||||
new certificate at once (checked with BIND 9.20: DoT keeps working, with
|
new certificate at once (checked with BIND 9.20: DoT keeps working, with
|
||||||
the new certificate).
|
the new certificate).
|
||||||
|
|
||||||
Until Jarl sets up DNS over TLS and HTTPS for you, add it by hand:
|
`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
|
```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 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 # DoT
|
jarl option set listen-on 'port 853 tls dot { 10.0.10.5; }' --add
|
||||||
jarl option set listen-on 'port 443 tls dot http default { 10.0.10.5; }' --add # DoH at /dns-query
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Listen on internal addresses only, unless you mean to run a public
|
The Certificates page (and `jarl cert show`) says what named uses each
|
||||||
resolver ([exposure.md](exposure.md)).
|
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
|
## A certificate you renew yourself
|
||||||
|
|
||||||
@@ -208,11 +216,105 @@ 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
|
be able to read both files (e.g. `chgrp jarl` and `chmod 640`, or have
|
||||||
your renewal hook copy them somewhere it can read).
|
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).
|
||||||
|
* **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
|
## Security notes
|
||||||
|
|
||||||
- Certificates Jarl obtains, their keys and the ACME account key are kept
|
- 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
|
in Jarl's database, so they are in its backups (like TSIG secrets). The
|
||||||
API never returns a key.
|
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
|
- Every publicly trusted certificate is published in Certificate
|
||||||
Transparency logs, with all its names. A name like `ns1.adbraun.lol`
|
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
|
used for both the web UI and DNS reveals less than separate names. Avoid
|
||||||
@@ -232,6 +334,7 @@ your renewal hook copy them somewhere it can read).
|
|||||||
| GET, PATCH, DELETE | `/certificates/{name}` | `domains` and `uses` replace the whole list |
|
| 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}/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 |
|
| 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
|
## Testing with Pebble
|
||||||
|
|
||||||
|
|||||||
+48
-1
@@ -177,6 +177,14 @@ 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 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.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.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 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 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 create consul --type forward --forwarder "127.0.0.1 port 8600"
|
||||||
@@ -221,6 +229,8 @@ 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 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 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 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
|
When a zone exists in several views, add `--view NAME` to commands that
|
||||||
@@ -307,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
|
host name (a bare `nas` is refused, since it would mean
|
||||||
`nas.0.10.10.in-addr.arpa.`). `--ptr`
|
`nas.0.10.10.in-addr.arpa.`). `--ptr`
|
||||||
keeps the PTR record in the reverse zone in step (when that zone is managed
|
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
|
syntax of the common ones and checks them strictly, and the rest are
|
||||||
validated by parsing.
|
validated by parsing.
|
||||||
|
|
||||||
@@ -402,6 +415,7 @@ jarl exposure # public views, findings,
|
|||||||
jarl exposure fix external exposure_rate_limit # rate limiting, log-only first
|
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 external exposure_rate_limit_log_only # then start enforcing
|
||||||
jarl exposure fix server exposure_version
|
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 ack external security_open_resolver --reason "public resolver, monitored"
|
||||||
jarl exposure unack external security_open_resolver
|
jarl exposure unack external security_open_resolver
|
||||||
jarl view update external --exposure public|private|auto
|
jarl view update external --exposure public|private|auto
|
||||||
@@ -409,6 +423,18 @@ jarl view update external --exposure public|private|auto
|
|||||||
|
|
||||||
See [exposure.md](exposure.md).
|
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
|
## Certificates
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
@@ -421,6 +447,13 @@ jarl cert show ns1
|
|||||||
jarl cert issue ns1 # renew now (--no-wait to return at once)
|
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 --challenge porkbun # auto, jarl or porkbun
|
||||||
jarl cert update ns1 --use dns # --use and --domain replace the list (--use none)
|
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 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 delete ns1
|
||||||
jarl cert account # the ACME account (set in jarl.yaml)
|
jarl cert account # the ACME account (set in jarl.yaml)
|
||||||
```
|
```
|
||||||
@@ -458,11 +491,22 @@ 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 allow-query '10.0.20.1-10.0.20.99; localhost' --view internal
|
||||||
jarl option set response-policy '{ zone "rpz.local"; }' --view internal
|
jarl option set response-policy '{ zone "rpz.local"; }' --view internal
|
||||||
jarl option set logging '{ channel q { file "/var/log/named/query.log" versions 3 size 20m; }; category queries { q; }; }' --toplevel
|
jarl option set logging '{ channel q { file "/var/log/named/query.log" versions 3 size 20m; }; category queries { q; }; }' --toplevel
|
||||||
|
jarl option 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 unset forwarders
|
||||||
jarl option describe # every option allowed at this scope
|
jarl option describe # every option allowed at this scope
|
||||||
jarl option describe allow-query-cache # grammar and ARM description
|
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
|
Options that end in a list (`allow-*`, `forwarders`, `listen-on`, ...) may
|
||||||
be given without the braces: elements are separated by `;`, `,` or spaces,
|
be given without the braces: elements are separated by `;`, `,` or spaces,
|
||||||
and `key NAME` and `port N` stay with their element. Options are always
|
and `key NAME` and `port N` stay with their element. Options are always
|
||||||
@@ -517,6 +561,9 @@ working.
|
|||||||
|
|
||||||
## Query statistics
|
## Query statistics
|
||||||
|
|
||||||
|
`--view internal,vps` (or repeated) limits any `jarl stats` command to those
|
||||||
|
views ([stats.md](stats.md#views)).
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
jarl stats status
|
jarl stats status
|
||||||
jarl stats summary --since 7d
|
jarl stats summary --since 7d
|
||||||
|
|||||||
@@ -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
|
**drift** when a file changes behind its back. It then refuses to overwrite
|
||||||
the file until you say so (`--force`, or `jarl resync --force`).
|
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
|
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.
|
options can all carry a comment, and Jarl writes it into the generated file.
|
||||||
Comments in imported zone files are kept.
|
Comments in imported zone files are kept.
|
||||||
@@ -250,6 +258,11 @@ boundary. So:
|
|||||||
(RFC 2317 delegation, where an ISP hands you part of a /24, is set up by
|
(RFC 2317 delegation, where an ISP hands you part of a /24, is set up by
|
||||||
hand with CNAMEs.)
|
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
|
### Dynamic updates and TSIG keys
|
||||||
|
|
||||||
Jarl writes every zone file, so it can't let named change a zone through
|
Jarl writes every zone file, so it can't let named change a zone through
|
||||||
|
|||||||
@@ -101,6 +101,7 @@ jarld restarts.
|
|||||||
| `acme.ca_bundle` | `""` | PEM file of extra CA certificates to trust for the ACME server (a private CA). |
|
| `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). |
|
| `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.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)
|
### ACME (lego httpreq endpoint)
|
||||||
|
|
||||||
@@ -163,7 +164,7 @@ check` says so).
|
|||||||
|-----|---------|---------|
|
|-----|---------|---------|
|
||||||
| `porkbun.api_key` | `""` | The API key (`pk1_...`). Set both or neither. |
|
| `porkbun.api_key` | `""` | The API key (`pk1_...`). Set both or neither. |
|
||||||
| `porkbun.secret_api_key` | `""` | Its secret (`sk1_...`). |
|
| `porkbun.secret_api_key` | `""` | Its secret (`sk1_...`). |
|
||||||
| `porkbun.domains` | `[]` | Only these domains, for both uses (default: any domain 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
|
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
|
file: it isn't stored in the database or backups, and there is no API to
|
||||||
|
|||||||
@@ -32,6 +32,12 @@ internal/
|
|||||||
blocking/ blocklist parser, response policy zone writers, and matching a name the way named does
|
blocking/ blocklist parser, response policy zone writers, and matching a name the way named does
|
||||||
(the downloads are in engine/blocklists.go)
|
(the downloads are in engine/blocklists.go)
|
||||||
config/ jarld's YAML configuration
|
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/ 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
|
deploy/debian/ the Debian package's control template and postinst/prerm/postrm
|
||||||
contrib/acme.sh/ acme.sh DNS API hook
|
contrib/acme.sh/ acme.sh DNS API hook
|
||||||
|
|||||||
@@ -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
|
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.
|
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
|
**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
|
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
|
the usual setup, with one catch. Once the DS is at the parent, the world
|
||||||
@@ -204,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
|
when everyone can read it. Only administrators can use the registrar
|
||||||
features.
|
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`:
|
**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` |
|
||||||
+36
-2
@@ -107,7 +107,10 @@ is only in its log (category `query-errors`). That is why the activity
|
|||||||
needs query statistics; without them, look at named's log for
|
needs query statistics; without them, look at named's log for
|
||||||
`would rate limit`. When rate limiting is configured and statistics are
|
`would rate limit`. When rate limiting is configured and statistics are
|
||||||
on, Jarl adds `query-errors` to its log channel (keeping named's syslog
|
on, Jarl adds `query-errors` to its log channel (keeping named's syslog
|
||||||
destination for it).
|
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`,
|
The statistics also count it: the `rrl` breakdown (`drop`, `slip`,
|
||||||
`would-drop`, `would-slip`), the `ratelimited` and `ratelimited_name` top
|
`would-drop`, `would-slip`), the `ratelimited` and `ratelimited_name` top
|
||||||
@@ -160,6 +163,36 @@ 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
|
none of its own, your internal resolver included, which RRL isn't meant
|
||||||
for.
|
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
|
## Confirming a change that opens a view up
|
||||||
|
|
||||||
A change that *introduces* an open resolver, open zone transfers, cache
|
A change that *introduces* an open resolver, open zone transfers, cache
|
||||||
@@ -204,7 +237,8 @@ monitoring, response size limits and usually a machine of its own.
|
|||||||
|
|
||||||
| Method | Path | Body |
|
| 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`) |
|
| 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/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/ack` | `{"view": "external", "code": "security_open_resolver", "reason": "..."}` |
|
||||||
| POST | `/exposure/unack` | `{"view": "external", "code": "security_open_resolver"}` |
|
| POST | `/exposure/unack` | `{"view": "external", "code": "security_open_resolver"}` |
|
||||||
|
|||||||
@@ -317,6 +317,10 @@ systemctl status jarl
|
|||||||
journalctl -u jarl -f
|
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`.
|
Open `https://<management-address>:8053/` and log in as `admin`.
|
||||||
|
|
||||||
## 9. Set up the CLI
|
## 9. Set up the CLI
|
||||||
|
|||||||
@@ -132,6 +132,8 @@ same values.
|
|||||||
| `jarl_certificate_expiry_timestamp_seconds{certificate,source}` | When each certificate Jarl manages expires ([certificates.md](certificates.md)). |
|
| `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_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_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`)
|
### Queries (with `stats.enabled`)
|
||||||
|
|
||||||
@@ -232,6 +234,15 @@ groups:
|
|||||||
expr: jarl_certificate_expiry_timestamp_seconds - time() < 10 * 86400
|
expr: jarl_certificate_expiry_timestamp_seconds - time() < 10 * 86400
|
||||||
annotations:
|
annotations:
|
||||||
summary: "Certificate {{ $labels.certificate }} expires within 10 days"
|
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
|
- alert: BlockingPausedForLong
|
||||||
expr: jarl_blocking_paused == 1
|
expr: jarl_blocking_paused == 1
|
||||||
for: 24h
|
for: 24h
|
||||||
|
|||||||
+15
-3
@@ -118,7 +118,8 @@ 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,
|
may do everything, and only they can manage users, see everyone's tokens,
|
||||||
run `resync`, wipe a zone's DNSSEC keys, use the registrar (Porkbun,
|
run `resync`, wipe a zone's DNSSEC keys, use the registrar (Porkbun,
|
||||||
whose key can move the domain), and manage TLS certificates (whose keys
|
whose key can move the domain), and manage TLS certificates (whose keys
|
||||||
can impersonate the server; [certificates.md](certificates.md)).
|
can impersonate the server; [certificates.md](certificates.md)). The one
|
||||||
|
exception is certificates for other hosts, below.
|
||||||
|
|
||||||
A grant is written as:
|
A grant is written as:
|
||||||
|
|
||||||
@@ -137,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) |
|
| `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) |
|
| `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 |
|
| `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.
|
`ACCESS` is `read` or `write` (write includes read). Filters narrow a grant.
|
||||||
Each takes comma separated patterns where `*` and `?` are wildcards, and a
|
Each takes comma separated patterns where `*` and `?` are wildcards, and a
|
||||||
@@ -146,6 +148,8 @@ missing filter matches everything:
|
|||||||
* `zones=adbraun.lol,*.adbraun.lol`: zone names, without the final dot
|
* `zones=adbraun.lol,*.adbraun.lol`: zone names, without the final dot
|
||||||
* `views=internal`: view names (`_default` means zones outside any view)
|
* `views=internal`: view names (`_default` means zones outside any view)
|
||||||
* `names=_acme-challenge.*`: record owner names, as FQDNs without the final dot
|
* `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
|
* `types=TXT,CNAME`: record types
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
@@ -157,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 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 names=_acme-challenge.* types=TXT ACME challenges anywhere
|
||||||
records:write zones=acme.adbraun.lol types=TXT only TXT records in a dedicated ACME zone
|
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
|
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
|
### Roles
|
||||||
|
|
||||||
|
|||||||
+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.
|
||||||
+78
-1
@@ -60,6 +60,7 @@ off again. The statistics database stays until you delete it
|
|||||||
stats:
|
stats:
|
||||||
enabled: false
|
enabled: false
|
||||||
query_log: true # per-query data; false = named's counters only
|
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
|
database: /var/lib/jarl/stats.db
|
||||||
statistics_channel: 127.0.0.1:8058 # must be a loopback address
|
statistics_channel: 127.0.0.1:8058 # must be a loopback address
|
||||||
log_file: /var/log/named/jarl-responses.log
|
log_file: /var/log/named/jarl-responses.log
|
||||||
@@ -69,6 +70,7 @@ stats:
|
|||||||
minute: 7d # per-minute counters
|
minute: 7d # per-minute counters
|
||||||
hour: 90d # hourly counters and top lists
|
hour: 90d # hourly counters and top lists
|
||||||
day: 730d # daily 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
|
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)
|
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
|
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
|
which is negligible at homelab volumes. At tens of thousands of queries per
|
||||||
second, consider counters only.
|
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
|
`exclude_clients` drops those clients from the log-derived figures (top
|
||||||
lists, views, zones). named's own counters can't be filtered.
|
lists, views, zones). named's own counters can't be filtered.
|
||||||
|
|
||||||
@@ -184,13 +193,81 @@ jarl stats summary --since 30d -o json # export (also -o yaml)
|
|||||||
`operator` roles include it, `dns-editor` doesn't. Statistics show client
|
`operator` roles include it, `dns-editor` doesn't. Statistics show client
|
||||||
addresses and what they look up, so grant it with care.
|
addresses and what they look up, so grant it with care.
|
||||||
|
|
||||||
|
## 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
|
## How data is kept
|
||||||
|
|
||||||
- Minute counters are kept for `retention.minute`, hourly counters and top
|
- Minute counters are kept for `retention.minute`, hourly counters and top
|
||||||
lists for `retention.hour`, daily ones for `retention.day`. Older data is
|
lists for `retention.hour`, daily ones for `retention.day`. Older data is
|
||||||
deleted every hour.
|
deleted every hour.
|
||||||
- Charts use the finest resolution that covers the range: minutes up to 48
|
- 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
|
- 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
|
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
|
built from the hourly top lists, so for very long tails they are
|
||||||
|
|||||||
+7
-1
@@ -34,7 +34,7 @@ showing them.
|
|||||||
| `cname_multiple` | error | A name can have only one CNAME |
|
| `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_apex` | error | No CNAME at the zone apex (it needs SOA and NS) |
|
||||||
| `cname_loop` | error | CNAMEs that point back to themselves |
|
| `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 |
|
| `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) |
|
| `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 |
|
| `apex_ns_missing` | error | A zone needs NS records at its apex |
|
||||||
@@ -104,6 +104,12 @@ applied.
|
|||||||
| `dnssec_old_keys` | info | Signing a zone whose key directory still has old keys |
|
| `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) |
|
| `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
|
## Linked zones
|
||||||
|
|
||||||
See [views.md](views.md#linked-zones). Jarl keeps linked zones in step
|
See [views.md](views.md#linked-zones). Jarl keeps linked zones in step
|
||||||
|
|||||||
+65
-2
@@ -168,6 +168,10 @@ 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
|
`jarl-view-` are reserved, and the view keys can be used in your own
|
||||||
options (`allow-transfer { key jarl-view-external; }`).
|
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
|
## Linked zones
|
||||||
|
|
||||||
Most split-horizon zones are the same in every view apart from a few
|
Most split-horizon zones are the same in every view apart from a few
|
||||||
@@ -183,7 +187,9 @@ own and its own zone options.
|
|||||||
the web UI). A local record overrides the shared records of the same name
|
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
|
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
|
external view keeps the shared `www A 203.0.113.20`. Delete the local
|
||||||
records and the view gets the shared ones back.
|
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
|
* A new record joins the set of the same name and type: local if that set
|
||||||
is local in its view, shared otherwise.
|
is local in its view, shared otherwise.
|
||||||
* **Sharing a version** (`jarl record update ID --shared`, **Share** in the
|
* **Sharing a version** (`jarl record update ID --shared`, **Share** in the
|
||||||
@@ -209,11 +215,17 @@ 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
|
keeps every record, all local; a link left with one zone is dissolved, as
|
||||||
when the other zone is deleted.
|
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
|
**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
|
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
|
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
|
change the other views too: Jarl says so and suggests a local record. A
|
||||||
grant without a view (the usual `zones=adbraun.lol`) covers every view.
|
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
|
**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
|
the records like any others: shared, unless the name and type is local
|
||||||
@@ -229,6 +241,57 @@ 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
|
don't depend on the view order (`in-view` can only use a view defined
|
||||||
before it).
|
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
|
## Living with views
|
||||||
|
|
||||||
* Commands that name a zone that exists in several views need `--view`
|
* Commands that name a zone that exists in several views need `--view`
|
||||||
|
|||||||
+96
-1
@@ -34,6 +34,26 @@ type Zone struct {
|
|||||||
LinkedWith []string `json:"linked_with,omitempty"`
|
LinkedWith []string `json:"linked_with,omitempty"`
|
||||||
// Links are the zones this one is linked with, with their views.
|
// Links are the zones this one is linked with, with their views.
|
||||||
Links []ZoneLink `json:"links,omitempty"`
|
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 is set when listing with a changeset: add, change or delete.
|
||||||
Pending string `json:"pending,omitempty"`
|
Pending string `json:"pending,omitempty"`
|
||||||
}
|
}
|
||||||
@@ -59,6 +79,21 @@ type View struct {
|
|||||||
model.View
|
model.View
|
||||||
Zones int `json:"zone_count"`
|
Zones int `json:"zone_count"`
|
||||||
Color string `json:"color,omitempty"` // web UI colour, see model.ViewColors
|
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.
|
// ViewColor sets the colour the web UI shows a view in.
|
||||||
@@ -266,6 +301,40 @@ type StatusResponse struct {
|
|||||||
// Certificates sums up the TLS certificates Jarl manages
|
// Certificates sums up the TLS certificates Jarl manages
|
||||||
// (administrators only, when there are any).
|
// (administrators only, when there are any).
|
||||||
Certificates *CertSummary `json:"certificates,omitempty"`
|
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.
|
// CertSummary is what the dashboard reports about certificates.
|
||||||
@@ -357,6 +426,9 @@ type UserInput struct {
|
|||||||
Role *string `json:"role,omitempty"` // a role template name
|
Role *string `json:"role,omitempty"` // a role template name
|
||||||
Disabled *bool `json:"disabled,omitempty"`
|
Disabled *bool `json:"disabled,omitempty"`
|
||||||
Comment *string `json:"comment,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.
|
// TokenInput creates a token.
|
||||||
@@ -423,8 +495,20 @@ type DNSSECResponse struct {
|
|||||||
View string `json:"view,omitempty"`
|
View string `json:"view,omitempty"`
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
*dnssec.Status
|
*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
|
// 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"`
|
ParentZone string `json:"parent_zone,omitempty"`
|
||||||
ParentZoneID int64 `json:"parent_zone_id,omitempty"`
|
ParentZoneID int64 `json:"parent_zone_id,omitempty"`
|
||||||
ParentView string `json:"parent_view,omitempty"`
|
ParentView string `json:"parent_view,omitempty"`
|
||||||
@@ -574,4 +658,15 @@ type BlockRule struct {
|
|||||||
type ZoneLink struct {
|
type ZoneLink struct {
|
||||||
ID int64 `json:"id"`
|
ID int64 `json:"id"`
|
||||||
View string `json:"view"`
|
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,23 @@ func TestRoleTemplatesValid(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
+12
-3
@@ -21,11 +21,14 @@ const (
|
|||||||
ResAudit = "audit" // audit log and everyone's changesets
|
ResAudit = "audit" // audit log and everyone's changesets
|
||||||
ResStats = "stats" // query statistics
|
ResStats = "stats" // query statistics
|
||||||
ResBlocking = "blocking" // blocklists and block/allow rules
|
ResBlocking = "blocking" // blocklists and block/allow rules
|
||||||
|
// ResCerts is certificates for other hosts (jarl cert get), by the
|
||||||
|
// names in them. It is never part of "*": it hands out private keys.
|
||||||
|
ResCerts = "certificates"
|
||||||
ResAll = "*"
|
ResAll = "*"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Resources lists the resources in display order.
|
// 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.
|
// Access levels.
|
||||||
const (
|
const (
|
||||||
@@ -74,8 +77,11 @@ func ValidateGrant(g *model.Grant) error {
|
|||||||
for i, t := range g.Types {
|
for i, t := range g.Types {
|
||||||
g.Types[i] = strings.ToUpper(t)
|
g.Types[i] = strings.ToUpper(t)
|
||||||
}
|
}
|
||||||
if (len(g.Names) > 0 || len(g.Types) > 0) && g.Resource != ResRecords {
|
if len(g.Types) > 0 && g.Resource != ResRecords {
|
||||||
return fmt.Errorf("names and types filters only apply to the records resource")
|
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 {
|
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")
|
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 {
|
if g.Resource != ResAll && g.Resource != a.Resource {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
if g.Resource == ResAll && a.Resource == ResCerts {
|
||||||
|
return false // only an explicit certificates grant
|
||||||
|
}
|
||||||
if a.Write && g.Access != Write {
|
if a.Write && g.Access != Write {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import (
|
|||||||
"github.com/miekg/dns"
|
"github.com/miekg/dns"
|
||||||
|
|
||||||
"jarl/internal/auth"
|
"jarl/internal/auth"
|
||||||
|
"jarl/internal/encdns"
|
||||||
"jarl/internal/engine"
|
"jarl/internal/engine"
|
||||||
"jarl/internal/model"
|
"jarl/internal/model"
|
||||||
"jarl/internal/porkbun"
|
"jarl/internal/porkbun"
|
||||||
@@ -488,3 +489,34 @@ func TestCleanLegoError(t *testing.T) {
|
|||||||
t.Errorf("rate limit: %s", got)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -22,6 +22,7 @@ import (
|
|||||||
legolog "github.com/go-acme/lego/v4/log"
|
legolog "github.com/go-acme/lego/v4/log"
|
||||||
|
|
||||||
"jarl/internal/auth"
|
"jarl/internal/auth"
|
||||||
|
"jarl/internal/encdns"
|
||||||
"jarl/internal/engine"
|
"jarl/internal/engine"
|
||||||
"jarl/internal/model"
|
"jarl/internal/model"
|
||||||
"jarl/internal/porkbun"
|
"jarl/internal/porkbun"
|
||||||
@@ -51,6 +52,9 @@ type Config struct {
|
|||||||
PropagationTimeout time.Duration
|
PropagationTimeout time.Duration
|
||||||
// SkipPropagationCheck is !CheckPropagation (set by New).
|
// SkipPropagationCheck is !CheckPropagation (set by New).
|
||||||
SkipPropagationCheck bool
|
SkipPropagationCheck bool
|
||||||
|
// MaxPerUser is how many certificates a user may own (Ensure), unless
|
||||||
|
// the user has a limit of their own.
|
||||||
|
MaxPerUser int
|
||||||
}
|
}
|
||||||
|
|
||||||
// Manager keeps certificates current and serves jarld's own.
|
// Manager keeps certificates current and serves jarld's own.
|
||||||
@@ -131,6 +135,8 @@ type Input struct {
|
|||||||
Challenge *string `json:"challenge"`
|
Challenge *string `json:"challenge"`
|
||||||
KeyType *string `json:"key_type"`
|
KeyType *string `json:"key_type"`
|
||||||
Comment *string `json:"comment"`
|
Comment *string `json:"comment"`
|
||||||
|
// Owner sets the owning user (Ensure only).
|
||||||
|
Owner *int64 `json:"-"`
|
||||||
}
|
}
|
||||||
|
|
||||||
var nameRe = regexp.MustCompile(`^[a-z0-9][a-z0-9_-]{0,62}$`)
|
var nameRe = regexp.MustCompile(`^[a-z0-9][a-z0-9_-]{0,62}$`)
|
||||||
@@ -209,6 +215,9 @@ func (m *Manager) apply(c *model.Certificate, in Input, creating bool) error {
|
|||||||
if in.Comment != nil {
|
if in.Comment != nil {
|
||||||
c.Comment = *in.Comment
|
c.Comment = *in.Comment
|
||||||
}
|
}
|
||||||
|
if in.Owner != nil {
|
||||||
|
c.OwnerID = *in.Owner
|
||||||
|
}
|
||||||
switch c.Source {
|
switch c.Source {
|
||||||
case model.CertSourceACME:
|
case model.CertSourceACME:
|
||||||
if in.CertFile != nil && *in.CertFile != "" || in.KeyFile != nil && *in.KeyFile != "" {
|
if in.CertFile != nil && *in.CertFile != "" || in.KeyFile != nil && *in.KeyFile != "" {
|
||||||
@@ -294,6 +303,128 @@ func (m *Manager) Get(ctx context.Context, name string) (*model.Certificate, err
|
|||||||
return c, err
|
return c, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PEM returns a certificate's current chain and private key, for
|
||||||
|
// exporting it: from Jarl's store, or from the files of one you renew
|
||||||
|
// yourself.
|
||||||
|
func (m *Manager) PEM(ctx context.Context, name string) (*model.Certificate, []byte, []byte, error) {
|
||||||
|
c, err := m.Get(ctx, name)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, nil, err
|
||||||
|
}
|
||||||
|
if c.Source == model.CertSourceFile {
|
||||||
|
cert, err := os.ReadFile(c.CertFile)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, nil, fileErr(err)
|
||||||
|
}
|
||||||
|
key, err := os.ReadFile(c.KeyFile)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, nil, fileErr(err)
|
||||||
|
}
|
||||||
|
return c, cert, key, nil
|
||||||
|
}
|
||||||
|
if c.CertPEM == "" {
|
||||||
|
return nil, nil, nil, &InputError{Msg: "certificate " + name + " hasn't been obtained yet, so there is nothing to export"}
|
||||||
|
}
|
||||||
|
return c, []byte(c.CertPEM), []byte(c.KeyPEM), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ErrTaken is Ensure's answer for a name another user's certificate (or
|
||||||
|
// the administrators') has.
|
||||||
|
var ErrTaken = errors.New("certificate name taken")
|
||||||
|
|
||||||
|
// LimitFor is how many certificates u may own.
|
||||||
|
func (m *Manager) LimitFor(u *model.User) int {
|
||||||
|
if u != nil && u.CertLimit != nil {
|
||||||
|
return *u.CertLimit
|
||||||
|
}
|
||||||
|
return m.cfg.MaxPerUser
|
||||||
|
}
|
||||||
|
|
||||||
|
// EnsureResult is what Ensure did.
|
||||||
|
type EnsureResult struct {
|
||||||
|
Certificate *model.Certificate
|
||||||
|
Created bool // a new certificate
|
||||||
|
Changed bool // its names changed, so it is obtained again
|
||||||
|
// Ignored says what of the request an existing certificate keeps as
|
||||||
|
// it is (a different key type).
|
||||||
|
Ignored string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure is jarl cert get: the certificate name, owned by p's user, for
|
||||||
|
// domains. A new one is created (within the user's limit) and obtained
|
||||||
|
// at once. An existing one must be the user's: it is obtained again only
|
||||||
|
// when its names change (a name added or removed); otherwise it is
|
||||||
|
// returned as it is, renewed by the loop as usual. The caller checks that
|
||||||
|
// p may have these names.
|
||||||
|
func (m *Manager) Ensure(ctx context.Context, name string, domains []string, keyType string, p *auth.Principal) (*EnsureResult, error) {
|
||||||
|
owner := p.User
|
||||||
|
want, err := normDomains(domains)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
c, err := m.Get(ctx, name)
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, ErrNotFound):
|
||||||
|
all, err := m.List(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
n := 0
|
||||||
|
for _, o := range all {
|
||||||
|
if o.OwnerID == owner.ID {
|
||||||
|
n++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if limit := m.LimitFor(owner); n >= limit {
|
||||||
|
return nil, inputErr("user %s has %d certificates, its limit (an administrator can raise it: jarl user update %s --cert-limit N, or certificates.max_per_user in jarl.yaml)", owner.Username, n, owner.Username)
|
||||||
|
}
|
||||||
|
in := Input{Name: &name, Domains: &want, Owner: &owner.ID, Comment: model.Ptr("for " + owner.Username + " (jarl cert get)")}
|
||||||
|
if keyType != "" {
|
||||||
|
in.KeyType = &keyType
|
||||||
|
}
|
||||||
|
c, err = m.Create(ctx, in, p)
|
||||||
|
if errors.Is(err, ErrConflict) {
|
||||||
|
return nil, ErrTaken // created meanwhile
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if _, err := m.IssueAsync(ctx, name, p); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
c, err = m.Get(ctx, name)
|
||||||
|
return &EnsureResult{Certificate: c, Created: true}, err
|
||||||
|
case err != nil:
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if c.OwnerID != owner.ID && !(p.IsAdmin() && c.OwnerID == 0) {
|
||||||
|
return nil, ErrTaken
|
||||||
|
}
|
||||||
|
if c.Source != model.CertSourceACME {
|
||||||
|
return nil, inputErr("certificate %s is read from files on the server, so Jarl can't obtain it for you", name)
|
||||||
|
}
|
||||||
|
res := &EnsureResult{Certificate: c}
|
||||||
|
if keyType != "" && keyType != c.KeyType {
|
||||||
|
res.Ignored = "it keeps its key type " + c.KeyType + " (changing it means a new certificate: delete this one, or ask an administrator)"
|
||||||
|
}
|
||||||
|
have := slices.Clone(c.Domains)
|
||||||
|
slices.Sort(have)
|
||||||
|
sorted := slices.Clone(want)
|
||||||
|
slices.Sort(sorted)
|
||||||
|
if !slices.Equal(have, sorted) {
|
||||||
|
if c, err = m.Update(ctx, name, Input{Domains: &want}, p); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
res.Certificate, res.Changed = c, true
|
||||||
|
}
|
||||||
|
if res.Changed || c.CertPEM == "" && m.Issuing(name).IsZero() && c.Failures == 0 {
|
||||||
|
if _, err := m.IssueAsync(ctx, name, p); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return res, nil
|
||||||
|
}
|
||||||
|
|
||||||
// Create adds a certificate. A file-source certificate is read at once;
|
// Create adds a certificate. A file-source certificate is read at once;
|
||||||
// one Jarl obtains is left for Issue (or the background loop).
|
// one Jarl obtains is left for Issue (or the background loop).
|
||||||
func (m *Manager) Create(ctx context.Context, in Input, p *auth.Principal) (*model.Certificate, error) {
|
func (m *Manager) Create(ctx context.Context, in Input, p *auth.Principal) (*model.Certificate, error) {
|
||||||
@@ -336,6 +467,9 @@ func (m *Manager) Update(ctx context.Context, name string, in Input, p *auth.Pri
|
|||||||
if err := m.checkUses(ctx, c); err != nil {
|
if err := m.checkUses(ctx, c); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
if before.HasUse(model.CertUseDNS) && !c.HasUse(model.CertUseDNS) && m.namedUses(ctx, m.FilesDir(c.Name)) {
|
||||||
|
return nil, inputErr("named's configuration uses certificate %s (a tls statement names its files), so it keeps the dns use: take it out of named first (jarl encrypted-dns disable)", c.Name)
|
||||||
|
}
|
||||||
if c.Source == model.CertSourceFile && (c.CertFile != before.CertFile || c.KeyFile != before.KeyFile) {
|
if c.Source == model.CertSourceFile && (c.CertFile != before.CertFile || c.KeyFile != before.KeyFile) {
|
||||||
if _, err := m.readFiles(c); err != nil {
|
if _, err := m.readFiles(c); err != nil {
|
||||||
return nil, inputErr("%v", err)
|
return nil, inputErr("%v", err)
|
||||||
@@ -373,6 +507,9 @@ func (m *Manager) Delete(ctx context.Context, name string, p *auth.Principal) er
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if c.HasUse(model.CertUseDNS) && m.namedUses(ctx, m.FilesDir(c.Name)) {
|
||||||
|
return inputErr("named's configuration uses certificate %s (a tls statement names its files): take it out first (jarl encrypted-dns disable, or remove the tls statement)", c.Name)
|
||||||
|
}
|
||||||
if err := m.store.DeleteCertificate(ctx, c.ID); err != nil {
|
if err := m.store.DeleteCertificate(ctx, c.ID); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -760,6 +897,40 @@ func (m *Manager) Start(ctx context.Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
m.followDir(ctx, all)
|
||||||
|
}
|
||||||
|
|
||||||
|
// followDir points the tls statements encrypted DNS wrote (jarl-NAME) at
|
||||||
|
// certificates.dir when it has changed: Start has just written the
|
||||||
|
// certificates there, and named would otherwise keep serving the old
|
||||||
|
// copies, which renewals no longer update.
|
||||||
|
func (m *Manager) followDir(ctx context.Context, all []*model.Certificate) {
|
||||||
|
st, err := m.store.LoadState(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var reqs []engine.Request
|
||||||
|
for _, b := range encdns.TLSBlocks(st) {
|
||||||
|
name, ok := strings.CutPrefix(b.Name, encdns.Prefix)
|
||||||
|
if !ok || filepath.Dir(b.CertFile) == m.FilesDir(name) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
i := slices.IndexFunc(all, func(c *model.Certificate) bool { return c.Name == name })
|
||||||
|
if i < 0 || !all[i].HasUse(model.CertUseDNS) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(filepath.Join(m.FilesDir(name), ChainFile)); err != nil {
|
||||||
|
continue // not written there (yet)
|
||||||
|
}
|
||||||
|
reqs = append(reqs, engine.NewRequest(engine.OpOptionUpdate, b.OptionID, engine.OptionInput{Value: model.Ptr(encdns.TLSValue(name, m.cfg.Dir))}))
|
||||||
|
m.log.Info("certificates.dir changed: pointing named at the new files", "tls", b.Name, "was", filepath.Dir(b.CertFile), "now", m.FilesDir(name))
|
||||||
|
}
|
||||||
|
if len(reqs) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, err := m.eng.Submit(ctx, reqs, auth.System, "jarld", "certificates.dir changed: tls statements follow it", engine.ApplyOptions{}); err != nil {
|
||||||
|
m.log.Error("pointing named at the moved certificate files failed; run 'jarl encrypted-dns setup' again", "err", err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Run renews certificates when they are due and rereads file-source ones
|
// Run renews certificates when they are due and rereads file-source ones
|
||||||
|
|||||||
@@ -51,6 +51,10 @@ type Certificates struct {
|
|||||||
// HTTPS): one directory per certificate, readable by bind.file_group.
|
// HTTPS): one directory per certificate, readable by bind.file_group.
|
||||||
// "" is <bind.config_dir>/tls.
|
// "" is <bind.config_dir>/tls.
|
||||||
Dir string `yaml:"dir"`
|
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).
|
// Metrics configures the Prometheus endpoint /metrics (docs/metrics.md).
|
||||||
@@ -364,6 +368,10 @@ type Stats struct {
|
|||||||
// per-zone counts) from named's response log. Without it only named's
|
// per-zone counts) from named's response log. Without it only named's
|
||||||
// own counters are collected.
|
// own counters are collected.
|
||||||
QueryLog bool `yaml:"query_log"`
|
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
|
// Database is the statistics database, kept apart from Jarl's
|
||||||
// configuration database.
|
// configuration database.
|
||||||
Database string `yaml:"database"`
|
Database string `yaml:"database"`
|
||||||
@@ -391,6 +399,9 @@ type StatsRetention struct {
|
|||||||
Minute Duration `yaml:"minute"`
|
Minute Duration `yaml:"minute"`
|
||||||
Hour Duration `yaml:"hour"`
|
Hour Duration `yaml:"hour"`
|
||||||
Day Duration `yaml:"day"`
|
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
|
// Duration is a time.Duration written as "15s" or "12h" in YAML. Days
|
||||||
@@ -451,14 +462,16 @@ func Default() *Config {
|
|||||||
},
|
},
|
||||||
Stats: Stats{
|
Stats: Stats{
|
||||||
QueryLog: true,
|
QueryLog: true,
|
||||||
|
RateLimitLog: true,
|
||||||
Database: "/var/lib/jarl/stats.db",
|
Database: "/var/lib/jarl/stats.db",
|
||||||
StatisticsChannel: "127.0.0.1:8058",
|
StatisticsChannel: "127.0.0.1:8058",
|
||||||
LogFile: "/var/log/named/jarl-responses.log",
|
LogFile: "/var/log/named/jarl-responses.log",
|
||||||
LogSize: "20m",
|
LogSize: "20m",
|
||||||
LogVersions: 2,
|
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,
|
TopN: 100,
|
||||||
},
|
},
|
||||||
|
Certificates: Certificates{MaxPerUser: 10},
|
||||||
ACME: ACME{HTTPReq: HTTPReq{RequireHTTPS: true}, Account: ACMEAccount{Directory: "letsencrypt"},
|
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},
|
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},
|
Blocking: Blocking{Refresh: Duration(24 * time.Hour), MaxDownloadMB: 64, MaxEntries: 1_000_000},
|
||||||
@@ -599,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) {
|
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")
|
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 {
|
if s.TopN < 10 || s.TopN > 10000 {
|
||||||
return fmt.Errorf("top_n must be between 10 and 10000")
|
return fmt.Errorf("top_n must be between 10 and 10000")
|
||||||
}
|
}
|
||||||
@@ -736,6 +752,10 @@ stats:
|
|||||||
# Per-query data (top names and clients, per view and zone). false
|
# Per-query data (top names and clients, per view and zone). false
|
||||||
# collects only named's own counters, with no logging overhead.
|
# collects only named's own counters, with no logging overhead.
|
||||||
query_log: true
|
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
|
database: /var/lib/jarl/stats.db
|
||||||
statistics_channel: 127.0.0.1:8058
|
statistics_channel: 127.0.0.1:8058
|
||||||
log_file: /var/log/named/jarl-responses.log
|
log_file: /var/log/named/jarl-responses.log
|
||||||
@@ -745,6 +765,7 @@ stats:
|
|||||||
minute: 7d # per-minute counters
|
minute: 7d # per-minute counters
|
||||||
hour: 90d # hourly counters and top lists
|
hour: 90d # hourly counters and top lists
|
||||||
day: 730d # daily 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
|
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
|
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
|
exclude_clients: [] # e.g. [127.0.0.1] to ignore local monitoring
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
|
|
||||||
"github.com/miekg/dns"
|
"github.com/miekg/dns"
|
||||||
"golang.org/x/net/idna"
|
"golang.org/x/net/idna"
|
||||||
|
"golang.org/x/net/publicsuffix"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Root is the root domain name.
|
// Root is the root domain name.
|
||||||
@@ -305,6 +306,28 @@ func EmailFromMailbox(rname string) string {
|
|||||||
return local + "@" + strings.Join(labels[1:], ".")
|
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.
|
// IsReverse reports whether name is in in-addr.arpa or ip6.arpa.
|
||||||
func IsReverse(name string) bool {
|
func IsReverse(name string) bool {
|
||||||
return IsSubdomain(name, "in-addr.arpa.") || IsSubdomain(name, "ip6.arpa.") || IsSubdomain(name, "ip6.int.")
|
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
|
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)
|
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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -25,7 +25,8 @@ func DiffOps(from, to *model.State) []model.Op {
|
|||||||
s := to.Settings
|
s := to.Settings
|
||||||
add(OpSettingsUpdate, 0, 0, SettingsInput{DefaultTTL: &s.DefaultTTL, Nameservers: model.Ptr(append([]string{}, s.Nameservers...)), RName: &s.RName,
|
add(OpSettingsUpdate, 0, 0, SettingsInput{DefaultTTL: &s.DefaultTTL, Nameservers: model.Ptr(append([]string{}, s.Nameservers...)), RName: &s.RName,
|
||||||
Refresh: &s.Refresh, Retry: &s.Retry, Expire: &s.Expire, NegativeTTL: &s.NegativeTTL, SerialPolicy: &s.SerialPolicy,
|
Refresh: &s.Refresh, Retry: &s.Retry, Expire: &s.Expire, NegativeTTL: &s.NegativeTTL, SerialPolicy: &s.SerialPolicy,
|
||||||
Exposure: &s.Exposure, Acknowledged: model.Ptr(slices.Clone(s.Acknowledged))}, "restore settings")
|
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) {
|
for _, k := range sortedKeys(to.Keys) {
|
||||||
@@ -50,7 +51,8 @@ func DiffOps(from, to *model.State) []model.Op {
|
|||||||
n := to.Views[k]
|
n := to.Views[k]
|
||||||
in := ViewInput{Name: &n.Name, MatchClients: model.Ptr(append([]string{}, n.MatchClients...)),
|
in := ViewInput{Name: &n.Name, MatchClients: model.Ptr(append([]string{}, n.MatchClients...)),
|
||||||
MatchDestinations: model.Ptr(append([]string{}, n.MatchDestinations...)), Comment: &n.Comment, Position: model.Ptr(n.Position),
|
MatchDestinations: model.Ptr(append([]string{}, n.MatchDestinations...)), Comment: &n.Comment, Position: model.Ptr(n.Position),
|
||||||
Exposure: model.Ptr(n.Exposure), Acknowledged: model.Ptr(slices.Clone(n.Acknowledged))}
|
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 {
|
if n.MatchRecursiveOnly != nil {
|
||||||
in.MatchRecursiveOnly = model.Some(*n.MatchRecursiveOnly)
|
in.MatchRecursiveOnly = model.Some(*n.MatchRecursiveOnly)
|
||||||
} else {
|
} else {
|
||||||
@@ -66,7 +68,7 @@ func DiffOps(from, to *model.State) []model.Op {
|
|||||||
n := to.Zones[k]
|
n := to.Zones[k]
|
||||||
s := n.SOA
|
s := n.SOA
|
||||||
in := ZoneInput{Name: &n.Name, ViewID: &n.ViewID, Type: &n.Type, InView: &n.InView, File: &n.File,
|
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, Disabled: &n.Disabled, LinkGroup: model.Ptr(n.LinkGroup)}
|
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 {
|
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}
|
in.SOA = &SOAInput{MName: &s.MName, RName: &s.RName, Serial: &s.Serial, Refresh: &s.Refresh, Retry: &s.Retry, Expire: &s.Expire, Minimum: &s.Minimum}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -285,7 +285,7 @@ func (e *Engine) expandAll(ctx context.Context, work *model.State, reqs []Reques
|
|||||||
var warnings []string
|
var warnings []string
|
||||||
c := &applyCtx{p: p, now: e.Now()}
|
c := &applyCtx{p: p, now: e.Now()}
|
||||||
for i, req := range reqs {
|
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)
|
more, err := x.expand(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if ee, ok := AsError(err); ok && len(reqs) > 1 {
|
if ee, ok := AsError(err); ok && len(reqs) > 1 {
|
||||||
@@ -330,6 +330,9 @@ func (e *Engine) Stage(ctx context.Context, csID int64, reqs []Request, p *auth.
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
if len(ops) == 0 {
|
||||||
|
return nil, errf(KindBadRequest, "nothing to change")
|
||||||
|
}
|
||||||
intro := validate.Introduced(before, e.validate(work))
|
intro := validate.Introduced(before, e.validate(work))
|
||||||
if validate.HasErrors(intro) {
|
if validate.HasErrors(intro) {
|
||||||
return nil, &Error{Kind: KindValidation, Message: describeErrors(intro), Problems: validate.Errors(intro)}
|
return nil, &Error{Kind: KindValidation, Message: describeErrors(intro), Problems: validate.Errors(intro)}
|
||||||
|
|||||||
@@ -462,3 +462,39 @@ func TestParseWarningsReachTheResult(t *testing.T) {
|
|||||||
t.Errorf("ops = %+v", res.Ops)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+434
-97
@@ -5,6 +5,8 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
|
"reflect"
|
||||||
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -27,6 +29,15 @@ type expander struct {
|
|||||||
p *auth.Principal
|
p *auth.Principal
|
||||||
now time.Time
|
now time.Time
|
||||||
warnings []string
|
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) {
|
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 == "" {
|
if zone == "" {
|
||||||
return nil, errf(KindBadRequest, "the zone is missing (zone_id or 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.
|
// FindZoneRef finds a zone by name and optional view name. Without a
|
||||||
func FindZoneRef(st *model.State, zone, view string) (*model.Zone, error) {
|
// 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)
|
name, err := ParseZoneName(zone)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, errf(KindBadRequest, "%v", err)
|
return nil, errf(KindBadRequest, "%v", err)
|
||||||
@@ -86,6 +99,16 @@ func FindZoneRef(st *model.State, zone, view string) (*model.Zone, error) {
|
|||||||
return z, nil
|
return z, nil
|
||||||
}
|
}
|
||||||
zs := st.ZonesNamed(name)
|
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) {
|
switch len(zs) {
|
||||||
case 0:
|
case 0:
|
||||||
return nil, errf(KindNotFound, "zone %s does not exist", dnsname.Display(name))
|
return nil, errf(KindNotFound, "zone %s does not exist", dnsname.Display(name))
|
||||||
@@ -131,13 +154,20 @@ func dateSerial(now time.Time) uint32 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (x *expander) expand(req Request) ([]model.Op, error) {
|
func (x *expander) expand(req Request) ([]model.Op, error) {
|
||||||
|
x.ptrForced, x.sharing = nil, nil
|
||||||
ops, err := x.expandOne(req)
|
ops, err := x.expandOne(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
switch req.Op {
|
switch req.Op {
|
||||||
case OpRecordCreate, OpRecordUpdate, OpRecordDelete, OpRRSetReplace, OpRecordDisable, OpRecordEnable:
|
case OpRecordCreate, OpRecordUpdate, OpRecordDelete, OpRRSetReplace, OpRecordDisable, OpRecordEnable:
|
||||||
// Changes to linked zones reach the other zones of the link.
|
// 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 x.linkSync(ops)
|
||||||
}
|
}
|
||||||
return ops, nil
|
return ops, nil
|
||||||
@@ -151,6 +181,10 @@ func (x *expander) expandOne(req Request) ([]model.Op, error) {
|
|||||||
return x.zoneUnlink(req)
|
return x.zoneUnlink(req)
|
||||||
case OpZoneCreate:
|
case OpZoneCreate:
|
||||||
return x.zoneCreate(req)
|
return x.zoneCreate(req)
|
||||||
|
case OpZoneFillPTR:
|
||||||
|
return x.zoneFillPTR(req)
|
||||||
|
case OpReverseDismiss, OpReverseUndismiss:
|
||||||
|
return x.reverseDismiss(req, req.Op == OpReverseDismiss)
|
||||||
case OpZoneUpdate, OpZoneDelete:
|
case OpZoneUpdate, OpZoneDelete:
|
||||||
z := x.st.Zones[req.ID]
|
z := x.st.Zones[req.ID]
|
||||||
if z == nil {
|
if z == nil {
|
||||||
@@ -185,6 +219,11 @@ func (x *expander) expandOne(req Request) ([]model.Op, error) {
|
|||||||
if in.LinkWith != "" {
|
if in.LinkWith != "" {
|
||||||
return nil, errf(KindBadRequest, "link_with is for new zones; link existing ones with zone.link (jarl zone link)")
|
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
|
return ops, nil
|
||||||
case OpRecordCreate:
|
case OpRecordCreate:
|
||||||
@@ -375,6 +414,7 @@ func (x *expander) zoneCreate(req Request) ([]model.Op, error) {
|
|||||||
return nil, errf(KindBadRequest, "%v", err)
|
return nil, errf(KindBadRequest, "%v", err)
|
||||||
}
|
}
|
||||||
in.Type = &zt
|
in.Type = &zt
|
||||||
|
viewGiven := in.View != nil || in.ViewID != nil
|
||||||
if in.View != nil {
|
if in.View != nil {
|
||||||
if *in.View != "" {
|
if *in.View != "" {
|
||||||
v := x.st.ViewByName(*in.View)
|
v := x.st.ViewByName(*in.View)
|
||||||
@@ -385,6 +425,79 @@ func (x *expander) zoneCreate(req Request) ([]model.Op, error) {
|
|||||||
}
|
}
|
||||||
in.View = nil
|
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
|
nameservers := in.Nameservers
|
||||||
records := in.Records
|
records := in.Records
|
||||||
cloneFrom := in.CloneFrom
|
cloneFrom := in.CloneFrom
|
||||||
@@ -602,7 +715,45 @@ func (x *expander) zoneCreate(req Request) ([]model.Op, error) {
|
|||||||
r.in.ZoneID = ids[0]
|
r.in.ZoneID = ids[0]
|
||||||
ops = append(ops, newOp(OpRecordCreate, ids[1+i], 0, r.in, r.summary))
|
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 {
|
func deref(s *string) string {
|
||||||
@@ -636,19 +787,218 @@ func addrOf(p *rr.Parsed) (netip.Addr, bool) {
|
|||||||
return netip.Addr{}, false
|
return netip.Addr{}, false
|
||||||
}
|
}
|
||||||
|
|
||||||
// ptrOps returns operations creating the PTR record for an address record.
|
// PTR records follow address records per view (docs/reverse.md): after a
|
||||||
func (x *expander) ptrCreate(z *model.Zone, owner string, p *rr.Parsed) ([]model.Op, error) {
|
// request's operations (and those keeping linked zones in step), ptrSync
|
||||||
addr, ok := addrOf(p)
|
// compares each touched A or AAAA set before and after, in every view it
|
||||||
if !ok {
|
// is in, and changes the PTR records of that view's reverse zone to match.
|
||||||
return nil, errf(KindBadRequest, "ptr only applies to A and AAAA records")
|
// 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)
|
x.ptrForced[rrKey{z.ID, name, typ}] = true
|
||||||
|
for _, o := range x.st.LinkedWith(z) {
|
||||||
|
x.ptrForced[rrKey{o.ID, name, typ}] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 rz == nil {
|
||||||
x.warnings = append(x.warnings, fmt.Sprintf("no reverse zone for %s in this view, so no PTR record was added", addr))
|
if force && is == addrEnabled {
|
||||||
return nil, nil
|
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)))
|
||||||
}
|
}
|
||||||
for _, r := range x.st.ZoneRecords(rz.ID) {
|
continue
|
||||||
if r.Name == ptrName && r.Type == "PTR" {
|
}
|
||||||
|
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 strings.EqualFold(r.Data, owner) {
|
||||||
if r.Disabled {
|
if r.Disabled {
|
||||||
in := RecordInput{Disabled: model.Ptr(false)}
|
in := RecordInput{Disabled: model.Ptr(false)}
|
||||||
@@ -656,38 +1006,28 @@ func (x *expander) ptrCreate(z *model.Zone, owner string, p *rr.Parsed) ([]model
|
|||||||
}
|
}
|
||||||
return nil, nil // already there
|
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))
|
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)
|
ids, err := x.ids(1)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
in := RecordInput{ZoneID: rz.ID, Name: model.Ptr(ptrName), Type: model.Ptr("PTR"), Data: model.Ptr(owner)}
|
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
|
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 {
|
func enableWord(disabled bool) string {
|
||||||
if disabled {
|
if disabled {
|
||||||
return "disable"
|
return "disable"
|
||||||
@@ -695,19 +1035,6 @@ func enableWord(disabled bool) string {
|
|||||||
return "enable"
|
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).
|
// parseWarnings keeps what parsing noticed (e.g. a name read as absolute).
|
||||||
// The stored operation holds the canonical data, so applying it doesn't
|
// The stored operation holds the canonical data, so applying it doesn't
|
||||||
// notice again.
|
// notice again.
|
||||||
@@ -734,7 +1061,12 @@ func (x *expander) recordCreate(req Request) ([]model.Op, error) {
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
x.parseWarnings(owner, p)
|
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.ZoneID, in.Zone, in.View, in.PTR = z.ID, "", "", false
|
||||||
in.Name, in.Type, in.Data = &owner, &p.Type, &p.Data
|
in.Name, in.Type, in.Data = &owner, &p.Type, &p.Data
|
||||||
ids, err := x.ids(1)
|
ids, err := x.ids(1)
|
||||||
@@ -757,19 +1089,12 @@ func (x *expander) recordCreate(req Request) ([]model.Op, error) {
|
|||||||
}
|
}
|
||||||
in.SharedID = &ids[0]
|
in.SharedID = &ids[0]
|
||||||
}
|
}
|
||||||
} else if in.Local != nil && *in.Local {
|
} else if in.Local != nil && !*in.Local {
|
||||||
return nil, errf(KindBadRequest, "zone %s isn't linked with other views, so its records are local anyway", zoneDesc(x.st, z))
|
return nil, notLinked(x.st, z)
|
||||||
}
|
}
|
||||||
|
// local on a zone that isn't linked: its records are local anyway.
|
||||||
in.Local = nil
|
in.Local = nil
|
||||||
ops := []model.Op{newOp(OpRecordCreate, ids[0], 0, in, summary)}
|
return []model.Op{newOp(OpRecordCreate, ids[0], 0, in, summary)}, nil
|
||||||
if ptr {
|
|
||||||
more, err := x.ptrCreate(z, owner, p)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
ops = append(ops, more...)
|
|
||||||
}
|
|
||||||
return ops, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (x *expander) recordUpdate(req Request) ([]model.Op, error) {
|
func (x *expander) recordUpdate(req Request) ([]model.Op, error) {
|
||||||
@@ -790,10 +1115,10 @@ func (x *expander) recordUpdate(req Request) ([]model.Op, error) {
|
|||||||
}
|
}
|
||||||
var pre []model.Op
|
var pre []model.Op
|
||||||
if in.Local != nil {
|
if in.Local != nil {
|
||||||
if z.LinkGroup == 0 {
|
if z.LinkGroup == 0 && !*in.Local {
|
||||||
return nil, errf(KindBadRequest, "zone %s isn't linked with other views, so its records are local anyway", zoneDesc(x.st, z))
|
return nil, notLinked(x.st, z)
|
||||||
}
|
}
|
||||||
if want := *in.Local; want != (r.SharedID == 0) {
|
if want := *in.Local; z.LinkGroup != 0 && want != (r.SharedID == 0) {
|
||||||
// The whole set of this name and type changes sides.
|
// The whole set of this name and type changes sides.
|
||||||
for _, o := range x.st.RRset(z.ID, r.Name, r.Type) {
|
for _, o := range x.st.RRset(z.ID, r.Name, r.Type) {
|
||||||
id := int64(0)
|
id := int64(0)
|
||||||
@@ -804,10 +1129,11 @@ func (x *expander) recordUpdate(req Request) ([]model.Op, error) {
|
|||||||
}
|
}
|
||||||
pre = append(pre, newOp(OpRecordUpdate, o.ID, o.Version, RecordInput{SharedID: model.Ptr(id)}, what))
|
pre = append(pre, newOp(OpRecordUpdate, o.ID, o.Version, RecordInput{SharedID: model.Ptr(id)}, what))
|
||||||
}
|
}
|
||||||
if !want {
|
|
||||||
// Sharing a version makes it every view's.
|
|
||||||
pre = append(pre, x.dropOverrides(z, r.Name, r.Type)...)
|
|
||||||
}
|
}
|
||||||
|
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
|
in.Local = nil
|
||||||
if string(mustJSON(in)) == "{}" {
|
if string(mustJSON(in)) == "{}" {
|
||||||
@@ -885,29 +1211,26 @@ func (x *expander) recordUpdate(req Request) ([]model.Op, error) {
|
|||||||
if len(pre) > 0 {
|
if len(pre) > 0 {
|
||||||
base = 0 // the record was just updated by pre
|
base = 0 // the record was just updated by pre
|
||||||
}
|
}
|
||||||
ops := append(pre, newOp(OpRecordUpdate, r.ID, base, in, summary))
|
ops := pre
|
||||||
disabled := r.Disabled
|
if string(mustJSON(in)) != "{}" || !ptr {
|
||||||
if in.Disabled != nil {
|
ops = append(ops, newOp(OpRecordUpdate, r.ID, base, in, summary))
|
||||||
disabled = *in.Disabled
|
|
||||||
}
|
}
|
||||||
moved := p.Data != r.Data || owner != r.Name
|
if ptr {
|
||||||
if ptr && moved {
|
// 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 {
|
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 _, ok := addrOf(old); ok {
|
||||||
}
|
isAddr = true
|
||||||
if !disabled {
|
x.ptrForce(z, r.Name, r.Type)
|
||||||
more, err := x.ptrCreate(z, owner, p)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
ops = append(ops, more...)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if ptr && !moved && toggled {
|
if !isAddr {
|
||||||
if _, ok := addrOf(p); !ok {
|
|
||||||
return nil, errf(KindBadRequest, "ptr only applies to A and AAAA records")
|
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
|
return ops, nil
|
||||||
}
|
}
|
||||||
@@ -931,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))}
|
ops := []model.Op{newOp(OpRecordDelete, r.ID, base, nil, fmt.Sprintf("delete %s %s %s", r.Name, r.Type, r.Data))}
|
||||||
if in.PTR {
|
if in.PTR {
|
||||||
z := x.st.Zones[r.ZoneID]
|
x.ptrForce(x.st.Zones[r.ZoneID], r.Name, r.Type)
|
||||||
if p, err := rr.Parse(z.Name, r.Name, r.Type, r.Data); err == nil {
|
|
||||||
ops = append(ops, x.ptrDelete(z, r.Name, p)...)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return ops, nil
|
return ops, nil
|
||||||
}
|
}
|
||||||
@@ -970,14 +1290,12 @@ func (x *expander) recordDelete(req Request) ([]model.Op, error) {
|
|||||||
if in.MissingOK {
|
if in.MissingOK {
|
||||||
what += " (if present)"
|
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.ZoneID, in.Zone, in.View, in.PTR = z.ID, "", "", false
|
||||||
in.Name, in.Type = &owner, &typ
|
in.Name, in.Type = &owner, &typ
|
||||||
ops := []model.Op{newOp(OpRecordDelete, 0, 0, in, "delete "+what)}
|
return []model.Op{newOp(OpRecordDelete, 0, 0, in, "delete "+what)}, nil
|
||||||
if ptr && parsed != nil {
|
|
||||||
ops = append(ops, x.ptrDelete(z, owner, parsed)...)
|
|
||||||
}
|
|
||||||
return ops, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// recordSetDisabled expands record.disable and record.enable: the record
|
// recordSetDisabled expands record.disable and record.enable: the record
|
||||||
@@ -1097,8 +1415,8 @@ func (x *expander) rrsetReplace(req Request) ([]model.Op, error) {
|
|||||||
// Explicitly shared: this version becomes every view's.
|
// Explicitly shared: this version becomes every view's.
|
||||||
pre = x.dropOverrides(z, owner, typ)
|
pre = x.dropOverrides(z, owner, typ)
|
||||||
}
|
}
|
||||||
} else if in.Local != nil && *in.Local {
|
} else if in.Local != nil && !*in.Local {
|
||||||
return nil, errf(KindBadRequest, "zone %s isn't linked with other views, so its records are local anyway", zoneDesc(x.st, z))
|
return nil, notLinked(x.st, z)
|
||||||
}
|
}
|
||||||
in.Local = nil
|
in.Local = nil
|
||||||
if len(canon) > 0 {
|
if len(canon) > 0 {
|
||||||
@@ -1300,3 +1618,22 @@ func firstNonEmptyStr(a, b string) string {
|
|||||||
}
|
}
|
||||||
return b
|
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)
|
||||||
|
}
|
||||||
@@ -59,8 +59,25 @@ const (
|
|||||||
// into zone and record updates.
|
// into zone and record updates.
|
||||||
OpZoneLink = "zone.link"
|
OpZoneLink = "zone.link"
|
||||||
OpZoneUnlink = "zone.unlink"
|
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
|
// AckInput acknowledges (or un-acknowledges) an exposure finding of a view
|
||||||
// (by ID or name), or of the server (neither given).
|
// (by ID or name), or of the server (neither given).
|
||||||
type AckInput struct {
|
type AckInput struct {
|
||||||
@@ -142,6 +159,15 @@ type ZoneInput struct {
|
|||||||
// name in this view, copying its shared records (or all of its
|
// name in this view, copying its shared records (or all of its
|
||||||
// records, when it isn't linked yet).
|
// records, when it isn't linked yet).
|
||||||
LinkWith string `json:"link_with,omitempty"`
|
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
|
// Create only: name servers for the apex NS records (default: the
|
||||||
// configured default name servers), additional records, and a zone to
|
// configured default name servers), additional records, and a zone to
|
||||||
@@ -174,7 +200,9 @@ type RecordInput struct {
|
|||||||
// Disabled keeps the record in Jarl but out of the zone.
|
// Disabled keeps the record in Jarl but out of the zone.
|
||||||
Disabled *bool `json:"disabled,omitempty"`
|
Disabled *bool `json:"disabled,omitempty"`
|
||||||
// PTR also creates (or deletes, disables or enables) the matching PTR
|
// 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"`
|
PTR bool `json:"ptr,omitempty"`
|
||||||
// MissingOK makes a delete-by-match succeed when nothing matches.
|
// MissingOK makes a delete-by-match succeed when nothing matches.
|
||||||
MissingOK bool `json:"missing_ok,omitempty"`
|
MissingOK bool `json:"missing_ok,omitempty"`
|
||||||
@@ -199,6 +227,13 @@ type LinkInput struct {
|
|||||||
With []string `json:"with,omitempty"`
|
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.
|
// RRSetInput replaces all records of one name and type.
|
||||||
type RRSetInput struct {
|
type RRSetInput struct {
|
||||||
ZoneID int64 `json:"zone_id,omitempty"`
|
ZoneID int64 `json:"zone_id,omitempty"`
|
||||||
@@ -235,6 +270,11 @@ type ViewInput struct {
|
|||||||
// Exposure is "auto" (or ""), "public" or "private" (docs/exposure.md).
|
// Exposure is "auto" (or ""), "public" or "private" (docs/exposure.md).
|
||||||
Exposure *string `json:"exposure,omitempty"`
|
Exposure *string `json:"exposure,omitempty"`
|
||||||
Acknowledged *[]model.Acknowledgement `json:"acknowledged,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.
|
// ReorderInput sets the order of views.
|
||||||
@@ -281,6 +321,11 @@ type SettingsInput struct {
|
|||||||
// Exposure and Acknowledged are the server's (docs/exposure.md).
|
// Exposure and Acknowledged are the server's (docs/exposure.md).
|
||||||
Exposure *string `json:"exposure,omitempty"`
|
Exposure *string `json:"exposure,omitempty"`
|
||||||
Acknowledged *[]model.Acknowledgement `json:"acknowledged,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.
|
// DeleteInput carries options for delete operations.
|
||||||
|
|||||||
+53
-15
@@ -106,8 +106,8 @@ func affectedKeys(pre, post *model.State, ops []model.Op) []rrKey {
|
|||||||
//
|
//
|
||||||
// - z overrides it (local records): z's shared copies go; the others
|
// - z overrides it (local records): z's shared copies go; the others
|
||||||
// keep theirs.
|
// keep theirs.
|
||||||
// - z's override was just removed: z gets the shared records back from
|
// - z's override was just removed: z goes without the name and type,
|
||||||
// a zone that has them.
|
// and the other zones' shared copies become local to each.
|
||||||
// - otherwise z's shared records are the truth: every zone that doesn't
|
// - otherwise z's shared records are the truth: every zone that doesn't
|
||||||
// override the name and type gets the same (created, updated or
|
// override the name and type gets the same (created, updated or
|
||||||
// deleted to match).
|
// deleted to match).
|
||||||
@@ -123,16 +123,10 @@ func (x *expander) syncKey(st *model.State, z *model.Zone, name, typ string) ([]
|
|||||||
}
|
}
|
||||||
truth := st.SharedRecords(z.ID, name, typ)
|
truth := st.SharedRecords(z.ID, name, typ)
|
||||||
members := st.LinkedWith(z)
|
members := st.LinkedWith(z)
|
||||||
if len(truth) == 0 && x.st.HasLocal(z.ID, name, typ) {
|
if len(truth) == 0 && x.st.HasLocal(z.ID, name, typ) && x.sharing[rrKey{z.ID, name, typ}] {
|
||||||
// The override was removed: back to the shared records.
|
// Another view's version was shared (dropOverrides): z gets it.
|
||||||
for _, o := range members {
|
for _, o := range members {
|
||||||
if st.HasLocal(o.ID, name, typ) {
|
if src := st.SharedRecords(o.ID, name, typ); len(src) > 0 && !st.HasLocal(o.ID, name, typ) {
|
||||||
continue
|
|
||||||
}
|
|
||||||
src := st.SharedRecords(o.ID, name, typ)
|
|
||||||
if len(src) == 0 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
for _, r := range src {
|
for _, r := range src {
|
||||||
op, err := x.copyRecord(st, z, r, "linked: "+name+" "+typ+" is shared again in "+where(z)+": add "+r.Data)
|
op, err := x.copyRecord(st, z, r, "linked: "+name+" "+typ+" is shared again in "+where(z)+": add "+r.Data)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -142,6 +136,28 @@ func (x *expander) syncKey(st *model.State, z *model.Zone, name, typ string) ([]
|
|||||||
}
|
}
|
||||||
break
|
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
|
return ops, nil
|
||||||
}
|
}
|
||||||
for _, o := range members {
|
for _, o := range members {
|
||||||
@@ -440,13 +456,31 @@ func (x *expander) unlinkOps(m *model.Zone, summary string) []model.Op {
|
|||||||
|
|
||||||
// localFor works out whether a new record (or set) in zone z is local:
|
// 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.
|
// 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) {
|
func (x *expander) localFor(z *model.Zone, name, typ string, want *bool) (bool, error) {
|
||||||
has := x.st.HasLocal(z.ID, name, typ)
|
if want != nil {
|
||||||
if want == nil {
|
|
||||||
return has, nil
|
|
||||||
}
|
|
||||||
return *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
|
// shareAllOps returns the operations that start a link with zone z: it
|
||||||
// joins group and all its records become shared.
|
// joins group and all its records become shared.
|
||||||
@@ -468,6 +502,10 @@ func (x *expander) dropOverrides(z *model.Zone, name, typ string) []model.Op {
|
|||||||
for _, o := range x.st.LinkedWith(z) {
|
for _, o := range x.st.LinkedWith(z) {
|
||||||
for _, r := range x.st.RRset(o.ID, name, typ) {
|
for _, r := range x.st.RRset(o.ID, name, typ) {
|
||||||
if r.SharedID == 0 {
|
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))))
|
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))))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -130,10 +130,15 @@ func TestLinkedZones(t *testing.T) {
|
|||||||
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") {
|
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"))
|
t.Errorf("shared set with an override elsewhere: internal %q external %q", env.view("internal"), env.view("external"))
|
||||||
}
|
}
|
||||||
// Removing the override brings the shared records back.
|
// 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{}}))
|
env.submit(env.admin, NewRequest(OpRRSetReplace, 0, RRSetInput{Zone: "example.com", View: "internal", Name: "www", Type: "A", Data: []string{}}))
|
||||||
if !slices.Contains(env.view("internal"), "www A 192.0.2.11 S") {
|
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", env.view("internal"))
|
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.
|
// Make a shared set local, then share another view's version.
|
||||||
@@ -285,3 +290,160 @@ func TestLinkExisting(t *testing.T) {
|
|||||||
t.Errorf("shared from internal: external %q", env.view("external"))
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -268,6 +269,9 @@ func setZoneFields(st *model.State, z *model.Zone, in *ZoneInput, creating bool)
|
|||||||
if in.LinkGroup != nil {
|
if in.LinkGroup != nil {
|
||||||
z.LinkGroup = *in.LinkGroup
|
z.LinkGroup = *in.LinkGroup
|
||||||
}
|
}
|
||||||
|
if in.AutoPTR != nil {
|
||||||
|
z.AutoPTR = *in.AutoPTR
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -805,6 +809,12 @@ func setViewFields(v *model.View, in *ViewInput) error {
|
|||||||
if in.Acknowledged != nil {
|
if in.Acknowledged != nil {
|
||||||
v.Acknowledged = cleanAcks(*in.Acknowledged)
|
v.Acknowledged = cleanAcks(*in.Acknowledged)
|
||||||
}
|
}
|
||||||
|
if in.ReverseSuggestions != nil {
|
||||||
|
v.ReverseSuggestionsOff = !*in.ReverseSuggestions
|
||||||
|
}
|
||||||
|
if in.DismissedReverse != nil {
|
||||||
|
v.DismissedReverse = slices.Clone(*in.DismissedReverse)
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1401,6 +1411,12 @@ func applySettings(st *model.State, op model.Op, c *applyCtx) error {
|
|||||||
if in.Acknowledged != nil {
|
if in.Acknowledged != nil {
|
||||||
s.Acknowledged = cleanAcks(*in.Acknowledged)
|
s.Acknowledged = cleanAcks(*in.Acknowledged)
|
||||||
}
|
}
|
||||||
|
if in.DismissedReverse != nil {
|
||||||
|
s.DismissedReverse = slices.Clone(*in.DismissedReverse)
|
||||||
|
}
|
||||||
|
if in.EncryptedDNSHosts != nil {
|
||||||
|
s.EncryptedDNSHosts = slices.Clone(*in.EncryptedDNSHosts)
|
||||||
|
}
|
||||||
s.Version++
|
s.Version++
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
package engine
|
||||||
|
|
||||||
|
import (
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"jarl/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Ticking "update PTR" on an unchanged address record adds its PTR when
|
||||||
|
// the reverse zone is there (it was created after the record).
|
||||||
|
func TestUpdatePTRAddsMissing(t *testing.T) {
|
||||||
|
env := newEnv(t)
|
||||||
|
env.submit(env.admin, NewRequest(OpSettingsUpdate, 0, SettingsInput{Nameservers: &[]string{"ns1.example.net."}}))
|
||||||
|
env.submit(env.admin, NewRequest(OpZoneCreate, 0, ZoneInput{Name: model.Ptr("example.com")}))
|
||||||
|
env.submit(env.admin, rec("example.com", "ldn", "A", "139.144.144.154"))
|
||||||
|
env.submit(env.admin, NewRequest(OpZoneCreate, 0, ZoneInput{Name: model.Ptr("139.144.144.154/32")}))
|
||||||
|
var id int64
|
||||||
|
for _, r := range env.state().Records {
|
||||||
|
if r.Name == "ldn.example.com." {
|
||||||
|
id = r.ID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
env.submit(env.admin, NewRequest(OpRecordUpdate, id, RecordInput{PTR: true}))
|
||||||
|
got := env.records("154.144.144.139.in-addr.arpa.")
|
||||||
|
if !slices.ContainsFunc(got, func(s string) bool { return strings.Contains(s, "PTR ldn.example.com.") }) {
|
||||||
|
t.Errorf("reverse zone = %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// In linked zones the PTR goes to the reverse zone of whichever linked
|
||||||
|
// view has one.
|
||||||
|
func TestUpdatePTRLinked(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", "ldn", "A", "139.144.144.154"))
|
||||||
|
env.submit(env.admin, NewRequest(OpZoneCreate, 0, ZoneInput{Name: model.Ptr("139.144.144.154/32"), View: model.Ptr("external")}))
|
||||||
|
res := env.submit(env.admin, NewRequest(OpRecordUpdate, env.recordID("internal", "ldn", "A"), RecordInput{PTR: true}))
|
||||||
|
st := env.state()
|
||||||
|
z := st.FindZone("154.144.144.139.in-addr.arpa.", st.ViewByName("external").ID)
|
||||||
|
ok := false
|
||||||
|
for _, r := range st.ZoneRecords(z.ID) {
|
||||||
|
ok = ok || r.Type == "PTR" && r.Data == "ldn.example.com."
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
t.Errorf("no PTR in external's reverse zone (warnings %q)", res.Warnings)
|
||||||
|
}
|
||||||
|
// Again: already there, nothing to do.
|
||||||
|
if e := env.submitErr(env.admin, NewRequest(OpRecordUpdate, env.recordID("internal", "ldn", "A"), RecordInput{PTR: true})); !strings.Contains(e.Message, "nothing to do") {
|
||||||
|
t.Errorf("second time: %v", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,565 @@
|
|||||||
|
package engine
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/netip"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"jarl/internal/dnsname"
|
||||||
|
"jarl/internal/model"
|
||||||
|
"jarl/internal/validate"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Reverse zones made easy (docs/reverse.md): filling a reverse zone from
|
||||||
|
// the A and AAAA records of its view, choosing its view, and suggesting
|
||||||
|
// the reverse zones a view lacks.
|
||||||
|
|
||||||
|
// addrUse is an address a forward record gives a name.
|
||||||
|
type addrUse struct {
|
||||||
|
addr netip.Addr
|
||||||
|
name string // the owner name, absolute
|
||||||
|
apex bool
|
||||||
|
view string // another view's record (fillAddresses), else ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// forwardAddresses returns the addresses of the enabled A and AAAA
|
||||||
|
// records of a view's primary forward zones (wildcards aside: a PTR record
|
||||||
|
// can't name them).
|
||||||
|
func forwardAddresses(st *model.State, viewID int64) []addrUse {
|
||||||
|
var out []addrUse
|
||||||
|
for _, z := range st.ZonesInView(viewID) {
|
||||||
|
if z.Type != model.ZonePrimary || z.Disabled || dnsname.IsReverse(z.Name) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, r := range st.ZoneRecords(z.ID) {
|
||||||
|
if r.Disabled || (r.Type != "A" && r.Type != "AAAA") || strings.HasPrefix(r.Name, "*.") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
a, err := netip.ParseAddr(r.Data)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, addrUse{addr: a.Unmap(), name: r.Name, apex: r.Name == z.Name})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// fillAddresses returns a view's addresses (forwardAddresses), then those
|
||||||
|
// of the other views' records that none of its own records use, marked
|
||||||
|
// with their view: a reverse zone in the internal view can name a host
|
||||||
|
// only the external view has a record for.
|
||||||
|
func fillAddresses(st *model.State, viewID int64) []addrUse {
|
||||||
|
out := forwardAddresses(st, viewID)
|
||||||
|
if len(st.Views) == 0 {
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
own := map[netip.Addr]bool{}
|
||||||
|
for _, u := range out {
|
||||||
|
own[u.addr] = true
|
||||||
|
}
|
||||||
|
for _, v := range st.OrderedViews() {
|
||||||
|
if v.ID == viewID {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, u := range forwardAddresses(st, v.ID) {
|
||||||
|
if !own[u.addr] {
|
||||||
|
u.view = v.Name
|
||||||
|
out = append(out, u)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// PTRFill is a PTR record filling a reverse zone would add.
|
||||||
|
type PTRFill struct {
|
||||||
|
Address string `json:"address"`
|
||||||
|
Name string `json:"name"` // the PTR owner name
|
||||||
|
Target string `json:"target"` // the host name it gives
|
||||||
|
Others []string `json:"others,omitempty"` // other names with this address
|
||||||
|
// From is the view whose record names the host, when the zone's own
|
||||||
|
// view has none for the address.
|
||||||
|
From string `json:"from,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// PTRFills returns the PTR records a reverse zone named zoneName in a view
|
||||||
|
// lacks for the addresses of the view's A and AAAA records: one per
|
||||||
|
// address, naming its mail or name server host when it is one, otherwise
|
||||||
|
// a host rather than the domain itself, then the shortest name. With
|
||||||
|
// otherViews, an address no record of the view uses is named from another
|
||||||
|
// view's records (fillAddresses), marked From; that is only on request,
|
||||||
|
// since it publishes one view's host names in another. existing are the
|
||||||
|
// zone's records (none for a new zone).
|
||||||
|
func PTRFills(st *model.State, viewID int64, zoneName string, existing []*model.Record, otherViews bool) []PTRFill {
|
||||||
|
prefix, ok := dnsname.PrefixFromReverse(zoneName)
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
has := map[string]bool{}
|
||||||
|
for _, r := range existing {
|
||||||
|
if r.Type == "PTR" {
|
||||||
|
has[r.Name] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
servers := serverNames(st, viewID)
|
||||||
|
byAddr := map[netip.Addr][]addrUse{}
|
||||||
|
var order []netip.Addr
|
||||||
|
addrs := forwardAddresses(st, viewID)
|
||||||
|
if otherViews {
|
||||||
|
addrs = fillAddresses(st, viewID)
|
||||||
|
}
|
||||||
|
for _, u := range addrs {
|
||||||
|
if !prefix.Contains(u.addr) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, seen := byAddr[u.addr]; !seen {
|
||||||
|
order = append(order, u.addr)
|
||||||
|
}
|
||||||
|
if !slices.ContainsFunc(byAddr[u.addr], func(o addrUse) bool { return o.name == u.name }) {
|
||||||
|
byAddr[u.addr] = append(byAddr[u.addr], u)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
slices.SortFunc(order, func(a, b netip.Addr) int { return a.Compare(b) })
|
||||||
|
var out []PTRFill
|
||||||
|
for _, a := range order {
|
||||||
|
owner := dnsname.ReverseName(a)
|
||||||
|
if has[owner] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
uses := byAddr[a]
|
||||||
|
slices.SortStableFunc(uses, func(x, y addrUse) int {
|
||||||
|
rank := func(u addrUse) int {
|
||||||
|
switch {
|
||||||
|
case servers[u.name]:
|
||||||
|
return 0
|
||||||
|
case !u.apex:
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
return 2
|
||||||
|
}
|
||||||
|
if d := rank(x) - rank(y); d != 0 {
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
if d := strings.Count(x.name, ".") - strings.Count(y.name, "."); d != 0 {
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
return strings.Compare(x.name, y.name)
|
||||||
|
})
|
||||||
|
f := PTRFill{Address: a.String(), Name: owner, Target: uses[0].name, From: uses[0].view}
|
||||||
|
for _, u := range uses[1:] {
|
||||||
|
if u.name != f.Target {
|
||||||
|
f.Others = append(f.Others, u.name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out = append(out, f)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// serverNames are the hosts the view's MX and NS records name.
|
||||||
|
func serverNames(st *model.State, viewID int64) map[string]bool {
|
||||||
|
out := map[string]bool{}
|
||||||
|
for _, z := range st.ZonesInView(viewID) {
|
||||||
|
if z.Type != model.ZonePrimary {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, r := range st.ZoneRecords(z.ID) {
|
||||||
|
switch r.Type {
|
||||||
|
case "MX":
|
||||||
|
if f := strings.Fields(r.Data); len(f) == 2 {
|
||||||
|
out[strings.ToLower(f[1])] = true
|
||||||
|
}
|
||||||
|
case "NS":
|
||||||
|
out[strings.ToLower(r.Data)] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func fillWarnings(fills []PTRFill) []string {
|
||||||
|
var out []string
|
||||||
|
for _, f := range fills {
|
||||||
|
if f.From != "" {
|
||||||
|
out = append(out, fmt.Sprintf("%s: only view %s has a record for it, so its PTR record gives %s from there", f.Address, f.From, dnsname.Display(f.Target)))
|
||||||
|
}
|
||||||
|
if len(f.Others) > 0 {
|
||||||
|
out = append(out, fmt.Sprintf("%s has several names: its PTR record gives %s (not %s); change it if you prefer another", f.Address, dnsname.Display(f.Target), strings.Join(displayAll(f.Others), ", ")))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func displayAll(names []string) []string {
|
||||||
|
out := make([]string, len(names))
|
||||||
|
for i, n := range names {
|
||||||
|
out[i] = dnsname.Display(n)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// otherViewsHint says how many addresses only other views' records use in
|
||||||
|
// a reverse zone, and how to add them; "" when there are none.
|
||||||
|
func otherViewsHint(st *model.State, viewID int64, zoneName string, existing []*model.Record, own int, how string) string {
|
||||||
|
n := len(PTRFills(st, viewID, zoneName, existing, true)) - own
|
||||||
|
if n <= 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
what, has, they := "addresses", "have", "they are"
|
||||||
|
if n == 1 {
|
||||||
|
what, has, they = "address", "has", "it is"
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%d %s in %s %s A or AAAA records only in other views, so %s not missing here; %s", n, what, dnsname.Display(zoneName), has, they, how)
|
||||||
|
}
|
||||||
|
|
||||||
|
// zoneFillPTR expands zone.fill_ptr: the PTR records a reverse zone lacks.
|
||||||
|
func (x *expander) zoneFillPTR(req Request) ([]model.Op, error) {
|
||||||
|
var in FillPTRInput
|
||||||
|
if err := decode(req.Data, &in); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
id := in.ZoneID
|
||||||
|
if id == 0 {
|
||||||
|
id = req.ID
|
||||||
|
}
|
||||||
|
z := x.st.Zones[id]
|
||||||
|
if z == nil {
|
||||||
|
return nil, errf(KindNotFound, "zone %d does not exist", id)
|
||||||
|
}
|
||||||
|
if z.Type != model.ZonePrimary {
|
||||||
|
return nil, errf(KindBadRequest, "zone %s isn't a primary zone", zoneDesc(x.st, z))
|
||||||
|
}
|
||||||
|
if _, ok := dnsname.PrefixFromReverse(z.Name); !ok {
|
||||||
|
return nil, errf(KindBadRequest, "zone %s isn't a reverse zone of whole octets or nibbles (like 10.0.10.in-addr.arpa)", zoneDesc(x.st, z))
|
||||||
|
}
|
||||||
|
fills := PTRFills(x.st, z.ViewID, z.Name, x.st.ZoneRecords(z.ID), in.OtherViews)
|
||||||
|
if len(fills) == 0 {
|
||||||
|
msg := fmt.Sprintf("nothing to add: every address of %s's A and AAAA records in %s has a PTR record", viewOrServer(x.st, z.ViewID), zoneDesc(x.st, z))
|
||||||
|
if !in.OtherViews {
|
||||||
|
if h := otherViewsHint(x.st, z.ViewID, z.Name, x.st.ZoneRecords(z.ID), 0, "to add PTR records for them in this view too: --other-views"); h != "" {
|
||||||
|
msg += "; " + h
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, errf(KindBadRequest, "%s", msg)
|
||||||
|
}
|
||||||
|
x.warnings = append(x.warnings, fillWarnings(fills)...)
|
||||||
|
ids, err := x.ids(len(fills))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var ops []model.Op
|
||||||
|
for i, f := range fills {
|
||||||
|
// Local to the view in a linked zone, like the PTR records that
|
||||||
|
// follow address records (ptrChange).
|
||||||
|
ri := RecordInput{ZoneID: z.ID, Name: model.Ptr(f.Name), Type: model.Ptr("PTR"), Data: model.Ptr(f.Target)}
|
||||||
|
if z.LinkGroup != 0 {
|
||||||
|
ri.SharedID = model.Ptr(int64(0))
|
||||||
|
}
|
||||||
|
ops = append(ops, newOp(OpRecordCreate, ids[i], 0, ri, fmt.Sprintf("add %s IN PTR %s", f.Name, f.Target)))
|
||||||
|
}
|
||||||
|
return ops, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func viewOrServer(st *model.State, viewID int64) string {
|
||||||
|
if v := st.Views[viewID]; v != nil {
|
||||||
|
return "view " + v.Name
|
||||||
|
}
|
||||||
|
return "the server"
|
||||||
|
}
|
||||||
|
|
||||||
|
// publicViews returns which views the internet reaches.
|
||||||
|
func (x *expander) publicViews() map[int64]bool {
|
||||||
|
out := map[int64]bool{}
|
||||||
|
if x.exposures == nil {
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
for _, e := range x.exposures(x.st) {
|
||||||
|
out[e.ViewID] = e.Public
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// reverseViewFor chooses the view of a new reverse zone given without one:
|
||||||
|
// the private view whose records use addresses in its range, or the only
|
||||||
|
// private view; views with reverse suggestions off don't count. nil with
|
||||||
|
// no error leaves the choice to the usual rules.
|
||||||
|
func reverseViewFor(st *model.State, public map[int64]bool, zoneName string) (*model.View, error) {
|
||||||
|
prefix, ok := dnsname.PrefixFromReverse(zoneName)
|
||||||
|
if !ok || len(st.Views) == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
var private, using []*model.View
|
||||||
|
for _, v := range st.OrderedViews() {
|
||||||
|
if public[v.ID] || v.ReverseSuggestionsOff {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
private = append(private, v)
|
||||||
|
if slices.ContainsFunc(forwardAddresses(st, v.ID), func(u addrUse) bool { return prefix.Contains(u.addr) }) {
|
||||||
|
using = append(using, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case len(using) == 1:
|
||||||
|
return using[0], nil
|
||||||
|
case len(using) > 1:
|
||||||
|
var names []string
|
||||||
|
for _, v := range using {
|
||||||
|
names = append(names, v.Name)
|
||||||
|
}
|
||||||
|
return nil, errf(KindBadRequest, "views %s all have addresses in %s: give the view (or create the zone in each)", strings.Join(names, " and "), prefix)
|
||||||
|
case len(private) == 1:
|
||||||
|
return private[0], nil
|
||||||
|
}
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReverseSuggestion is a reverse zone a view lacks for addresses its
|
||||||
|
// records use.
|
||||||
|
type ReverseSuggestion struct {
|
||||||
|
ViewID int64 `json:"view_id"`
|
||||||
|
View string `json:"view,omitempty"`
|
||||||
|
Prefix string `json:"prefix"`
|
||||||
|
Zone string `json:"zone"`
|
||||||
|
// Group is "yours" for networks that are yours for sure (private
|
||||||
|
// ranges, IPv6 prefixes this server has an address in), "public" for
|
||||||
|
// a public address that may be someone else's (a web host's).
|
||||||
|
Group string `json:"group"`
|
||||||
|
// Hidden says why the suggestion isn't made unasked (with All only):
|
||||||
|
// "public view", "suggestions off" or "dismissed".
|
||||||
|
Hidden string `json:"hidden,omitempty"`
|
||||||
|
Why string `json:"why"`
|
||||||
|
Fill int `json:"fill"` // PTR records creating it with fill adds
|
||||||
|
Samples []string `json:"samples"` // a few of them, "address name"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Suggestion groups.
|
||||||
|
const (
|
||||||
|
SuggestYours = "yours"
|
||||||
|
SuggestPublic = "public"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ReverseSuggestions returns the reverse zones the private views (or the
|
||||||
|
// server, without views) lack for their A and AAAA records' addresses:
|
||||||
|
//
|
||||||
|
// - yours: the /24 of a private IPv4 address, the /48 of a ULA or of a
|
||||||
|
// public IPv6 prefix this server has an address in (local);
|
||||||
|
// - public: a /32 (or /128) for any other public address. It may be a
|
||||||
|
// web host's; the rest of its /24 surely belongs to others, and a
|
||||||
|
// zone for it here would hide their names from your clients.
|
||||||
|
//
|
||||||
|
// Public views, views with suggestions turned off and dismissed networks
|
||||||
|
// get none: the internet asks the addresses' owners.
|
||||||
|
func ReverseSuggestions(st *model.State, exposures []validate.Exposure, local []netip.Addr) []ReverseSuggestion {
|
||||||
|
return ReverseCandidates(st, exposures, local, ReverseOptions{})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReverseOptions widen or narrow ReverseCandidates.
|
||||||
|
type ReverseOptions struct {
|
||||||
|
// All includes what isn't suggested unasked, marked Hidden: public
|
||||||
|
// views, views with suggestions off, dismissed networks.
|
||||||
|
All bool
|
||||||
|
// View narrows it to one view (by name; "" all). With All, the view
|
||||||
|
// also gets the addresses only other views' records use, marked
|
||||||
|
// Hidden "records in VIEW": a reverse zone there can name them.
|
||||||
|
View string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReverseCandidates is ReverseSuggestions with options: with All, every
|
||||||
|
// network the views' records use that no reverse zone in their view
|
||||||
|
// covers, for creating one by hand where suggestions are off.
|
||||||
|
func ReverseCandidates(st *model.State, exposures []validate.Exposure, local []netip.Addr, opt ReverseOptions) []ReverseSuggestion {
|
||||||
|
public := map[int64]bool{}
|
||||||
|
for _, e := range exposures {
|
||||||
|
public[e.ViewID] = e.Public
|
||||||
|
}
|
||||||
|
type scope struct {
|
||||||
|
id int64
|
||||||
|
dismissed []string
|
||||||
|
hidden string
|
||||||
|
}
|
||||||
|
var scopes []scope
|
||||||
|
if len(st.Views) > 0 {
|
||||||
|
for _, v := range st.OrderedViews() {
|
||||||
|
if opt.View != "" && !strings.EqualFold(v.Name, opt.View) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
hidden := ""
|
||||||
|
switch {
|
||||||
|
case public[v.ID]:
|
||||||
|
hidden = "public view"
|
||||||
|
case v.ReverseSuggestionsOff:
|
||||||
|
hidden = "suggestions off"
|
||||||
|
}
|
||||||
|
if hidden == "" || opt.All {
|
||||||
|
scopes = append(scopes, scope{v.ID, v.DismissedReverse, hidden})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if !public[0] || opt.All {
|
||||||
|
hidden := ""
|
||||||
|
if public[0] {
|
||||||
|
hidden = "public server"
|
||||||
|
}
|
||||||
|
scopes = append(scopes, scope{0, st.Settings.DismissedReverse, hidden})
|
||||||
|
}
|
||||||
|
out := []ReverseSuggestion{}
|
||||||
|
for _, sc := range scopes {
|
||||||
|
seen := map[netip.Prefix]bool{}
|
||||||
|
addrs := forwardAddresses(st, sc.id)
|
||||||
|
if opt.All && opt.View != "" {
|
||||||
|
addrs = fillAddresses(st, sc.id)
|
||||||
|
}
|
||||||
|
for _, u := range addrs {
|
||||||
|
a := u.addr
|
||||||
|
if a.IsLoopback() || a.IsLinkLocalUnicast() || a.IsUnspecified() || a.IsMulticast() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if rz, _ := reverseZoneFor(st, sc.id, a); rz != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
p, group, why := suggestedPrefix(a, local)
|
||||||
|
hidden := sc.hidden
|
||||||
|
if hidden == "" && u.view != "" {
|
||||||
|
hidden = "records in " + u.view
|
||||||
|
}
|
||||||
|
if seen[p] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if slices.Contains(sc.dismissed, p.String()) {
|
||||||
|
if !opt.All {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if hidden == "" {
|
||||||
|
hidden = "dismissed"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
seen[p] = true
|
||||||
|
name, err := dnsname.ReverseZone(p)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
s := ReverseSuggestion{ViewID: sc.id, View: viewName(st, sc.id), Prefix: p.String(), Zone: strings.TrimSuffix(name, "."), Group: group, Hidden: hidden, Why: why, Samples: []string{}}
|
||||||
|
fills := PTRFills(st, sc.id, name, nil, opt.All && opt.View != "")
|
||||||
|
s.Fill = len(fills)
|
||||||
|
for i, f := range fills {
|
||||||
|
if i == 3 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
s.Samples = append(s.Samples, f.Address+" "+dnsname.Display(f.Target))
|
||||||
|
}
|
||||||
|
out = append(out, s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
slices.SortStableFunc(out, func(a, b ReverseSuggestion) int {
|
||||||
|
if a.Group != b.Group {
|
||||||
|
return strings.Compare(b.Group, a.Group) // yours first
|
||||||
|
}
|
||||||
|
if a.View != b.View {
|
||||||
|
return strings.Compare(a.View, b.View)
|
||||||
|
}
|
||||||
|
return netip.MustParsePrefix(a.Prefix).Addr().Compare(netip.MustParsePrefix(b.Prefix).Addr())
|
||||||
|
})
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// suggestedPrefix returns the reverse zone network for an address, its
|
||||||
|
// group and why.
|
||||||
|
func suggestedPrefix(a netip.Addr, local []netip.Addr) (netip.Prefix, string, string) {
|
||||||
|
switch {
|
||||||
|
case a.Is4() && (a.IsPrivate() || cgnat.Contains(a)):
|
||||||
|
return netip.PrefixFrom(a, 24).Masked(), SuggestYours, "private network"
|
||||||
|
case a.Is4():
|
||||||
|
return netip.PrefixFrom(a, 32), SuggestPublic, "a public address: create it only if it is yours (not a web host's); the rest of its /24 belongs to others"
|
||||||
|
case a.IsPrivate():
|
||||||
|
return netip.PrefixFrom(a, 48).Masked(), SuggestYours, "your ULA /48 (one zone for all its subnets)"
|
||||||
|
}
|
||||||
|
p := netip.PrefixFrom(a, 48).Masked()
|
||||||
|
if slices.ContainsFunc(local, func(l netip.Addr) bool { return p.Contains(l.Unmap()) }) {
|
||||||
|
return p, SuggestYours, "your IPv6 /48 (this server has an address in it): one zone for all its subnets"
|
||||||
|
}
|
||||||
|
return netip.PrefixFrom(a, 128), SuggestPublic, "a public address outside this server's networks: create it only if it is yours"
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReverseSuggestions lists the reverse zones the views lack (opt.All:
|
||||||
|
// also where suggestions aren't made unasked).
|
||||||
|
func (e *Engine) ReverseSuggestions(st *model.State, opt ReverseOptions) []ReverseSuggestion {
|
||||||
|
var local []netip.Addr
|
||||||
|
if e.cfg.LocalAddrs != nil {
|
||||||
|
local = e.cfg.LocalAddrs()
|
||||||
|
}
|
||||||
|
return ReverseCandidates(st, e.Exposures(st), local, opt)
|
||||||
|
}
|
||||||
|
|
||||||
|
// reverseDismiss expands reverse.dismiss and reverse.undismiss.
|
||||||
|
func (x *expander) reverseDismiss(req Request, dismiss bool) ([]model.Op, error) {
|
||||||
|
var in DismissInput
|
||||||
|
if err := decode(req.Data, &in); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
raw := in.Prefixes
|
||||||
|
if in.Prefix != "" {
|
||||||
|
raw = append(raw, in.Prefix)
|
||||||
|
}
|
||||||
|
if len(raw) == 0 {
|
||||||
|
return nil, errf(KindBadRequest, "give the network (prefix) of the suggestion")
|
||||||
|
}
|
||||||
|
var prefixes []string
|
||||||
|
for _, r := range raw {
|
||||||
|
p, err := netip.ParsePrefix(strings.TrimSpace(r))
|
||||||
|
if err != nil {
|
||||||
|
return nil, errf(KindBadRequest, "prefix: %v", err)
|
||||||
|
}
|
||||||
|
if s := p.Masked().String(); !slices.Contains(prefixes, s) {
|
||||||
|
prefixes = append(prefixes, s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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)))
|
||||||
|
}
|
||||||
|
if v == nil && len(x.st.Views) > 0 {
|
||||||
|
return nil, errf(KindBadRequest, "give the view of the suggestion")
|
||||||
|
}
|
||||||
|
list, where := x.st.Settings.DismissedReverse, "the server"
|
||||||
|
if v != nil {
|
||||||
|
list, where = v.DismissedReverse, "view "+v.Name
|
||||||
|
}
|
||||||
|
out := slices.DeleteFunc(slices.Clone(list), func(s string) bool { return slices.Contains(prefixes, s) })
|
||||||
|
what := prefixes[0]
|
||||||
|
if len(prefixes) > 1 {
|
||||||
|
what = fmt.Sprintf("%d networks (%s, …)", len(prefixes), prefixes[0])
|
||||||
|
}
|
||||||
|
summary := "dismiss the reverse zone suggestion for " + what + " in " + where
|
||||||
|
if dismiss {
|
||||||
|
out = append(out, prefixes...)
|
||||||
|
slices.Sort(out)
|
||||||
|
} else {
|
||||||
|
if len(out) == len(list) {
|
||||||
|
return nil, errf(KindConflict, "%s isn't dismissed in %s", what, where)
|
||||||
|
}
|
||||||
|
summary = "suggest a reverse zone for " + what + " in " + where + " again"
|
||||||
|
}
|
||||||
|
if out == nil {
|
||||||
|
out = []string{}
|
||||||
|
}
|
||||||
|
if v != nil {
|
||||||
|
return []model.Op{newOp(OpViewUpdate, v.ID, v.Version, ViewInput{DismissedReverse: &out}, summary)}, nil
|
||||||
|
}
|
||||||
|
return []model.Op{newOp(OpSettingsUpdate, 0, 0, SettingsInput{DismissedReverse: &out}, summary)}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var cgnat = netip.MustParsePrefix("100.64.0.0/10")
|
||||||
|
|
||||||
|
func viewName(st *model.State, id int64) string {
|
||||||
|
if v := st.Views[id]; v != nil {
|
||||||
|
return v.Name
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
@@ -0,0 +1,427 @@
|
|||||||
|
package engine
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/netip"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"jarl/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ptrs returns a reverse zone's PTR records in a view as "owner target",
|
||||||
|
// with " disabled" when so.
|
||||||
|
func (env *testEnv) ptrs(view, zone string) []string {
|
||||||
|
env.t.Helper()
|
||||||
|
s := env.state()
|
||||||
|
z := s.FindZone(zone, s.ViewByName(view).ID)
|
||||||
|
if z == nil {
|
||||||
|
env.t.Fatalf("no zone %s in %s", zone, view)
|
||||||
|
}
|
||||||
|
var out []string
|
||||||
|
for _, r := range s.ZoneRecords(z.ID) {
|
||||||
|
if r.Type == "PTR" {
|
||||||
|
d := r.Name + " " + r.Data
|
||||||
|
if r.Disabled {
|
||||||
|
d += " disabled"
|
||||||
|
}
|
||||||
|
out = append(out, d)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReverseFillAndSuggest(t *testing.T) {
|
||||||
|
env := linkedEnv(t)
|
||||||
|
env.submit(env.admin,
|
||||||
|
recIn("internal", "@", "A", "192.0.2.25"),
|
||||||
|
recIn("internal", "nas", "A", "10.0.10.20"),
|
||||||
|
recIn("internal", "files", "A", "10.0.10.20"),
|
||||||
|
recIn("internal", "nas", "AAAA", "fdab:abab:abab:10::20"),
|
||||||
|
recIn("internal", "*.dev", "A", "10.0.10.99"),
|
||||||
|
recIn("internal", "lo", "A", "127.0.0.1"))
|
||||||
|
|
||||||
|
sugg := ReverseSuggestions(env.state(), env.e.Exposures(env.state()), nil)
|
||||||
|
var got []string
|
||||||
|
for _, s := range sugg {
|
||||||
|
got = append(got, s.Group+" "+s.View+" "+s.Prefix+" "+s.Zone)
|
||||||
|
}
|
||||||
|
want(t, "suggestions", got,
|
||||||
|
"yours internal 10.0.10.0/24 10.0.10.in-addr.arpa",
|
||||||
|
"yours internal fdab:abab:abab::/48 b.a.b.a.b.a.b.a.b.a.d.f.ip6.arpa",
|
||||||
|
"public internal 192.0.2.10/32 10.2.0.192.in-addr.arpa",
|
||||||
|
"public internal 192.0.2.25/32 25.2.0.192.in-addr.arpa")
|
||||||
|
if sugg[0].Fill != 1 || sugg[0].Samples[0] != "10.0.10.20 files.example.com" {
|
||||||
|
t.Errorf("10.0.10.0/24: %+v", sugg[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
// No view given: the private view whose records use the addresses.
|
||||||
|
res := env.submit(env.admin, NewRequest(OpZoneCreate, 0, ZoneInput{Name: model.Ptr("10.0.10.0/24"), Fill: true}))
|
||||||
|
want(t, "10.0.10/24", env.ptrs("internal", "10.0.10.in-addr.arpa."), "20.10.0.10.in-addr.arpa. files.example.com.")
|
||||||
|
if !slices.ContainsFunc(res.Warnings, func(w string) bool { return strings.Contains(w, "10.0.10.20 has several names") }) {
|
||||||
|
t.Errorf("warnings %q", res.Warnings)
|
||||||
|
}
|
||||||
|
// The mail server's name beats the domain's own.
|
||||||
|
env.submit(env.admin, NewRequest(OpZoneCreate, 0, ZoneInput{Name: model.Ptr("192.0.2.25/32"), Fill: true}))
|
||||||
|
want(t, "192.0.2.25", env.ptrs("internal", "25.2.0.192.in-addr.arpa."), "25.2.0.192.in-addr.arpa. mail.example.com.")
|
||||||
|
|
||||||
|
// Filling an existing zone; then there is nothing left to add.
|
||||||
|
env.submit(env.admin, NewRequest(OpZoneCreate, 0, ZoneInput{Name: model.Ptr("fdab:abab:abab::/48")}))
|
||||||
|
st := env.state()
|
||||||
|
z6 := st.FindZone("b.a.b.a.b.a.b.a.b.a.d.f.ip6.arpa.", st.ViewByName("internal").ID)
|
||||||
|
env.submit(env.admin, NewRequest(OpZoneFillPTR, z6.ID, FillPTRInput{}))
|
||||||
|
if p := env.ptrs("internal", z6.Name); len(p) != 1 || !strings.HasSuffix(p[0], " nas.example.com.") {
|
||||||
|
t.Errorf("ip6 fill: %q", p)
|
||||||
|
}
|
||||||
|
if e := env.submitErr(env.admin, NewRequest(OpZoneFillPTR, z6.ID, FillPTRInput{})); !strings.Contains(e.Message, "nothing to add") {
|
||||||
|
t.Errorf("second fill: %v", e)
|
||||||
|
}
|
||||||
|
|
||||||
|
got = got[:0]
|
||||||
|
for _, s := range ReverseSuggestions(env.state(), env.e.Exposures(env.state()), nil) {
|
||||||
|
got = append(got, s.Prefix)
|
||||||
|
}
|
||||||
|
want(t, "suggestions left", got, "192.0.2.10/32")
|
||||||
|
|
||||||
|
// Addresses that two private views use: the view must be given.
|
||||||
|
env.submit(env.admin, NewRequest(OpViewCreate, 0, ViewInput{Name: model.Ptr("lab"), MatchClients: &[]string{"172.16.0.0/12"}}))
|
||||||
|
env.submit(env.admin, NewRequest(OpZoneCreate, 0, ZoneInput{Name: model.Ptr("lab.test"), View: model.Ptr("lab")}),
|
||||||
|
NewRequest(OpRecordCreate, 0, RecordInput{Zone: "lab.test", View: "lab", Name: model.Ptr("x"), Type: model.Ptr("A"), Data: model.Ptr("192.0.2.10")}))
|
||||||
|
if e := env.submitErr(env.admin, NewRequest(OpZoneCreate, 0, ZoneInput{Name: model.Ptr("192.0.2.10/32")})); !strings.Contains(e.Message, "views internal and lab all have addresses") {
|
||||||
|
t.Errorf("two views: %v", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAutoPTR(t *testing.T) {
|
||||||
|
env := linkedEnv(t)
|
||||||
|
env.submit(env.admin, NewRequest(OpZoneCreate, 0, ZoneInput{Name: model.Ptr("10.0.10.0/24"), View: model.Ptr("internal")}))
|
||||||
|
st := env.state()
|
||||||
|
zid := st.FindZone("example.com.", st.ViewByName("internal").ID).ID
|
||||||
|
env.submit(env.admin, NewRequest(OpZoneUpdate, zid, ZoneInput{AutoPTR: model.Ptr(true)}))
|
||||||
|
rz := "10.0.10.in-addr.arpa."
|
||||||
|
|
||||||
|
res := env.submit(env.admin, recIn("internal", "host", "A", "10.0.10.30"), recIn("internal", "pub", "A", "203.0.113.5"))
|
||||||
|
want(t, "added", env.ptrs("internal", rz), "30.10.0.10.in-addr.arpa. host.example.com.")
|
||||||
|
if len(res.Warnings) > 0 {
|
||||||
|
t.Errorf("no reverse zone for 203.0.113.5 isn't worth a warning: %q", res.Warnings)
|
||||||
|
}
|
||||||
|
// A second name for the address: its PTR stays.
|
||||||
|
res = env.submit(env.admin, recIn("internal", "alias", "A", "10.0.10.30"))
|
||||||
|
want(t, "second name", env.ptrs("internal", rz), "30.10.0.10.in-addr.arpa. host.example.com.")
|
||||||
|
if len(res.Warnings) > 0 {
|
||||||
|
t.Errorf("warnings %q", res.Warnings)
|
||||||
|
}
|
||||||
|
env.submit(env.admin, NewRequest(OpRecordDelete, env.recordID("internal", "alias", "A"), nil))
|
||||||
|
|
||||||
|
// Moved, disabled, enabled, deleted.
|
||||||
|
env.submit(env.admin, NewRequest(OpRecordUpdate, env.recordID("internal", "host", "A"), RecordInput{Data: model.Ptr("10.0.10.31")}))
|
||||||
|
want(t, "moved", env.ptrs("internal", rz), "31.10.0.10.in-addr.arpa. host.example.com.")
|
||||||
|
env.submit(env.admin, NewRequest(OpRecordUpdate, env.recordID("internal", "host", "A"), RecordInput{Disabled: model.Ptr(true)}))
|
||||||
|
want(t, "disabled", env.ptrs("internal", rz), "31.10.0.10.in-addr.arpa. host.example.com. disabled")
|
||||||
|
env.submit(env.admin, NewRequest(OpRecordUpdate, env.recordID("internal", "host", "A"), RecordInput{Disabled: model.Ptr(false)}))
|
||||||
|
want(t, "enabled", env.ptrs("internal", rz), "31.10.0.10.in-addr.arpa. host.example.com.")
|
||||||
|
env.submit(env.admin, NewRequest(OpRecordDelete, env.recordID("internal", "host", "A"), nil))
|
||||||
|
want(t, "deleted", env.ptrs("internal", rz))
|
||||||
|
|
||||||
|
// A whole set replaced.
|
||||||
|
env.submit(env.admin, NewRequest(OpRRSetReplace, 0, RRSetInput{Zone: "example.com", View: "internal", Name: "multi", Type: "A", Data: []string{"10.0.10.40", "10.0.10.41"}}))
|
||||||
|
want(t, "set", env.ptrs("internal", rz), "40.10.0.10.in-addr.arpa. multi.example.com.", "41.10.0.10.in-addr.arpa. multi.example.com.")
|
||||||
|
env.submit(env.admin, NewRequest(OpRRSetReplace, 0, RRSetInput{Zone: "example.com", View: "internal", Name: "multi", Type: "A", Data: []string{"10.0.10.41"}}))
|
||||||
|
want(t, "set shrunk", env.ptrs("internal", rz), "41.10.0.10.in-addr.arpa. multi.example.com.")
|
||||||
|
// Deleting by name and type.
|
||||||
|
env.submit(env.admin, NewRequest(OpRecordDelete, 0, RecordInput{Zone: "example.com", View: "internal", Name: model.Ptr("multi"), Type: model.Ptr("A")}))
|
||||||
|
want(t, "deleted by match", env.ptrs("internal", rz))
|
||||||
|
|
||||||
|
// Off again: records come and go alone.
|
||||||
|
env.submit(env.admin, NewRequest(OpZoneUpdate, zid, ZoneInput{AutoPTR: model.Ptr(false)}))
|
||||||
|
env.submit(env.admin, recIn("internal", "quiet", "A", "10.0.10.50"))
|
||||||
|
want(t, "off", env.ptrs("internal", rz))
|
||||||
|
}
|
||||||
|
|
||||||
|
func suggested(env *testEnv, local ...netip.Addr) []string {
|
||||||
|
var out []string
|
||||||
|
for _, s := range ReverseSuggestions(env.state(), env.e.Exposures(env.state()), local) {
|
||||||
|
out = append(out, s.Group+" "+s.View+" "+s.Prefix)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// Public IPv6: the /48 only when this server has an address in it, else
|
||||||
|
// the address alone, as a public suggestion. Dismissed networks and views
|
||||||
|
// with suggestions off get none.
|
||||||
|
func TestReverseSuggestionGroups(t *testing.T) {
|
||||||
|
env := linkedEnv(t)
|
||||||
|
env.submit(env.admin,
|
||||||
|
recIn("internal", "home", "AAAA", "2a02:8013:7f3d:10::5"),
|
||||||
|
recIn("internal", "gh", "AAAA", "2606:50c0:8000::153"))
|
||||||
|
want(t, "no local addresses", suggested(env),
|
||||||
|
"public internal 192.0.2.10/32", "public internal 192.0.2.25/32",
|
||||||
|
"public internal 2606:50c0:8000::153/128", "public internal 2a02:8013:7f3d:10::5/128")
|
||||||
|
want(t, "this server is in 2a02:8013:7f3d::/48", suggested(env, netip.MustParseAddr("2a02:8013:7f3d:10:1266:6aff:fef9:f5b1")),
|
||||||
|
"yours internal 2a02:8013:7f3d::/48",
|
||||||
|
"public internal 192.0.2.10/32", "public internal 192.0.2.25/32", "public internal 2606:50c0:8000::153/128")
|
||||||
|
|
||||||
|
env.submit(env.admin, NewRequest(OpReverseDismiss, 0, DismissInput{View: "internal", Prefix: "2606:50c0:8000::153/128"}))
|
||||||
|
if got := suggested(env); slices.Contains(got, "public internal 2606:50c0:8000::153/128") {
|
||||||
|
t.Errorf("dismissed still suggested: %q", got)
|
||||||
|
}
|
||||||
|
if e := env.submitErr(env.admin, NewRequest(OpReverseUndismiss, 0, DismissInput{View: "internal", Prefix: "192.0.2.10/32"})); e.Kind != KindConflict {
|
||||||
|
t.Errorf("undismiss of one not dismissed: %v", e)
|
||||||
|
}
|
||||||
|
env.submit(env.admin, NewRequest(OpReverseUndismiss, 0, DismissInput{View: "internal", Prefix: "2606:50c0:8000::153/128"}))
|
||||||
|
// Several at once.
|
||||||
|
env.submit(env.admin, NewRequest(OpReverseDismiss, 0, DismissInput{View: "internal", Prefixes: []string{"192.0.2.10/32", "192.0.2.25/32"}}))
|
||||||
|
if got := suggested(env); slices.ContainsFunc(got, func(s string) bool { return strings.Contains(s, "192.0.2.") }) {
|
||||||
|
t.Errorf("dismissed together, still suggested: %q", got)
|
||||||
|
}
|
||||||
|
env.submit(env.admin, NewRequest(OpReverseUndismiss, 0, DismissInput{View: "internal", Prefixes: []string{"192.0.2.10/32", "192.0.2.25/32"}}))
|
||||||
|
if got := suggested(env); !slices.Contains(got, "public internal 2606:50c0:8000::153/128") {
|
||||||
|
t.Errorf("undismissed not suggested: %q", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A second private view using the same address (a VPS's view): with
|
||||||
|
// its suggestions off it gets none, and doesn't count when choosing
|
||||||
|
// the view of a new reverse zone.
|
||||||
|
env.submit(env.admin, NewRequest(OpViewCreate, 0, ViewInput{Name: model.Ptr("vps"), MatchClients: &[]string{"198.51.100.7"}}))
|
||||||
|
env.submit(env.admin, NewRequest(OpZoneCreate, 0, ZoneInput{Name: model.Ptr("example.com"), View: model.Ptr("vps"), LinkWith: "internal"}))
|
||||||
|
if got := suggested(env); !slices.Contains(got, "public vps 192.0.2.25/32") {
|
||||||
|
t.Errorf("vps view: %q", got)
|
||||||
|
}
|
||||||
|
if e := env.submitErr(env.admin, NewRequest(OpZoneCreate, 0, ZoneInput{Name: model.Ptr("192.0.2.25/32")})); !strings.Contains(e.Message, "views internal and vps") {
|
||||||
|
t.Errorf("two views: %v", e)
|
||||||
|
}
|
||||||
|
vps := env.state().ViewByName("vps")
|
||||||
|
env.submit(env.admin, NewRequest(OpViewUpdate, vps.ID, ViewInput{ReverseSuggestions: model.Ptr(false)}))
|
||||||
|
if got := suggested(env); slices.ContainsFunc(got, func(s string) bool { return strings.Contains(s, " vps ") }) {
|
||||||
|
t.Errorf("vps view with suggestions off: %q", got)
|
||||||
|
}
|
||||||
|
env.submit(env.admin, NewRequest(OpZoneCreate, 0, ZoneInput{Name: model.Ptr("192.0.2.25/32"), Fill: true}))
|
||||||
|
want(t, "went to internal", env.ptrs("internal", "25.2.0.192.in-addr.arpa."), "25.2.0.192.in-addr.arpa. mail.example.com.")
|
||||||
|
}
|
||||||
|
|
||||||
|
// A zone created inside another takes the parent's records under it.
|
||||||
|
func TestChildZoneTakesRecords(t *testing.T) {
|
||||||
|
env := linkedEnv(t)
|
||||||
|
env.submit(env.admin, NewRequest(OpZoneCreate, 0, ZoneInput{Name: model.Ptr("10.0.0.0/8"), View: model.Ptr("internal")}))
|
||||||
|
ptr := func(name, data string) Request {
|
||||||
|
return NewRequest(OpRecordCreate, 0, RecordInput{Zone: "10.in-addr.arpa", View: "internal", Name: &name, Type: model.Ptr("PTR"), Data: &data})
|
||||||
|
}
|
||||||
|
env.submit(env.admin, ptr("20.10.0.10.in-addr.arpa.", "printer.example.com."), ptr("5.20.0.10.in-addr.arpa.", "other.example.com."))
|
||||||
|
env.submit(env.admin, recIn("internal", "nas", "A", "10.0.10.30"))
|
||||||
|
res := env.submit(env.admin, NewRequest(OpZoneCreate, 0, ZoneInput{Name: model.Ptr("10.0.10.0/24"), View: model.Ptr("internal"), Fill: true}))
|
||||||
|
want(t, "/24", env.ptrs("internal", "10.0.10.in-addr.arpa."),
|
||||||
|
"20.10.0.10.in-addr.arpa. printer.example.com.", "30.10.0.10.in-addr.arpa. nas.example.com.")
|
||||||
|
want(t, "/8 keeps the rest", env.ptrs("internal", "10.in-addr.arpa."), "5.20.0.10.in-addr.arpa. other.example.com.")
|
||||||
|
for _, p := range res.Problems {
|
||||||
|
if p.Code == "shadowed_by_zone" {
|
||||||
|
t.Errorf("still hidden records: %v", p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A forward child: its delegation and glue stay in the parent.
|
||||||
|
env.submit(env.admin,
|
||||||
|
recIn("internal", "lab", "NS", "ns.lab.example.com."),
|
||||||
|
recIn("internal", "ns.lab", "A", "10.0.30.1"),
|
||||||
|
recIn("internal", "www.lab", "A", "10.0.30.2"))
|
||||||
|
env.submit(env.admin, NewRequest(OpZoneCreate, 0, ZoneInput{Name: model.Ptr("lab.example.com"), View: model.Ptr("internal")}))
|
||||||
|
st := env.state()
|
||||||
|
lab := st.FindZone("lab.example.com.", st.ViewByName("internal").ID)
|
||||||
|
var labRecs []string
|
||||||
|
for _, r := range st.ZoneRecords(lab.ID) {
|
||||||
|
if r.Type != "NS" {
|
||||||
|
labRecs = append(labRecs, r.Name+" "+r.Type)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
want(t, "lab", labRecs, "www.lab.example.com. A")
|
||||||
|
v := env.view("internal") // without NS records
|
||||||
|
if !slices.Contains(v, "ns.lab A 10.0.30.1 L") || slices.ContainsFunc(v, func(s string) bool { return strings.HasPrefix(s, "www.lab ") }) {
|
||||||
|
t.Errorf("parent after: %q", v)
|
||||||
|
}
|
||||||
|
if env.recordID("internal", "lab", "NS") == 0 {
|
||||||
|
t.Error("the delegation left the parent")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// With All, the networks no suggestion is made for unasked are listed too,
|
||||||
|
// marked: views with suggestions off, dismissed ones, public views.
|
||||||
|
func TestReverseCandidatesAll(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: "internal", Name: "nas", Type: "AAAA", Data: []string{"2a02:8013:7f3d:10::5"}, Local: model.Ptr(true)}))
|
||||||
|
env.submit(env.admin, NewRequest(OpReverseDismiss, 0, DismissInput{View: "internal", Prefix: "192.0.2.10/32"}))
|
||||||
|
internal := env.state().ViewByName("internal")
|
||||||
|
env.submit(env.admin, NewRequest(OpViewUpdate, internal.ID, ViewInput{ReverseSuggestions: model.Ptr(false)}))
|
||||||
|
list := func(opt ReverseOptions) []string {
|
||||||
|
var out []string
|
||||||
|
for _, s := range ReverseCandidates(env.state(), env.e.Exposures(env.state()), nil, opt) {
|
||||||
|
out = append(out, s.View+" "+s.Prefix+" "+s.Hidden)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
want(t, "unasked", list(ReverseOptions{}))
|
||||||
|
want(t, "internal, all", list(ReverseOptions{All: true, View: "internal"}),
|
||||||
|
"internal 192.0.2.10/32 suggestions off", "internal 192.0.2.25/32 suggestions off", "internal 2a02:8013:7f3d:10::5/128 suggestions off")
|
||||||
|
got := list(ReverseOptions{All: true, View: "external"})
|
||||||
|
want(t, "external, all", got, "external 192.0.2.10/32 public view", "external 192.0.2.25/32 public view", "external 2a02:8013:7f3d:10::5/128 public view")
|
||||||
|
|
||||||
|
env.submit(env.admin, NewRequest(OpViewUpdate, internal.ID, ViewInput{ReverseSuggestions: model.Ptr(true)}))
|
||||||
|
want(t, "dismissed", list(ReverseOptions{All: true, View: "internal"}),
|
||||||
|
"internal 192.0.2.10/32 dismissed", "internal 192.0.2.25/32 ", "internal 2a02:8013:7f3d:10::5/128 ")
|
||||||
|
}
|
||||||
|
|
||||||
|
// An address only another view has a record for: a reverse zone in the
|
||||||
|
// internal view is offered for it, and filled from that view's record.
|
||||||
|
func TestReverseFromOtherView(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: "vps", Type: "AAAA", Data: []string{"2600:3c01::f03c:93ff:fe2b:1"}, Local: model.Ptr(true)}))
|
||||||
|
var got []string
|
||||||
|
for _, s := range ReverseCandidates(env.state(), env.e.Exposures(env.state()), nil, ReverseOptions{All: true, View: "internal"}) {
|
||||||
|
got = append(got, s.Prefix+" "+s.Hidden)
|
||||||
|
}
|
||||||
|
if !slices.Contains(got, "2600:3c01::f03c:93ff:fe2b:1/128 records in external") {
|
||||||
|
t.Errorf("internal candidates %q", got)
|
||||||
|
}
|
||||||
|
// Not suggested unasked: the internal view's own records don't use it.
|
||||||
|
for _, s := range ReverseSuggestions(env.state(), env.e.Exposures(env.state()), nil) {
|
||||||
|
if strings.HasPrefix(s.Prefix, "2600:") {
|
||||||
|
t.Errorf("suggested unasked: %+v", s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, s := range ReverseCandidates(env.state(), env.e.Exposures(env.state()), nil, ReverseOptions{All: true, View: "internal"}) {
|
||||||
|
if strings.HasPrefix(s.Prefix, "2600:") && (s.Fill != 1 || len(s.Samples) != 1) {
|
||||||
|
t.Errorf("candidate counts the other view's address: %+v", s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Filling doesn't take names from other views unasked: it would
|
||||||
|
// publish one view's host names in another. It says how to.
|
||||||
|
res := env.submit(env.admin, NewRequest(OpZoneCreate, 0, ZoneInput{Name: model.Ptr("2600:3c01::f03c:93ff:fe2b:1/128"), View: model.Ptr("internal"), Fill: true}))
|
||||||
|
st := env.state()
|
||||||
|
var z *model.Zone
|
||||||
|
for _, o := range st.ZonesInView(st.ViewByName("internal").ID) {
|
||||||
|
if strings.HasSuffix(o.Name, ".ip6.arpa.") {
|
||||||
|
z = o
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if p := env.ptrs("internal", z.Name); len(p) != 0 {
|
||||||
|
t.Errorf("filled from another view unasked: %q", p)
|
||||||
|
}
|
||||||
|
if !slices.ContainsFunc(res.Warnings, func(w string) bool {
|
||||||
|
return strings.Contains(w, "1 address in") && strings.Contains(w, "--other-views")
|
||||||
|
}) {
|
||||||
|
t.Errorf("warnings %q", res.Warnings)
|
||||||
|
}
|
||||||
|
// Not missing: no record of the zone's own view uses the address.
|
||||||
|
if f := PTRFills(st, z.ViewID, z.Name, st.ZoneRecords(z.ID), false); len(f) != 0 {
|
||||||
|
t.Errorf("missing %+v", f)
|
||||||
|
}
|
||||||
|
if e := env.submitErr(env.admin, NewRequest(OpZoneFillPTR, z.ID, FillPTRInput{})); !strings.Contains(e.Message, "nothing to add") || !strings.Contains(e.Message, "--other-views") {
|
||||||
|
t.Errorf("fill-ptr: %v", e)
|
||||||
|
}
|
||||||
|
res = env.submit(env.admin, NewRequest(OpZoneFillPTR, z.ID, FillPTRInput{OtherViews: true}))
|
||||||
|
if p := env.ptrs("internal", z.Name); len(p) != 1 || !strings.HasSuffix(p[0], " vps.example.com.") {
|
||||||
|
t.Errorf("filled %q", p)
|
||||||
|
}
|
||||||
|
if !slices.ContainsFunc(res.Warnings, func(w string) bool { return strings.Contains(w, "only view external has a record for it") }) {
|
||||||
|
t.Errorf("warnings %q", res.Warnings)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ptrsMarked is ptrs with S (shared) or L (local) after each record.
|
||||||
|
func (env *testEnv) ptrsMarked(view, zone string) []string {
|
||||||
|
env.t.Helper()
|
||||||
|
s := env.state()
|
||||||
|
z := s.FindZone(zone, s.ViewByName(view).ID)
|
||||||
|
var out []string
|
||||||
|
for _, r := range s.ZoneRecords(z.ID) {
|
||||||
|
if r.Type == "PTR" {
|
||||||
|
mark := " L"
|
||||||
|
if r.SharedID != 0 {
|
||||||
|
mark = " S"
|
||||||
|
}
|
||||||
|
out = append(out, strings.TrimSuffix(r.Name, ".10.0.10.in-addr.arpa.")+" "+strings.TrimSuffix(r.Data, ".example.com.")+mark)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-PTR with linked forward and reverse zones: PTR records follow each
|
||||||
|
// view's own address records, and are local to their view.
|
||||||
|
func TestAutoPTRLinked(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(OpZoneCreate, 0, ZoneInput{Name: model.Ptr("10.0.10.0/24"), View: model.Ptr("internal")}))
|
||||||
|
env.submit(env.admin, NewRequest(OpZoneCreate, 0, ZoneInput{Name: model.Ptr("10.0.10.0/24"), View: model.Ptr("external"), LinkWith: "internal"}))
|
||||||
|
st := env.state()
|
||||||
|
for _, v := range []string{"internal", "external"} {
|
||||||
|
env.submit(env.admin, NewRequest(OpZoneUpdate, st.FindZone("example.com.", st.ViewByName(v).ID).ID, ZoneInput{AutoPTR: model.Ptr(true)}))
|
||||||
|
}
|
||||||
|
rz := "10.0.10.in-addr.arpa."
|
||||||
|
local := func(view, name, data string) Request {
|
||||||
|
r := recIn(view, name, "A", data)
|
||||||
|
var in RecordInput
|
||||||
|
_ = decode(r.Data, &in)
|
||||||
|
in.Local = model.Ptr(true)
|
||||||
|
return NewRequest(OpRecordCreate, 0, in)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A local record: a local PTR record in its own view only.
|
||||||
|
env.submit(env.admin, local("internal", "a", "10.0.10.1"))
|
||||||
|
want(t, "local a internal", env.ptrsMarked("internal", rz), "1 a L")
|
||||||
|
want(t, "local a external", env.ptrsMarked("external", rz))
|
||||||
|
|
||||||
|
// The other view adds the same name (shared, as it has no local
|
||||||
|
// one): its own PTR record; deleting it in one view leaves the other.
|
||||||
|
env.submit(env.admin, recIn("external", "a", "A", "10.0.10.1"))
|
||||||
|
want(t, "both internal", env.ptrsMarked("internal", rz), "1 a L")
|
||||||
|
want(t, "both external", env.ptrsMarked("external", rz), "1 a L")
|
||||||
|
env.submit(env.admin, NewRequest(OpRecordDelete, env.recordID("external", "a", "A"), nil))
|
||||||
|
want(t, "deleted in external: internal", env.ptrsMarked("internal", rz), "1 a L")
|
||||||
|
want(t, "deleted in external: external", env.ptrsMarked("external", rz))
|
||||||
|
|
||||||
|
// Both local; one changes its address, the other keeps its PTR.
|
||||||
|
env.submit(env.admin, local("external", "a", "10.0.10.1"))
|
||||||
|
env.submit(env.admin, NewRequest(OpRecordUpdate, env.recordID("internal", "a", "A"), RecordInput{Data: model.Ptr("10.0.10.2")}))
|
||||||
|
want(t, "moved internal", env.ptrsMarked("internal", rz), "2 a L")
|
||||||
|
want(t, "moved external", env.ptrsMarked("external", rz), "1 a L")
|
||||||
|
// Disabling in one view disables there only.
|
||||||
|
env.submit(env.admin, NewRequest(OpRecordUpdate, env.recordID("external", "a", "A"), RecordInput{Disabled: model.Ptr(true)}))
|
||||||
|
want(t, "disabled external", env.ptrs("external", rz), "1.10.0.10.in-addr.arpa. a.example.com. disabled")
|
||||||
|
want(t, "disabled: internal", env.ptrsMarked("internal", rz), "2 a L")
|
||||||
|
env.submit(env.admin, NewRequest(OpRecordDelete, env.recordID("external", "a", "A"), nil))
|
||||||
|
env.submit(env.admin, NewRequest(OpRecordDelete, env.recordID("internal", "a", "A"), nil))
|
||||||
|
want(t, "gone internal", env.ptrsMarked("internal", rz))
|
||||||
|
want(t, "gone external", env.ptrsMarked("external", rz))
|
||||||
|
|
||||||
|
// A shared record: a local PTR in each view; deleting it removes both.
|
||||||
|
env.submit(env.admin, recIn("internal", "b", "A", "10.0.10.3"))
|
||||||
|
want(t, "shared internal", env.ptrsMarked("internal", rz), "3 b L")
|
||||||
|
want(t, "shared external", env.ptrsMarked("external", rz), "3 b L")
|
||||||
|
// Overridden in one view with another address: that view's PTR moves.
|
||||||
|
env.submit(env.admin, NewRequest(OpRecordUpdate, env.recordID("external", "b", "A"), RecordInput{Local: model.Ptr(true), Data: model.Ptr("10.0.10.4")}))
|
||||||
|
want(t, "override internal", env.ptrsMarked("internal", rz), "3 b L")
|
||||||
|
want(t, "override external", env.ptrsMarked("external", rz), "4 b L")
|
||||||
|
// Sharing internal's version: external gets it back, and its PTR.
|
||||||
|
env.submit(env.admin, NewRequest(OpRecordUpdate, env.recordID("internal", "b", "A"), RecordInput{Local: model.Ptr(false)}))
|
||||||
|
want(t, "shared again internal", env.ptrsMarked("internal", rz), "3 b L")
|
||||||
|
want(t, "shared again external", env.ptrsMarked("external", rz), "3 b L")
|
||||||
|
// The override deleted: external goes without b (and its PTR);
|
||||||
|
// internal keeps it, local now.
|
||||||
|
env.submit(env.admin, NewRequest(OpRecordUpdate, env.recordID("external", "b", "A"), RecordInput{Local: model.Ptr(true), Data: model.Ptr("10.0.10.4")}))
|
||||||
|
env.submit(env.admin, NewRequest(OpRecordDelete, env.recordID("external", "b", "A"), nil))
|
||||||
|
want(t, "override gone internal", env.ptrsMarked("internal", rz), "3 b L")
|
||||||
|
want(t, "override gone external", env.ptrsMarked("external", rz))
|
||||||
|
env.submit(env.admin, NewRequest(OpRecordDelete, env.recordID("internal", "b", "A"), nil))
|
||||||
|
want(t, "b gone internal", env.ptrsMarked("internal", rz))
|
||||||
|
|
||||||
|
// A shared PTR record from before (shared by hand) becomes local in
|
||||||
|
// each view when one view's address goes.
|
||||||
|
env.submit(env.admin, recIn("internal", "c", "A", "10.0.10.5"))
|
||||||
|
env.submit(env.admin, NewRequest(OpRecordDelete, 0, RecordInput{Zone: rz, View: "internal", Name: model.Ptr("5"), Type: model.Ptr("PTR")}))
|
||||||
|
env.submit(env.admin, NewRequest(OpRecordDelete, 0, RecordInput{Zone: rz, View: "external", Name: model.Ptr("5"), Type: model.Ptr("PTR")}))
|
||||||
|
env.submit(env.admin, NewRequest(OpRecordCreate, 0, RecordInput{Zone: rz, View: "internal", Name: model.Ptr("5"), Type: model.Ptr("PTR"), Data: model.Ptr("c.example.com."), Local: model.Ptr(false)}))
|
||||||
|
want(t, "shared ptr external", env.ptrsMarked("external", rz), "5 c S")
|
||||||
|
env.submit(env.admin, NewRequest(OpRecordUpdate, env.recordID("external", "c", "A"), RecordInput{Local: model.Ptr(true), Data: model.Ptr("10.0.10.6")}))
|
||||||
|
want(t, "unshared internal", env.ptrsMarked("internal", rz), "5 c L")
|
||||||
|
want(t, "unshared external", env.ptrsMarked("external", rz), "6 c L")
|
||||||
|
}
|
||||||
@@ -66,3 +66,9 @@ func (e *Engine) ViewKey(ctx context.Context, st *model.State, z *model.Zone) *n
|
|||||||
}
|
}
|
||||||
return &nscheck.Key{Name: render.ViewKeyName(v.Name), Algorithm: render.ViewKeyAlgorithm, Secret: render.ViewKeySecret(x.ViewKeySeed, v.ID)}
|
return &nscheck.Key{Name: render.ViewKeyName(v.Name), Algorithm: render.ViewKeyAlgorithm, Secret: render.ViewKeySecret(x.ViewKeySeed, v.ID)}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GeneratedStatements lists what Jarl adds to named.conf by itself
|
||||||
|
// (render.GeneratedStatements).
|
||||||
|
func (e *Engine) GeneratedStatements(ctx context.Context, st *model.State) []render.Generated {
|
||||||
|
return render.GeneratedStatements(st, e.cfg.Paths, e.extras(ctx))
|
||||||
|
}
|
||||||
@@ -0,0 +1,339 @@
|
|||||||
|
// Package mailcheck checks the reverse DNS of the addresses that send and
|
||||||
|
// receive mail for your public zones (docs/exposure.md#mail): receivers
|
||||||
|
// such as Gmail and Outlook reject or mark mail from an address without a
|
||||||
|
// PTR record, or whose PTR name doesn't resolve back to it. Those records
|
||||||
|
// belong to whoever owns the address (your hosting provider or ISP), not
|
||||||
|
// to Jarl, so the check asks the internet and says who to ask.
|
||||||
|
package mailcheck
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/netip"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/miekg/dns"
|
||||||
|
"golang.org/x/net/publicsuffix"
|
||||||
|
|
||||||
|
"jarl/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Target is an address mail comes from or goes to, and why it is checked.
|
||||||
|
type Target struct {
|
||||||
|
Zone string `json:"zone"`
|
||||||
|
Address string `json:"address"`
|
||||||
|
Roles []string `json:"roles"` // e.g. "MX mail.adbraun.lol", "SPF ip4"
|
||||||
|
Host string `json:"host,omitempty"` // the MX host name, when it is one
|
||||||
|
}
|
||||||
|
|
||||||
|
// Result is the check of one address.
|
||||||
|
type Result struct {
|
||||||
|
Target
|
||||||
|
PTR []string `json:"ptr"` // the names its PTR records give
|
||||||
|
// OK: a PTR name resolves back to the address (forward-confirmed).
|
||||||
|
OK bool `json:"ok"`
|
||||||
|
// Level is ok, info or warning; Message says what to do.
|
||||||
|
Level string `json:"level"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
// ReverseZone and ManagedBy say where the PTR record lives.
|
||||||
|
ReverseZone string `json:"reverse_zone,omitempty"`
|
||||||
|
ManagedBy string `json:"managed_by,omitempty"`
|
||||||
|
// LocalZone is a reverse zone for the address in Jarl ("view/zone"),
|
||||||
|
// which the internet doesn't ask unless the address's owner delegated
|
||||||
|
// it to this server.
|
||||||
|
LocalZone string `json:"local_zone,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Report is a whole check.
|
||||||
|
type Report struct {
|
||||||
|
Checked time.Time `json:"checked"`
|
||||||
|
Results []Result `json:"results"`
|
||||||
|
// Errors are lookups that failed (no internet, say).
|
||||||
|
Errors []string `json:"errors,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Targets finds the addresses to check in the public zones: the MX hosts
|
||||||
|
// (receiving) and the SPF record's addresses (sending). Names are looked
|
||||||
|
// up later, through the resolver.
|
||||||
|
func Targets(st *model.State, public func(viewID int64) bool) (addrs []Target, names []Target) {
|
||||||
|
for _, z := range st.SortedZones() {
|
||||||
|
if z.Type != model.ZonePrimary || z.Disabled || !public(z.ViewID) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
zone := strings.TrimSuffix(z.Name, ".")
|
||||||
|
for _, r := range st.ZoneRecords(z.ID) {
|
||||||
|
if r.Name != z.Name || r.Disabled {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
switch r.Type {
|
||||||
|
case "MX":
|
||||||
|
f := strings.Fields(r.Data)
|
||||||
|
if len(f) == 2 && f[1] != "." {
|
||||||
|
names = append(names, Target{Zone: zone, Host: strings.TrimSuffix(strings.ToLower(f[1]), "."), Roles: []string{"MX " + strings.TrimSuffix(f[1], ".")}})
|
||||||
|
}
|
||||||
|
case "TXT":
|
||||||
|
text := strings.ReplaceAll(strings.ReplaceAll(r.Data, `" "`, ""), `"`, "")
|
||||||
|
if !strings.HasPrefix(strings.ToLower(text), "v=spf1") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, mech := range strings.Fields(text)[1:] {
|
||||||
|
m := strings.ToLower(strings.TrimLeft(mech, "+?~"))
|
||||||
|
switch {
|
||||||
|
case strings.HasPrefix(m, "ip4:"), strings.HasPrefix(m, "ip6:"):
|
||||||
|
v := m[4:]
|
||||||
|
p, err := netip.ParsePrefix(v)
|
||||||
|
if err != nil {
|
||||||
|
if a, err := netip.ParseAddr(v); err == nil {
|
||||||
|
p = netip.PrefixFrom(a, a.BitLen())
|
||||||
|
} else {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if p.IsSingleIP() {
|
||||||
|
addrs = append(addrs, Target{Zone: zone, Address: p.Addr().String(), Roles: []string{"SPF " + m[:3]}})
|
||||||
|
}
|
||||||
|
case m == "a":
|
||||||
|
names = append(names, Target{Zone: zone, Host: zone, Roles: []string{"SPF a"}})
|
||||||
|
case strings.HasPrefix(m, "a:"):
|
||||||
|
names = append(names, Target{Zone: zone, Host: strings.TrimSuffix(m[2:], "."), Roles: []string{"SPF " + m}})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return addrs, names
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolver asks recursive resolvers (the public ones of acme.resolvers).
|
||||||
|
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.RecursionDesired = true
|
||||||
|
m.SetEdns0(1232, false)
|
||||||
|
t := r.Timeout
|
||||||
|
if t == 0 {
|
||||||
|
t = 5 * time.Second
|
||||||
|
}
|
||||||
|
var last error
|
||||||
|
for _, s := range r.Servers {
|
||||||
|
in, _, err := (&dns.Client{Timeout: t}).ExchangeContext(ctx, m, s)
|
||||||
|
if err == nil && in.Truncated {
|
||||||
|
in, _, err = (&dns.Client{Timeout: t, Net: "tcp"}).ExchangeContext(ctx, m, s)
|
||||||
|
}
|
||||||
|
if err == nil && in.Rcode != dns.RcodeServerFailure && in.Rcode != dns.RcodeRefused {
|
||||||
|
return in, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
last = fmt.Errorf("%s: %w", s, err)
|
||||||
|
} else {
|
||||||
|
last = fmt.Errorf("%s answered %s", s, dns.RcodeToString[in.Rcode])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("looking up %s %s: %w", strings.TrimSuffix(name, "."), dns.TypeToString[qtype], last)
|
||||||
|
}
|
||||||
|
|
||||||
|
// addresses returns a name's A and AAAA addresses (after CNAMEs).
|
||||||
|
func (r Resolver) addresses(ctx context.Context, name string) ([]netip.Addr, bool, error) {
|
||||||
|
var out []netip.Addr
|
||||||
|
alias := false
|
||||||
|
for _, t := range []uint16{dns.TypeA, dns.TypeAAAA} {
|
||||||
|
in, err := r.query(ctx, name, t)
|
||||||
|
if err != nil {
|
||||||
|
return nil, false, err
|
||||||
|
}
|
||||||
|
for _, rr := range in.Answer {
|
||||||
|
switch v := rr.(type) {
|
||||||
|
case *dns.A:
|
||||||
|
a, _ := netip.AddrFromSlice(v.A.To4())
|
||||||
|
out = append(out, a)
|
||||||
|
case *dns.AAAA:
|
||||||
|
a, _ := netip.AddrFromSlice(v.AAAA)
|
||||||
|
out = append(out, a)
|
||||||
|
case *dns.CNAME:
|
||||||
|
alias = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, alias, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check looks up every target's reverse DNS.
|
||||||
|
func Check(ctx context.Context, st *model.State, public func(int64) bool, res Resolver) *Report {
|
||||||
|
rep := &Report{Checked: time.Now(), Results: []Result{}}
|
||||||
|
addrs, names := Targets(st, public)
|
||||||
|
// Names to addresses, as the internet sees them.
|
||||||
|
for _, n := range names {
|
||||||
|
as, _, err := res.addresses(ctx, n.Host)
|
||||||
|
if err != nil {
|
||||||
|
rep.Errors = append(rep.Errors, err.Error())
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if len(as) == 0 {
|
||||||
|
rep.Errors = append(rep.Errors, fmt.Sprintf("%s (%s, %s) has no address as the internet sees it: not published yet, or a typo?", n.Host, n.Zone, strings.Join(n.Roles, ", ")))
|
||||||
|
}
|
||||||
|
for _, a := range as {
|
||||||
|
t := n
|
||||||
|
t.Address = a.String()
|
||||||
|
addrs = append(addrs, t)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// One result per address, with every reason it is checked.
|
||||||
|
merged := map[string]*Target{}
|
||||||
|
var order []string
|
||||||
|
for _, t := range addrs {
|
||||||
|
k := t.Zone + "|" + t.Address
|
||||||
|
if m := merged[k]; m != nil {
|
||||||
|
for _, role := range t.Roles {
|
||||||
|
if !slices.Contains(m.Roles, role) {
|
||||||
|
m.Roles = append(m.Roles, role)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if m.Host == "" {
|
||||||
|
m.Host = t.Host
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
t := t
|
||||||
|
merged[k] = &t
|
||||||
|
order = append(order, k)
|
||||||
|
}
|
||||||
|
for _, k := range order {
|
||||||
|
r := checkOne(ctx, *merged[k], res)
|
||||||
|
r.LocalZone = localReverse(st, r.Address, r.ReverseZone)
|
||||||
|
rep.Results = append(rep.Results, r)
|
||||||
|
}
|
||||||
|
return rep
|
||||||
|
}
|
||||||
|
|
||||||
|
// localReverse returns a reverse zone in Jarl that covers the address,
|
||||||
|
// unless it is the zone the internet asks (delegated to this server).
|
||||||
|
func localReverse(st *model.State, addr, internetZone string) string {
|
||||||
|
rev, err := dns.ReverseAddr(addr)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
rev = strings.ToLower(rev)
|
||||||
|
best := ""
|
||||||
|
var bz *model.Zone
|
||||||
|
for _, z := range st.SortedZones() {
|
||||||
|
n := strings.ToLower(dns.Fqdn(z.Name))
|
||||||
|
if z.Type == model.ZonePrimary && dns.IsSubDomain(n, rev) && len(n) > len(best) {
|
||||||
|
best, bz = n, z
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if bz == nil || strings.EqualFold(strings.TrimSuffix(best, "."), internetZone) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if v := st.ViewName(bz); v != "" {
|
||||||
|
return v + "/" + strings.TrimSuffix(bz.Name, ".")
|
||||||
|
}
|
||||||
|
return strings.TrimSuffix(bz.Name, ".")
|
||||||
|
}
|
||||||
|
|
||||||
|
func checkOne(ctx context.Context, t Target, res Resolver) Result {
|
||||||
|
r := Result{Target: t, PTR: []string{}}
|
||||||
|
a, err := netip.ParseAddr(t.Address)
|
||||||
|
if err != nil {
|
||||||
|
r.Level, r.Message = "warning", err.Error()
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
rev, _ := dns.ReverseAddr(a.String())
|
||||||
|
in, err := res.query(ctx, rev, dns.TypePTR)
|
||||||
|
if err != nil {
|
||||||
|
r.Level, r.Message = "warning", err.Error()
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
// Who manages the reverse zone: the SOA in the answer.
|
||||||
|
for _, sec := range [][]dns.RR{in.Ns, in.Answer} {
|
||||||
|
for _, rr := range sec {
|
||||||
|
if soa, ok := rr.(*dns.SOA); ok {
|
||||||
|
r.ReverseZone, r.ManagedBy = strings.TrimSuffix(soa.Hdr.Name, "."), managedBy(soa.Ns)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if r.ReverseZone == "" {
|
||||||
|
if s, err := res.query(ctx, rev, dns.TypeSOA); err == nil {
|
||||||
|
for _, sec := range [][]dns.RR{s.Answer, s.Ns} {
|
||||||
|
for _, rr := range sec {
|
||||||
|
if soa, ok := rr.(*dns.SOA); ok {
|
||||||
|
r.ReverseZone, r.ManagedBy = strings.TrimSuffix(soa.Hdr.Name, "."), managedBy(soa.Ns)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, rr := range in.Answer {
|
||||||
|
if p, ok := rr.(*dns.PTR); ok {
|
||||||
|
r.PTR = append(r.PTR, strings.TrimSuffix(strings.ToLower(p.Ptr), "."))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
where := "whoever runs the address's reverse zone (your hosting provider or ISP)"
|
||||||
|
if r.ManagedBy != "" {
|
||||||
|
where = "the reverse zone's owner (" + r.ManagedBy + ": your hosting provider's or ISP's panel)"
|
||||||
|
}
|
||||||
|
if len(r.PTR) == 0 {
|
||||||
|
r.Level = "warning"
|
||||||
|
r.Message = fmt.Sprintf("%s has no PTR record: many receivers reject or mark mail from it. Set its reverse DNS to %s at %s", t.Address, suggestName(t), where)
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
var notes []string
|
||||||
|
for _, name := range r.PTR {
|
||||||
|
as, alias, err := res.addresses(ctx, name)
|
||||||
|
if err != nil {
|
||||||
|
notes = append(notes, err.Error())
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if slices.Contains(as, a) {
|
||||||
|
r.OK = true
|
||||||
|
if alias {
|
||||||
|
notes = append(notes, name+" is an alias (CNAME): receivers expect the PTR to name a host with its own A/AAAA record")
|
||||||
|
}
|
||||||
|
if t.Host != "" && name != t.Host && strings.HasPrefix(strings.Join(t.Roles, " "), "MX") {
|
||||||
|
notes = append(notes, fmt.Sprintf("the PTR name is %s, not the MX name %s: fine if the mail server says %s in HELO", name, t.Host, name))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case !r.OK:
|
||||||
|
r.Level = "warning"
|
||||||
|
r.Message = fmt.Sprintf("the PTR record of %s names %s, which doesn't resolve back to %s: receivers check that it does. Point the PTR at a name with an A/AAAA record for %s (set at %s), or add that record", t.Address, strings.Join(r.PTR, ", "), t.Address, t.Address, where)
|
||||||
|
case len(notes) > 0:
|
||||||
|
r.Level, r.Message = "info", strings.Join(notes, "; ")
|
||||||
|
default:
|
||||||
|
r.Level, r.Message = "ok", fmt.Sprintf("%s → %s → %s", t.Address, strings.Join(r.PTR, ", "), t.Address)
|
||||||
|
}
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
func suggestName(t Target) string {
|
||||||
|
if t.Host != "" {
|
||||||
|
return t.Host
|
||||||
|
}
|
||||||
|
return "the host's name (with an A/AAAA record for it)"
|
||||||
|
}
|
||||||
|
|
||||||
|
// managedBy turns the reverse zone's primary name server into its owner's
|
||||||
|
// domain: ns1.linode.com -> linode.com, dns0.zen.co.uk -> zen.co.uk.
|
||||||
|
func managedBy(ns string) string {
|
||||||
|
ns = strings.TrimSuffix(strings.ToLower(ns), ".")
|
||||||
|
labels := strings.Split(ns, ".")
|
||||||
|
// The registered domain under the ICANN suffix: the list's private
|
||||||
|
// part (members.linode.com) names customers, not the provider.
|
||||||
|
for i := 1; i < len(labels); i++ {
|
||||||
|
if suffix, icann := publicsuffix.PublicSuffix(strings.Join(labels[i:], ".")); icann && suffix == strings.Join(labels[i:], ".") {
|
||||||
|
return strings.Join(labels[i-1:], ".")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ns
|
||||||
|
}
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
package mailcheck
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/miekg/dns"
|
||||||
|
|
||||||
|
"jarl/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
// fakeResolver answers from a fixed set of records, as a recursive
|
||||||
|
// resolver would, with the reverse zone's SOA in negative answers.
|
||||||
|
func fakeResolver(t *testing.T, records ...string) string {
|
||||||
|
t.Helper()
|
||||||
|
var rrs []dns.RR
|
||||||
|
for _, s := range records {
|
||||||
|
rr, err := dns.NewRR(s)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
rrs = append(rrs, rr)
|
||||||
|
}
|
||||||
|
soa, _ := dns.NewRR("2.0.192.in-addr.arpa. 3600 IN SOA ns1.linode.com. hostmaster.linode.com. 1 3600 900 1209600 300")
|
||||||
|
pc, err := net.ListenPacket("udp", "127.0.0.1:0")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
srv := &dns.Server{PacketConn: pc, Handler: dns.HandlerFunc(func(w dns.ResponseWriter, q *dns.Msg) {
|
||||||
|
m := new(dns.Msg)
|
||||||
|
m.SetReply(q)
|
||||||
|
qn := strings.ToLower(q.Question[0].Name)
|
||||||
|
for _, rr := range rrs {
|
||||||
|
if strings.ToLower(rr.Header().Name) == qn && (rr.Header().Rrtype == q.Question[0].Qtype || rr.Header().Rrtype == dns.TypeCNAME) {
|
||||||
|
m.Answer = append(m.Answer, rr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(m.Answer) == 0 && dns.IsSubDomain(soa.Header().Name, qn) {
|
||||||
|
if q.Question[0].Qtype == dns.TypeSOA && qn == soa.Header().Name {
|
||||||
|
m.Answer = append(m.Answer, soa)
|
||||||
|
} else {
|
||||||
|
m.Ns = append(m.Ns, soa)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
w.WriteMsg(m)
|
||||||
|
})}
|
||||||
|
go srv.ActivateAndServe()
|
||||||
|
t.Cleanup(func() { srv.Shutdown() })
|
||||||
|
return pc.LocalAddr().String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCheck(t *testing.T) {
|
||||||
|
st := model.NewState()
|
||||||
|
ext := &model.View{ID: 1, Name: "external", MatchClients: []string{"any"}}
|
||||||
|
in := &model.View{ID: 2, Name: "internal", MatchClients: []string{"lan"}}
|
||||||
|
st.Views[1], st.Views[2] = ext, in
|
||||||
|
z := &model.Zone{ID: 10, Name: "example.com.", ViewID: 1, Type: model.ZonePrimary}
|
||||||
|
zi := &model.Zone{ID: 11, Name: "example.com.", ViewID: 2, Type: model.ZonePrimary}
|
||||||
|
rev := &model.Zone{ID: 12, Name: "27.2.0.192.in-addr.arpa.", ViewID: 2, Type: model.ZonePrimary}
|
||||||
|
st.Zones[10], st.Zones[11], st.Zones[12] = z, zi, rev
|
||||||
|
rec := func(id, zone int64, name, typ, data string) {
|
||||||
|
st.Records[id] = &model.Record{ID: id, ZoneID: zone, Name: name, Type: typ, Data: data}
|
||||||
|
}
|
||||||
|
rec(1, 10, "example.com.", "MX", "10 mail.example.com.")
|
||||||
|
rec(2, 10, "example.com.", "TXT", `"v=spf1 mx ip4:192.0.2.26 ip4:192.0.2.27" " ip4:198.51.100.0/24 -all"`)
|
||||||
|
rec(3, 10, "www.example.com.", "MX", "10 ignored.example.com.") // not the apex
|
||||||
|
rec(4, 11, "example.com.", "MX", "10 internal.example.com.") // private view
|
||||||
|
rec(5, 10, "example.com.", "TXT", `"google-site-verification=x"`)
|
||||||
|
rec(6, 10, "example.com.", "MX", "20 backup.example.com.") // not on the internet
|
||||||
|
|
||||||
|
res := Resolver{Servers: []string{fakeResolver(t,
|
||||||
|
"mail.example.com. 300 IN A 192.0.2.25",
|
||||||
|
"25.2.0.192.in-addr.arpa. 300 IN PTR mail.example.com.",
|
||||||
|
"27.2.0.192.in-addr.arpa. 300 IN PTR li-27.members.linode.com.",
|
||||||
|
"li-27.members.linode.com. 300 IN A 192.0.2.99",
|
||||||
|
)}}
|
||||||
|
public := func(id int64) bool { return id == 1 }
|
||||||
|
rep := Check(context.Background(), st, public, res)
|
||||||
|
if len(rep.Errors) != 1 || !strings.Contains(rep.Errors[0], "backup.example.com (example.com, MX backup.example.com) has no address") {
|
||||||
|
t.Fatalf("errors %v", rep.Errors)
|
||||||
|
}
|
||||||
|
got := map[string]Result{}
|
||||||
|
for _, r := range rep.Results {
|
||||||
|
got[r.Address] = r
|
||||||
|
}
|
||||||
|
if len(got) != 3 {
|
||||||
|
t.Fatalf("results %+v", rep.Results)
|
||||||
|
}
|
||||||
|
if r := got["192.0.2.25"]; !r.OK || r.Level != "ok" || r.Roles[0] != "MX mail.example.com" || r.ManagedBy != "" && r.ManagedBy != "linode.com" {
|
||||||
|
t.Errorf("mail: %+v", r)
|
||||||
|
}
|
||||||
|
if r := got["192.0.2.26"]; r.OK || r.Level != "warning" || !strings.Contains(r.Message, "no PTR record") || r.ManagedBy != "linode.com" || r.ReverseZone != "2.0.192.in-addr.arpa" {
|
||||||
|
t.Errorf("no PTR: %+v", r)
|
||||||
|
}
|
||||||
|
if r := got["192.0.2.27"]; r.OK || !strings.Contains(r.Message, "doesn't resolve back") || r.LocalZone != "internal/27.2.0.192.in-addr.arpa" {
|
||||||
|
t.Errorf("not forward-confirmed: %+v", r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestManagedBy(t *testing.T) {
|
||||||
|
for in, want := range map[string]string{"ns1.linode.com.": "linode.com", "dns0.zen.co.uk.": "zen.co.uk", "ns1.members.linode.com.": "linode.com", "localhost.": "localhost"} {
|
||||||
|
if got := managedBy(in); got != want {
|
||||||
|
t.Errorf("managedBy(%q) = %q, want %q", in, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -204,7 +204,7 @@ func (s *State) SortedBlockRules() []*BlockRule {
|
|||||||
sort.Slice(out, func(i, j int) bool {
|
sort.Slice(out, func(i, j int) bool {
|
||||||
a, b := out[i], out[j]
|
a, b := out[i], out[j]
|
||||||
if a.Name != b.Name {
|
if a.Name != b.Name {
|
||||||
return CanonicalLess(a.Name+".", b.Name+".")
|
return NameLess(a.Name+".", b.Name+".")
|
||||||
}
|
}
|
||||||
if a.Exact != b.Exact {
|
if a.Exact != b.Exact {
|
||||||
return !a.Exact
|
return !a.Exact
|
||||||
|
|||||||
@@ -39,7 +39,8 @@ type Certificate struct {
|
|||||||
Comment string `json:"comment"`
|
Comment string `json:"comment"`
|
||||||
|
|
||||||
// The current certificate: acme source only (a file source's is read
|
// The current certificate: acme source only (a file source's is read
|
||||||
// from its files). The key never leaves the server through the API.
|
// from its files). The key leaves the server only through the
|
||||||
|
// administrators' export (GET /certificates/{name}/pem), audited.
|
||||||
CertPEM string `json:"-"`
|
CertPEM string `json:"-"`
|
||||||
KeyPEM string `json:"-"`
|
KeyPEM string `json:"-"`
|
||||||
|
|
||||||
@@ -61,11 +62,27 @@ type Certificate struct {
|
|||||||
// LastRoutes says where the challenges went the last time ("adbraun.lol: porkbun").
|
// LastRoutes says where the challenges went the last time ("adbraun.lol: porkbun").
|
||||||
LastRoutes []string `json:"last_routes,omitempty"`
|
LastRoutes []string `json:"last_routes,omitempty"`
|
||||||
|
|
||||||
|
// OwnerID is the user a certificate for another host belongs to
|
||||||
|
// (jarl cert get); 0 for the administrators' own.
|
||||||
|
OwnerID int64 `json:"owner_id,omitempty"`
|
||||||
|
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
CreatedBy string `json:"created_by"`
|
CreatedBy string `json:"created_by"`
|
||||||
UpdatedBy string `json:"updated_by"`
|
UpdatedBy string `json:"updated_by"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CertificatePull records that someone downloaded a certificate: who
|
||||||
|
// ("token NAME" or "user NAME"), which one (Serial), when and from where.
|
||||||
|
type CertificatePull struct {
|
||||||
|
CertID int64 `json:"-"`
|
||||||
|
Who string `json:"who"`
|
||||||
|
UserID int64 `json:"user_id,omitempty"`
|
||||||
|
TokenID int64 `json:"token_id,omitempty"`
|
||||||
|
Serial string `json:"serial"`
|
||||||
|
IP string `json:"ip,omitempty"`
|
||||||
|
At time.Time `json:"at"`
|
||||||
|
}
|
||||||
|
|
||||||
// HasUse reports whether c is used for use.
|
// HasUse reports whether c is used for use.
|
||||||
func (c *Certificate) HasUse(use string) bool { return slices.Contains(c.Uses, use) }
|
func (c *Certificate) HasUse(use string) bool { return slices.Contains(c.Uses, use) }
|
||||||
@@ -76,6 +76,9 @@ type User struct {
|
|||||||
TOTPSecret string `json:"-"`
|
TOTPSecret string `json:"-"`
|
||||||
TOTPPending string `json:"-"`
|
TOTPPending string `json:"-"`
|
||||||
TOTPLastStep int64 `json:"-"`
|
TOTPLastStep int64 `json:"-"`
|
||||||
|
// CertLimit is how many certificates the user may own (jarl cert get);
|
||||||
|
// nil: certificates.max_per_user in jarl.yaml.
|
||||||
|
CertLimit *int `json:"cert_limit,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// HasTOTP reports whether the user logs in with a TOTP code.
|
// HasTOTP reports whether the user logs in with a TOTP code.
|
||||||
|
|||||||
+56
-10
@@ -54,6 +54,11 @@ type View struct {
|
|||||||
// internet (docs/exposure.md): "" works it out, ExposurePublic or
|
// internet (docs/exposure.md): "" works it out, ExposurePublic or
|
||||||
// ExposurePrivate say so.
|
// ExposurePrivate say so.
|
||||||
Exposure string `json:"exposure,omitempty"`
|
Exposure string `json:"exposure,omitempty"`
|
||||||
|
// ReverseSuggestionsOff stops suggesting reverse zones for the view's
|
||||||
|
// addresses (docs/reverse.md), e.g. for a view only a few servers use;
|
||||||
|
// DismissedReverse are the networks whose suggestion was dismissed.
|
||||||
|
ReverseSuggestionsOff bool `json:"reverse_suggestions_off,omitempty"`
|
||||||
|
DismissedReverse []string `json:"dismissed_reverse,omitempty"`
|
||||||
// Acknowledged are exposure findings accepted on purpose; they are
|
// Acknowledged are exposure findings accepted on purpose; they are
|
||||||
// shown as info instead of warnings.
|
// shown as info instead of warnings.
|
||||||
Acknowledged []Acknowledgement `json:"acknowledged,omitempty"`
|
Acknowledged []Acknowledgement `json:"acknowledged,omitempty"`
|
||||||
@@ -170,6 +175,10 @@ type Zone struct {
|
|||||||
// keeps their shared records in step (docs/views.md#linked-zones). 0
|
// keeps their shared records in step (docs/views.md#linked-zones). 0
|
||||||
// for a zone that isn't linked; linked zones share the ID.
|
// for a zone that isn't linked; linked zones share the ID.
|
||||||
LinkGroup int64 `json:"link_group,omitempty"`
|
LinkGroup int64 `json:"link_group,omitempty"`
|
||||||
|
// AutoPTR keeps the PTR records of the zone's A and AAAA records in
|
||||||
|
// step with them, in the reverse zones of the zone's views, as if every
|
||||||
|
// change asked for it (docs/reverse.md).
|
||||||
|
AutoPTR bool `json:"auto_ptr,omitempty"`
|
||||||
Meta
|
Meta
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -263,6 +272,12 @@ type Settings struct {
|
|||||||
// for server-wide findings such as a visible version): see View.
|
// for server-wide findings such as a visible version): see View.
|
||||||
Exposure string `json:"exposure,omitempty"`
|
Exposure string `json:"exposure,omitempty"`
|
||||||
Acknowledged []Acknowledgement `json:"acknowledged,omitempty"`
|
Acknowledged []Acknowledgement `json:"acknowledged,omitempty"`
|
||||||
|
// DismissedReverse are dismissed reverse zone suggestions (without
|
||||||
|
// views; see View).
|
||||||
|
DismissedReverse []string `json:"dismissed_reverse,omitempty"`
|
||||||
|
// EncryptedDNSHosts are the host names clients use for DNS over TLS
|
||||||
|
// and HTTPS (docs/encrypted-dns.md); the first is checked first.
|
||||||
|
EncryptedDNSHosts []string `json:"encrypted_dns_hosts,omitempty"`
|
||||||
Version int `json:"version"`
|
Version int `json:"version"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -509,14 +524,14 @@ func (s *State) SortedZones() []*Zone {
|
|||||||
if pi, pj := pos[out[i].ViewID], pos[out[j].ViewID]; pi != pj {
|
if pi, pj := pos[out[i].ViewID], pos[out[j].ViewID]; pi != pj {
|
||||||
return pi < pj
|
return pi < pj
|
||||||
}
|
}
|
||||||
return CanonicalLess(out[i].Name, out[j].Name)
|
return NameLess(out[i].Name, out[j].Name)
|
||||||
})
|
})
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
// SortZones sorts zones by name in DNS canonical order.
|
// SortZones sorts zones by name (see NameLess).
|
||||||
func SortZones(zs []*Zone) {
|
func SortZones(zs []*Zone) {
|
||||||
sort.Slice(zs, func(i, j int) bool { return CanonicalLess(zs[i].Name, zs[j].Name) })
|
sort.Slice(zs, func(i, j int) bool { return NameLess(zs[i].Name, zs[j].Name) })
|
||||||
}
|
}
|
||||||
|
|
||||||
// FindZone finds a zone by name within a view (viewID 0 for no view).
|
// FindZone finds a zone by name within a view (viewID 0 for no view).
|
||||||
@@ -631,7 +646,7 @@ func typeOrder(t string) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// SortRecords sorts records in zone-file order: apex first, then names in
|
// SortRecords sorts records in zone-file order: apex first, then names in
|
||||||
// DNS canonical order (so subdomains group together), then by type and
|
// NameLess order (so subdomains group together), then by type and
|
||||||
// data.
|
// data.
|
||||||
func SortRecords(rs []*Record, z *Zone) {
|
func SortRecords(rs []*Record, z *Zone) {
|
||||||
apex := ""
|
apex := ""
|
||||||
@@ -647,7 +662,7 @@ func SortRecords(rs []*Record, z *Zone) {
|
|||||||
if b.Name == apex {
|
if b.Name == apex {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
return CanonicalLess(a.Name, b.Name)
|
return NameLess(a.Name, b.Name)
|
||||||
}
|
}
|
||||||
if a.Type != b.Type {
|
if a.Type != b.Type {
|
||||||
return typeOrder(a.Type) < typeOrder(b.Type)
|
return typeOrder(a.Type) < typeOrder(b.Type)
|
||||||
@@ -659,19 +674,50 @@ func SortRecords(rs []*Record, z *Zone) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// CanonicalLess compares names in DNS canonical order (RFC 4034 section
|
// NameLess compares names label by label from the right, like DNS
|
||||||
// 6.1): by label from the right.
|
// canonical order (RFC 4034 section 6.1), except that two all-digit
|
||||||
func CanonicalLess(a, b string) bool {
|
// labels compare as numbers, so 2.10.0.10.in-addr.arpa comes before
|
||||||
|
// 101.10.0.10.in-addr.arpa. It orders names for people and zone files;
|
||||||
|
// BIND does its own canonical ordering where DNSSEC needs it.
|
||||||
|
func NameLess(a, b string) bool {
|
||||||
la := strings.Split(strings.TrimSuffix(a, "."), ".")
|
la := strings.Split(strings.TrimSuffix(a, "."), ".")
|
||||||
lb := strings.Split(strings.TrimSuffix(b, "."), ".")
|
lb := strings.Split(strings.TrimSuffix(b, "."), ".")
|
||||||
for i, j := len(la)-1, len(lb)-1; i >= 0 && j >= 0; i, j = i-1, j-1 {
|
for i, j := len(la)-1, len(lb)-1; i >= 0 && j >= 0; i, j = i-1, j-1 {
|
||||||
if la[i] != lb[j] {
|
if c := compareLabel(la[i], lb[j]); c != 0 {
|
||||||
return la[i] < lb[j]
|
return c < 0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return len(la) < len(lb)
|
return len(la) < len(lb)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func compareLabel(a, b string) int {
|
||||||
|
if a == b {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
if allDigits(a) && allDigits(b) {
|
||||||
|
na, nb := strings.TrimLeft(a, "0"), strings.TrimLeft(b, "0")
|
||||||
|
if len(na) != len(nb) {
|
||||||
|
return len(na) - len(nb)
|
||||||
|
}
|
||||||
|
if c := strings.Compare(na, nb); c != 0 {
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return strings.Compare(a, b)
|
||||||
|
}
|
||||||
|
|
||||||
|
func allDigits(s string) bool {
|
||||||
|
if s == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for i := 0; i < len(s); i++ {
|
||||||
|
if s[i] < '0' || s[i] > '9' {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
// RRSetKey identifies an RRset.
|
// RRSetKey identifies an RRset.
|
||||||
type RRSetKey struct {
|
type RRSetKey struct {
|
||||||
Name string
|
Name string
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import (
|
||||||
|
"slices"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestNameLess(t *testing.T) {
|
||||||
|
want := []string{
|
||||||
|
"10.0.10.in-addr.arpa.",
|
||||||
|
"1.10.0.10.in-addr.arpa.",
|
||||||
|
"02.10.0.10.in-addr.arpa.",
|
||||||
|
"2.10.0.10.in-addr.arpa.",
|
||||||
|
"10.10.0.10.in-addr.arpa.",
|
||||||
|
"101.10.0.10.in-addr.arpa.",
|
||||||
|
"x.101.10.0.10.in-addr.arpa.",
|
||||||
|
"254.10.0.10.in-addr.arpa.",
|
||||||
|
"a.10.0.10.in-addr.arpa.",
|
||||||
|
}
|
||||||
|
got := slices.Clone(want)
|
||||||
|
slices.Reverse(got)
|
||||||
|
slices.SortFunc(got, func(a, b string) int {
|
||||||
|
if NameLess(a, b) {
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
if NameLess(b, a) {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
})
|
||||||
|
if !slices.Equal(got, want) {
|
||||||
|
t.Fatalf("got %v\nwant %v", got, want)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Non-numeric labels keep canonical (byte) order.
|
||||||
|
if !NameLess("host10.example.", "host2.example.") {
|
||||||
|
t.Error("host10 should sort before host2")
|
||||||
|
}
|
||||||
|
if !NameLess("example.", "www.example.") {
|
||||||
|
t.Error("parent should sort before child")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -182,3 +182,38 @@ func writeItemsInline(sb *strings.Builder, items []Item) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// FormatValueList is FormatValue, except that a value whose block is a
|
||||||
|
// plain list (no nested blocks) of at least min elements is laid out one
|
||||||
|
// element per line, for reading: allow-query, exempt-clients, forwarders.
|
||||||
|
func FormatValueList(name, value string, min int) string {
|
||||||
|
st, err := ParseOptionValue(name, value)
|
||||||
|
if err != nil {
|
||||||
|
return FormatValue(name, value)
|
||||||
|
}
|
||||||
|
items := st.Items[1:]
|
||||||
|
blocks := 0
|
||||||
|
var list *Block
|
||||||
|
for _, it := range items {
|
||||||
|
if it.Block != nil {
|
||||||
|
blocks++
|
||||||
|
list = it.Block
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if blocks != 1 || len(list.Stmts) < min || blockHasBlocks(list) {
|
||||||
|
return FormatValue(name, value)
|
||||||
|
}
|
||||||
|
var sb strings.Builder
|
||||||
|
for _, it := range items {
|
||||||
|
if it.Block == nil {
|
||||||
|
sb.WriteString(Compact([]Item{it}) + " ")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
sb.WriteString("{\n")
|
||||||
|
for _, s := range list.Stmts {
|
||||||
|
sb.WriteString("\t" + Compact(s.Items) + ";\n")
|
||||||
|
}
|
||||||
|
sb.WriteString("} ")
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(sb.String())
|
||||||
|
}
|
||||||
@@ -16,3 +16,23 @@ func TestFormatValue(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestFormatValueList(t *testing.T) {
|
||||||
|
cases := []struct{ name, value, want string }{
|
||||||
|
{"allow-query", "{ homelab; localhost; 10.0.0.0/8; }", "{\n\thomelab;\n\tlocalhost;\n\t10.0.0.0/8;\n}"},
|
||||||
|
{"allow-query", "{ homelab; localhost; }", "{ homelab; localhost; }"}, // short: as it was
|
||||||
|
{"listen-on", "port 853 tls dot { 10.0.0.1; 10.0.0.2; 10.0.0.3; }", "port 853 tls dot {\n\t10.0.0.1;\n\t10.0.0.2;\n\t10.0.0.3;\n}"},
|
||||||
|
{"allow-transfer", "{ !10.0.0.9; key xfer; 10.0.0.0/8; }", "{\n\t!10.0.0.9;\n\tkey xfer;\n\t10.0.0.0/8;\n}"},
|
||||||
|
{"recursion", "yes", "yes"},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
if got := FormatValueList(c.name, c.value, 3); got != c.want {
|
||||||
|
t.Errorf("%s %q:\n got %q\nwant %q", c.name, c.value, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// A nested block (rate-limit with exempt-clients) keeps FormatValue's layout.
|
||||||
|
v := "{ responses-per-second 10; log-only yes; exempt-clients { 192.0.2.1; 192.0.2.2; 192.0.2.3; }; }"
|
||||||
|
if got := FormatValueList("rate-limit", v, 3); got != FormatValue("rate-limit", v) {
|
||||||
|
t.Errorf("nested: %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
package render
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"jarl/internal/model"
|
||||||
|
"jarl/internal/namedconf"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Generated is a statement Jarl adds to named's configuration by itself,
|
||||||
|
// not from an option you set: why, and what controls it.
|
||||||
|
type Generated struct {
|
||||||
|
Where string `json:"where"` // top level, options, or "view NAME"
|
||||||
|
Text string `json:"text"` // as written in named.conf
|
||||||
|
Why string `json:"why"` // what it is for
|
||||||
|
Setting string `json:"setting"` // what turns it on or off
|
||||||
|
}
|
||||||
|
|
||||||
|
// GeneratedStatements lists what Render adds to named.conf beyond the
|
||||||
|
// options in the database, so nothing there comes as a surprise.
|
||||||
|
func GeneratedStatements(s *model.State, p Paths, x Extras) []Generated {
|
||||||
|
var out []Generated
|
||||||
|
add := func(where, text, why, setting string) {
|
||||||
|
out = append(out, Generated{Where: where, Text: strings.TrimRight(text, "\n"), Why: why, Setting: setting})
|
||||||
|
}
|
||||||
|
add("top level", "include "+namedconf.QuoteString(p.KeysConfig())+";",
|
||||||
|
"TSIG keys (and the view keys) live in their own file, which only named and jarld can read", "always")
|
||||||
|
st := x.Stats
|
||||||
|
logResponses := st != nil && st.LogFile != ""
|
||||||
|
pzs := PolicyZones(s, p)
|
||||||
|
if st != nil {
|
||||||
|
_, user := findTop(s, "statistics-channels")
|
||||||
|
why := "named publishes its counters for jarld's query statistics, on a loopback address only"
|
||||||
|
if user {
|
||||||
|
why += " (added to your statistics-channels statement)"
|
||||||
|
}
|
||||||
|
add("top level", formatStmt(namedconf.NewStmt("statistics-channels", namedconf.NewBlock(st.statsInet())), 0), why, "stats.enabled in jarl.yaml")
|
||||||
|
}
|
||||||
|
if logResponses {
|
||||||
|
b := namedconf.NewBlock(st.logChannel())
|
||||||
|
cat := func(name string, extra ...string) {
|
||||||
|
ch := namedconf.NewBlock(namedconf.NewStmt(StatsChannelName))
|
||||||
|
for _, e := range extra {
|
||||||
|
ch.Stmts = append(ch.Stmts, namedconf.NewStmt(e))
|
||||||
|
}
|
||||||
|
b.Stmts = append(b.Stmts, namedconf.NewStmt("category", name, ch))
|
||||||
|
}
|
||||||
|
why := []string{"responses: every answer, for the statistics' names, clients and views"}
|
||||||
|
cat("responses")
|
||||||
|
if len(pzs) > 0 {
|
||||||
|
cat("rpz")
|
||||||
|
why = append(why, "rpz: what blocking blocked")
|
||||||
|
}
|
||||||
|
if logRateLimit(s, st) {
|
||||||
|
cat("query-errors", "default_syslog")
|
||||||
|
why = append(why, "query-errors: what rate limiting limited, or would in log-only mode, for the Exposure page (also to syslog, as before)")
|
||||||
|
}
|
||||||
|
w := "named logs to a file jarld reads (" + strings.Join(why, "; ") + ")"
|
||||||
|
if _, user := findTop(s, "logging"); user {
|
||||||
|
w += "; merged into your logging statement"
|
||||||
|
}
|
||||||
|
setting := "stats.query_log in jarl.yaml"
|
||||||
|
if logRateLimit(s, st) || hasRateLimit(s) {
|
||||||
|
setting += "; query-errors: stats.rate_limit_log"
|
||||||
|
}
|
||||||
|
add("top level", formatStmt(namedconf.NewStmt("logging", b), 0), w, setting)
|
||||||
|
if s.FindOption(model.ScopeOptions, 0, "responselog") == nil {
|
||||||
|
add("options", "responselog yes;", "named logs answers from the start, for the statistics", "stats.query_log in jarl.yaml")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
views := s.OrderedViews()
|
||||||
|
scopes := []struct {
|
||||||
|
id int64
|
||||||
|
name string
|
||||||
|
}{{0, "options"}}
|
||||||
|
if len(views) > 0 {
|
||||||
|
scopes = scopes[:0]
|
||||||
|
for _, v := range views {
|
||||||
|
scopes = append(scopes, struct {
|
||||||
|
id int64
|
||||||
|
name string
|
||||||
|
}{v.ID, "view " + v.Name})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, sc := range scopes {
|
||||||
|
zs := scopeZones(pzs, sc.id)
|
||||||
|
if len(zs) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
w := newConfWriter()
|
||||||
|
writeResponsePolicy(w, zs, logResponses)
|
||||||
|
writePolicyZones(w, s, zs, &Output{}, 0)
|
||||||
|
add(sc.name, dedent(w.sb.String()), "blocking: your rules and blocklists, as response policy zones named loads", "the Blocking page (jarl block, jarl blocklist)")
|
||||||
|
}
|
||||||
|
if viewKeysOn(s, Extras{ViewKeySeed: x.ViewKeySeed}) {
|
||||||
|
for _, v := range views {
|
||||||
|
mc := viewMatchClients(s, x, v)
|
||||||
|
var keys []string
|
||||||
|
for _, e := range mc {
|
||||||
|
if strings.Contains(e, "key ") {
|
||||||
|
keys = append(keys, e+";")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
add("view "+v.Name, "match-clients { "+strings.Join(keys, " ")+" ... };",
|
||||||
|
"view keys: a query signed with a view's key gets that view, from any address, and never another view (jarl view key)", "always, when there are views")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func findTop(s *model.State, name string) (*model.Option, bool) {
|
||||||
|
for _, o := range s.ScopedOptions(model.ScopeTopLevel, 0) {
|
||||||
|
if o.Name == name {
|
||||||
|
return o, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
|
||||||
|
func hasRateLimit(s *model.State) bool {
|
||||||
|
for _, o := range s.Options {
|
||||||
|
if o.Name == "rate-limit" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// logRateLimit reports whether rate limiting's log lines go to the
|
||||||
|
// response log (docs/exposure.md).
|
||||||
|
func logRateLimit(s *model.State, st *StatsHooks) bool {
|
||||||
|
return st != nil && st.LogFile != "" && !st.NoRateLimitLog && hasRateLimit(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
// dedent removes one leading tab from every line.
|
||||||
|
func dedent(s string) string {
|
||||||
|
lines := strings.Split(s, "\n")
|
||||||
|
for i, l := range lines {
|
||||||
|
lines[i] = strings.TrimPrefix(l, "\t")
|
||||||
|
}
|
||||||
|
return strings.Join(lines, "\n")
|
||||||
|
}
|
||||||
@@ -274,10 +274,7 @@ func renderConfig(s *model.State, p Paths, x Extras, out *Output) (string, []Spa
|
|||||||
logRewrites := logResponses && len(pzs) > 0
|
logRewrites := logResponses && len(pzs) > 0
|
||||||
// Rate limiting logs what it limits (or would, in log-only mode) as
|
// Rate limiting logs what it limits (or would, in log-only mode) as
|
||||||
// query-errors; the statistics count it (docs/exposure.md).
|
// query-errors; the statistics count it (docs/exposure.md).
|
||||||
logRRL := false
|
logRRL := logRateLimit(s, st)
|
||||||
for _, o := range s.Options {
|
|
||||||
logRRL = logRRL || logResponses && o.Name == "rate-limit"
|
|
||||||
}
|
|
||||||
sawStats, sawLogging := false, false
|
sawStats, sawLogging := false, false
|
||||||
if top := s.ScopedOptions(model.ScopeTopLevel, 0); len(top) > 0 {
|
if top := s.ScopedOptions(model.ScopeTopLevel, 0); len(top) > 0 {
|
||||||
w.write("\n")
|
w.write("\n")
|
||||||
|
|||||||
@@ -26,6 +26,9 @@ type StatsHooks struct {
|
|||||||
LogFile string // response log; "" disables response logging
|
LogFile string // response log; "" disables response logging
|
||||||
LogVersions int
|
LogVersions int
|
||||||
LogSize string
|
LogSize string
|
||||||
|
// NoRateLimitLog keeps rate limiting's log lines (category
|
||||||
|
// query-errors) out of the response log (stats.rate_limit_log: false).
|
||||||
|
NoRateLimitLog bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// StatsChannelName is the logging channel Jarl adds for responses.
|
// StatsChannelName is the logging channel Jarl adds for responses.
|
||||||
|
|||||||
@@ -82,3 +82,45 @@ func TestStatsHooksMerged(t *testing.T) {
|
|||||||
func TestStatsHooksWithNamed(t *testing.T) {
|
func TestStatsHooksWithNamed(t *testing.T) {
|
||||||
checkWithNamed(t, sampleState(t), Extras{Stats: statsHooks()})
|
checkWithNamed(t, sampleState(t), Extras{Stats: statsHooks()})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Rate limiting's log lines go to the response log unless
|
||||||
|
// stats.rate_limit_log is off; the list of what Jarl adds says so.
|
||||||
|
func TestRateLimitLogAndGenerated(t *testing.T) {
|
||||||
|
s := sampleState(t)
|
||||||
|
for id, o := range s.Options {
|
||||||
|
if o.Name == "logging" {
|
||||||
|
delete(s.Options, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s.Options[20000] = &model.Option{ID: 20000, Scope: model.ScopeOptions, Name: "rate-limit", Value: "{ responses-per-second 10; log-only yes; }", Position: 20000}
|
||||||
|
p := Paths{ConfigDir: "/etc/bind/jarl", ZoneDir: "/var/lib/bind/jarl", CacheDir: "/var/cache/bind/jarl"}
|
||||||
|
conf := string(Render(s, p, Extras{Stats: statsHooks()}).File(p.MainConfig()).Content)
|
||||||
|
if !strings.Contains(conf, "category query-errors { jarl_responses; default_syslog; };") {
|
||||||
|
t.Errorf("query-errors not logged:\n%s", conf)
|
||||||
|
}
|
||||||
|
gen := GeneratedStatements(s, p, Extras{Stats: statsHooks()})
|
||||||
|
var logging *Generated
|
||||||
|
for i := range gen {
|
||||||
|
if strings.HasPrefix(gen[i].Text, "logging") {
|
||||||
|
logging = &gen[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if logging == nil || !strings.Contains(logging.Text, "query-errors") || !strings.Contains(logging.Setting, "stats.rate_limit_log") {
|
||||||
|
t.Errorf("generated logging = %+v (all %+v)", logging, gen)
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(gen[0].Text, "include ") || gen[0].Where != "top level" {
|
||||||
|
t.Errorf("first generated = %+v", gen[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
h := statsHooks()
|
||||||
|
h.NoRateLimitLog = true
|
||||||
|
conf = string(Render(s, p, Extras{Stats: h}).File(p.MainConfig()).Content)
|
||||||
|
if strings.Contains(conf, "query-errors") || !strings.Contains(conf, "category responses") {
|
||||||
|
t.Errorf("rate_limit_log off but query-errors logged:\n%s", conf)
|
||||||
|
}
|
||||||
|
for _, g := range GeneratedStatements(s, p, Extras{Stats: h}) {
|
||||||
|
if strings.Contains(g.Text, "query-errors") {
|
||||||
|
t.Errorf("listed while off: %+v", g)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/tls"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"jarl/internal/auth"
|
||||||
|
"jarl/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
// certUser creates a service user with grants and returns a token.
|
||||||
|
func certUser(t *testing.T, e *apiEnv, name string, grants ...string) (string, *model.User) {
|
||||||
|
t.Helper()
|
||||||
|
gs := []model.Grant{}
|
||||||
|
for _, s := range grants {
|
||||||
|
g, err := auth.ParseGrant(s)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
gs = append(gs, g)
|
||||||
|
}
|
||||||
|
u := &model.User{Username: name, Grants: gs}
|
||||||
|
if err := e.st.CreateUser(context.Background(), u); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
tok, pid, sh := auth.NewToken()
|
||||||
|
if err := e.st.CreateToken(context.Background(), &model.Token{UserID: u.ID, Name: name + "-host", PublicID: pid, SecretHash: sh}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return tok, u
|
||||||
|
}
|
||||||
|
|
||||||
|
// obtained gives a certificate a current certificate, as if the CA had
|
||||||
|
// signed it.
|
||||||
|
func obtained(t *testing.T, e *apiEnv, name string, domains ...string) {
|
||||||
|
t.Helper()
|
||||||
|
certFile, keyFile := selfSigned(t, t.TempDir(), domains...)
|
||||||
|
cb, _ := os.ReadFile(certFile)
|
||||||
|
kb, _ := os.ReadFile(keyFile)
|
||||||
|
ctx := context.Background()
|
||||||
|
for i := 0; i < 50 && e.srv.cfg.Certs.Issuing(name) != (time.Time{}); i++ {
|
||||||
|
time.Sleep(20 * time.Millisecond) // the failing attempt (no CA) ends
|
||||||
|
}
|
||||||
|
c, err := e.st.CertificateByName(ctx, name)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
na, nb := time.Now().Add(30*24*time.Hour), time.Now()
|
||||||
|
c.CertPEM, c.KeyPEM, c.NotAfter, c.NotBefore, c.Serial, c.Failures, c.LastError = string(cb), string(kb), &na, &nb, "s-"+name, 0, ""
|
||||||
|
if err := e.st.UpdateCertificate(ctx, c); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCertGet(t *testing.T) {
|
||||||
|
e := newAPIEnv(t)
|
||||||
|
withCerts(e)
|
||||||
|
mx, mxUser := certUser(t, e, "mx", "certificates:write names=*.adbraun.lol")
|
||||||
|
other, _ := certUser(t, e, "other", "certificates:write names=*.adbraun.lol")
|
||||||
|
wide, _ := certUser(t, e, "wide", "*:write")
|
||||||
|
|
||||||
|
req := func(tok, name string, domains ...string) (map[string]any, int) {
|
||||||
|
b, status := e.req("POST", "/certificates/"+name+"/request", tok, map[string]any{"domains": domains})
|
||||||
|
var out map[string]any
|
||||||
|
_ = jsonUnmarshal(b, &out)
|
||||||
|
return out, status
|
||||||
|
}
|
||||||
|
// "*" doesn't cover certificates.
|
||||||
|
if _, st := req(wide, "mail", "mail.adbraun.lol"); st != 403 {
|
||||||
|
t.Errorf("*:write: %d", st)
|
||||||
|
}
|
||||||
|
// Names outside the grant.
|
||||||
|
if out, st := req(mx, "mail", "mail.example.org"); st != 403 || !strings.Contains(errMsg(out), "mail.example.org") {
|
||||||
|
t.Errorf("other names: %d %v", st, out)
|
||||||
|
}
|
||||||
|
// A new certificate, owned by mx.
|
||||||
|
if out, st := req(mx, "mail", "mail.adbraun.lol"); st != 200 && st != 202 || out["created"] != true {
|
||||||
|
t.Fatalf("create: %d %v", st, out)
|
||||||
|
}
|
||||||
|
c, _ := e.st.CertificateByName(context.Background(), "mail")
|
||||||
|
if c.OwnerID != mxUser.ID || len(c.Uses) != 0 {
|
||||||
|
t.Errorf("owner %d uses %v", c.OwnerID, c.Uses)
|
||||||
|
}
|
||||||
|
// Another user can't have the name, or see it.
|
||||||
|
if out, st := req(other, "mail", "mail.adbraun.lol"); st != 409 || !strings.Contains(errMsg(out), "taken") {
|
||||||
|
t.Errorf("taken: %d %v", st, out)
|
||||||
|
}
|
||||||
|
e.do("GET", "/certificates/mail", other, nil, 404)
|
||||||
|
if l := e.do("GET", "/certificates", other, nil, 200)["certificates"].([]any); len(l) != 0 {
|
||||||
|
t.Errorf("other's list: %v", l)
|
||||||
|
}
|
||||||
|
// Nothing to download until it is obtained.
|
||||||
|
e.do("GET", "/certificates/mail/pem", mx, nil, 400)
|
||||||
|
obtained(t, e, "mail", "mail.adbraun.lol")
|
||||||
|
|
||||||
|
// The same names: the same certificate.
|
||||||
|
if out, st := req(mx, "mail", "mail.adbraun.lol"); st != 200 || out["created"] != false || out["changed"] != false {
|
||||||
|
t.Errorf("again: %d %v", st, out)
|
||||||
|
}
|
||||||
|
if c2, _ := e.st.CertificateByName(context.Background(), "mail"); c2.Serial != "s-mail" {
|
||||||
|
t.Errorf("replaced: %v", c2.Serial)
|
||||||
|
}
|
||||||
|
// Download: recorded, with the token.
|
||||||
|
if b, st := e.req("GET", "/certificates/mail/pem?part=key", mx, nil); st != 200 || !strings.Contains(string(b), "PRIVATE KEY") {
|
||||||
|
t.Errorf("key: %d", st)
|
||||||
|
}
|
||||||
|
got := e.do("GET", "/certificates/mail", e.token, nil, 200)
|
||||||
|
pulls, _ := got["pulled_by"].([]any)
|
||||||
|
if got["owner"] != "mx" || len(pulls) != 1 || !strings.Contains(pulls[0].(map[string]any)["who"].(string), "mx-host") || pulls[0].(map[string]any)["serial"] != "s-mail" {
|
||||||
|
t.Errorf("owner %v pulled_by %v", got["owner"], got["pulled_by"])
|
||||||
|
}
|
||||||
|
// A name added: obtained again.
|
||||||
|
if out, st := req(mx, "mail", "mail.adbraun.lol", "smtp.adbraun.lol"); out["changed"] != true || st != 200 && st != 202 {
|
||||||
|
t.Errorf("SAN added: %d %v", st, out)
|
||||||
|
}
|
||||||
|
// The owner's own list.
|
||||||
|
if l := e.do("GET", "/certificates", mx, nil, 200)["certificates"].([]any); len(l) != 1 {
|
||||||
|
t.Errorf("mx's list: %v", l)
|
||||||
|
}
|
||||||
|
// Jarl's own certificates are the administrators'.
|
||||||
|
certFile, keyFile := selfSigned(t, t.TempDir(), "jarl.adbraun.lol")
|
||||||
|
e.do("POST", "/certificates", e.token, map[string]any{"name": "web", "source": "file", "cert_file": certFile, "key_file": keyFile}, 201)
|
||||||
|
if _, st := req(mx, "web", "jarl.adbraun.lol"); st != 409 {
|
||||||
|
t.Errorf("Jarl's own: %d", st)
|
||||||
|
}
|
||||||
|
e.do("GET", "/certificates/web/pem", mx, nil, 404)
|
||||||
|
// The limit: one more for mx, then no more.
|
||||||
|
limit := 2
|
||||||
|
mxUser.CertLimit = &limit
|
||||||
|
if err := e.st.UpdateUser(context.Background(), mxUser); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if out, st := req(mx, "www", "www.adbraun.lol"); st != 200 && st != 202 {
|
||||||
|
t.Errorf("second: %d %v", st, out)
|
||||||
|
}
|
||||||
|
if out, st := req(mx, "third", "x.adbraun.lol"); st != 400 || !strings.Contains(errMsg(out), "limit") {
|
||||||
|
t.Errorf("over the limit: %d %v", st, out)
|
||||||
|
}
|
||||||
|
// The owner may delete theirs.
|
||||||
|
e.do("DELETE", "/certificates/www", mx, nil, 200)
|
||||||
|
}
|
||||||
|
|
||||||
|
func jsonUnmarshal(b []byte, v any) error { return json.Unmarshal(b, v) }
|
||||||
|
|
||||||
|
// errMsg is an error answer's message.
|
||||||
|
func errMsg(out map[string]any) string {
|
||||||
|
e, _ := out["error"].(map[string]any)
|
||||||
|
m, _ := e["message"].(string)
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
// A private key goes back only over HTTPS or to this server itself.
|
||||||
|
func TestKeyTransport(t *testing.T) {
|
||||||
|
e := newAPIEnv(t)
|
||||||
|
for addr, want := range map[string]bool{"127.0.0.1:5000": true, "[::1]:5000": true, "192.0.2.7:5000": false} {
|
||||||
|
r, _ := http.NewRequest("GET", "/x", nil)
|
||||||
|
r.RemoteAddr = addr
|
||||||
|
if ok, why := e.srv.keyTransport(r); ok != want {
|
||||||
|
t.Errorf("%s: %v (%s)", addr, ok, why)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
r, _ := http.NewRequest("GET", "/x", nil)
|
||||||
|
r.RemoteAddr, r.TLS = "192.0.2.7:5000", &tls.ConnectionState{}
|
||||||
|
if ok, _ := e.srv.keyTransport(r); !ok {
|
||||||
|
t.Error("HTTPS refused")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -45,7 +45,7 @@ func selfSigned(t *testing.T, dir string, names ...string) (certFile, keyFile st
|
|||||||
|
|
||||||
func withCerts(env *apiEnv) *certs.Manager {
|
func withCerts(env *apiEnv) *certs.Manager {
|
||||||
quiet := slog.New(slog.NewTextHandler(io.Discard, nil))
|
quiet := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||||
m := certs.New(certs.Config{Dir: filepath.Join(env.t.TempDir(), "tls"), Directory: "https://acme.invalid/dir"}, env.st, env.srv.eng, quiet)
|
m := certs.New(certs.Config{Dir: filepath.Join(env.t.TempDir(), "tls"), Directory: "https://acme.invalid/dir", MaxPerUser: 10}, env.st, env.srv.eng, quiet)
|
||||||
env.srv.cfg.Certs = m
|
env.srv.cfg.Certs = m
|
||||||
return m
|
return m
|
||||||
}
|
}
|
||||||
@@ -165,3 +165,39 @@ func TestHTTPSAndPlainOnOnePort(t *testing.T) {
|
|||||||
t.Errorf("no certificate: %d %s", rec.Code, rec.Body.String())
|
t.Errorf("no certificate: %d %s", rec.Code, rec.Body.String())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A certificate's files can be exported for use elsewhere; the key only
|
||||||
|
// by an administrator, audited.
|
||||||
|
func TestCertificateExport(t *testing.T) {
|
||||||
|
env := newAPIEnv(t)
|
||||||
|
withCerts(env)
|
||||||
|
certFile, keyFile := selfSigned(t, t.TempDir(), "mail.example.com")
|
||||||
|
env.do("POST", "/certificates", env.token, map[string]any{"name": "mail", "source": "file", "cert_file": certFile, "key_file": keyFile}, 201)
|
||||||
|
chain, _ := os.ReadFile(certFile)
|
||||||
|
key, _ := os.ReadFile(keyFile)
|
||||||
|
for part, want := range map[string]string{"": string(chain), "fullchain": string(chain), "cert": string(chain), "key": string(key), "bundle": string(key) + "\n" + string(chain)} {
|
||||||
|
b, status := env.req("GET", "/certificates/mail/pem?part="+part, env.token, nil)
|
||||||
|
if status != 200 || string(b) != want {
|
||||||
|
t.Errorf("part %q: %d %q", part, status, b)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, status := env.req("GET", "/certificates/mail/pem?part=nope", env.token, nil); status != 400 {
|
||||||
|
t.Errorf("bad part: %d", status)
|
||||||
|
}
|
||||||
|
if _, status := env.req("GET", "/certificates/none/pem", env.token, nil); status != 404 {
|
||||||
|
t.Errorf("no such certificate: %d", status)
|
||||||
|
}
|
||||||
|
// Not for a user who isn't an administrator.
|
||||||
|
hash, _ := auth.HashPassword("pw-pw-pw-pw")
|
||||||
|
u := &model.User{Username: "ops", PasswordHash: hash, Grants: []model.Grant{{Resource: "*", Access: "write"}}}
|
||||||
|
env.st.CreateUser(context.Background(), u)
|
||||||
|
tok, pid, sh := auth.NewToken()
|
||||||
|
env.st.CreateToken(context.Background(), &model.Token{UserID: u.ID, Name: "t", PublicID: pid, SecretHash: sh})
|
||||||
|
if _, status := env.req("GET", "/certificates/mail/pem?part=key", tok, nil); status != 403 {
|
||||||
|
t.Errorf("non-admin: %d", status)
|
||||||
|
}
|
||||||
|
audit, _ := env.req("GET", "/audit?action=certificate.export_key", env.token, nil)
|
||||||
|
if n := strings.Count(string(audit), "certificate.export_key"); n != 2 {
|
||||||
|
t.Errorf("audited key exports: %d (%s)", n, audit)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The DNSSEC page says where each zone's DS record goes.
|
||||||
|
func TestDNSSECWhereDSGoes(t *testing.T) {
|
||||||
|
e := newAPIEnv(t)
|
||||||
|
e.do("POST", "/views", e.token, map[string]any{"name": "internal", "match_clients": []string{"10.0.0.0/8"}}, 201)
|
||||||
|
e.do("POST", "/views", e.token, map[string]any{"name": "external"}, 201)
|
||||||
|
zone := func(name, view string) {
|
||||||
|
e.do("POST", "/zones", e.token, map[string]any{"name": name, "view": view, "nameservers": []string{"ns1.example.net."}}, 201)
|
||||||
|
}
|
||||||
|
zone("adbraun.lol", "external")
|
||||||
|
zone("lab.adbraun.lol", "external")
|
||||||
|
zone("dev.adbraun.lol", "internal") // its parent only in external
|
||||||
|
zone("sub.example.com", "external")
|
||||||
|
zone("10.0.10.0/24", "internal")
|
||||||
|
zone("home.lan", "internal")
|
||||||
|
for _, c := range []struct{ zone, at, place, parent string }{
|
||||||
|
{"adbraun.lol?view=external", "registrar", "at your registrar", ""},
|
||||||
|
{"lab.adbraun.lol?view=external", "parent_zone", "in the parent zone adbraun.lol", "adbraun.lol."},
|
||||||
|
{"dev.adbraun.lol?view=internal", "parent_zone", "in the parent zone adbraun.lol (view external)", "adbraun.lol."},
|
||||||
|
{"sub.example.com?view=external", "parent", "in its parent zone (most likely example.com)", ""},
|
||||||
|
{"10.0.10.in-addr.arpa?view=internal", "reverse", "your ISP", ""},
|
||||||
|
{"home.lan?view=internal", "none", "", ""},
|
||||||
|
} {
|
||||||
|
r := e.do("GET", "/zones/"+strings.Replace(c.zone, "?", "/dnssec?", 1), e.token, nil, 200)
|
||||||
|
place, _ := r["ds_place"].(string)
|
||||||
|
parent, _ := r["parent_zone"].(string)
|
||||||
|
if r["ds_at"] != c.at || !strings.Contains(place, c.place) || (c.place == "" && place != "") || parent != c.parent {
|
||||||
|
t.Errorf("%s: ds_at %v, ds_place %q, parent_zone %q", c.zone, r["ds_at"], place, parent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A zone lists the zones of its name in the other views.
|
||||||
|
func TestZoneInViews(t *testing.T) {
|
||||||
|
e := newAPIEnv(t)
|
||||||
|
e.do("POST", "/views", e.token, map[string]any{"name": "internal", "match_clients": []string{"10.0.0.0/8"}}, 201)
|
||||||
|
e.do("POST", "/views", e.token, map[string]any{"name": "external"}, 201)
|
||||||
|
e.do("POST", "/zones", e.token, map[string]any{"name": "adbraun.lol", "view": "internal", "nameservers": []string{"ns1.example.net."}}, 201)
|
||||||
|
e.do("POST", "/zones", e.token, map[string]any{"name": "adbraun.lol", "view": "external", "nameservers": []string{"ns1.example.net."}}, 201)
|
||||||
|
z := e.do("GET", "/zones/adbraun.lol?view=internal", e.token, nil, 200)
|
||||||
|
in, _ := z["in_views"].([]any)
|
||||||
|
if len(in) != 1 || in[0].(map[string]any)["view"] != "external" || in[0].(map[string]any)["type"] != "primary" {
|
||||||
|
t.Errorf("in_views = %v", z["in_views"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A token for records in one view of a zone that is in two: the zone is
|
||||||
|
// found without naming the view, and view list names the views it can
|
||||||
|
// see zones in, without their settings.
|
||||||
|
func TestOneViewToken(t *testing.T) {
|
||||||
|
e := newAPIEnv(t)
|
||||||
|
e.do("POST", "/views", e.token, map[string]any{"name": "internal", "match_clients": []string{"10.0.0.0/8"}}, 201)
|
||||||
|
e.do("POST", "/views", e.token, map[string]any{"name": "external"}, 201)
|
||||||
|
for _, v := range []string{"internal", "external"} {
|
||||||
|
e.do("POST", "/zones", e.token, map[string]any{"name": "adbraun.lol", "view": v, "nameservers": []string{"ns1.example.net."}}, 201)
|
||||||
|
}
|
||||||
|
e.do("POST", "/users", e.token, map[string]any{"username": "certbot", "password": "", "grant_strings": []string{"records:write zones=adbraun.lol views=external names=_acme-challenge.* types=TXT"}}, 201)
|
||||||
|
var uid int64
|
||||||
|
for _, u := range e.list("/users", e.token) {
|
||||||
|
if u["username"] == "certbot" {
|
||||||
|
uid = int64(u["id"].(float64))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tok := e.do("POST", "/users/"+itoa(uid)+"/tokens", e.token, map[string]any{"name": "certbot"}, 201)["token"].(string)
|
||||||
|
|
||||||
|
if z := e.do("GET", "/zones/adbraun.lol", tok, nil, 200); z["view"] != "external" {
|
||||||
|
t.Errorf("zone without a view: %v", z["view"])
|
||||||
|
}
|
||||||
|
e.do("GET", "/zones/adbraun.lol/records", tok, nil, 200)
|
||||||
|
views := e.list("/views", tok)
|
||||||
|
if len(views) != 1 || views[0]["name"] != "external" || views[0]["limited"] != true || views[0]["match_clients"] != nil {
|
||||||
|
t.Errorf("views = %v", views)
|
||||||
|
}
|
||||||
|
// The admin still has to say which.
|
||||||
|
e.do("GET", "/zones/adbraun.lol", e.token, nil, 400)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A reverse zone lists the PTR records it lacks, not just how many.
|
||||||
|
func TestPTRMissingList(t *testing.T) {
|
||||||
|
e := newAPIEnv(t)
|
||||||
|
e.do("POST", "/zones", e.token, map[string]any{"name": "adbraun.lol", "nameservers": []string{"ns1.example.net."},
|
||||||
|
"records": []map[string]any{{"name": "nas", "type": "A", "data": "10.60.60.5"}}}, 201)
|
||||||
|
e.do("POST", "/zones", e.token, map[string]any{"name": "10.60.60.0/24", "nameservers": []string{"ns1.example.net."}}, 201)
|
||||||
|
z := e.do("GET", "/zones/60.60.10.in-addr.arpa", e.token, nil, 200)
|
||||||
|
list, _ := z["ptr_missing_list"].([]any)
|
||||||
|
if z["ptr_missing"] != float64(1) || len(list) != 1 || list[0].(map[string]any)["address"] != "10.60.60.5" || list[0].(map[string]any)["target"] != "nas.adbraun.lol." {
|
||||||
|
t.Errorf("ptr_missing %v, list %v", z["ptr_missing"], z["ptr_missing_list"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// An address only another view's records use isn't missing from a
|
||||||
|
// reverse zone: it is counted apart, and filled only when asked.
|
||||||
|
func TestPTROtherViews(t *testing.T) {
|
||||||
|
e := newAPIEnv(t)
|
||||||
|
e.do("POST", "/views", e.token, map[string]any{"name": "internal", "match_clients": []string{"10.0.0.0/8"}}, 201)
|
||||||
|
e.do("POST", "/views", e.token, map[string]any{"name": "vps"}, 201)
|
||||||
|
e.do("POST", "/zones", e.token, map[string]any{"name": "adbraun.lol", "view": "internal", "nameservers": []string{"ns1.example.net."},
|
||||||
|
"records": []map[string]any{{"name": "claude", "type": "A", "data": "10.0.10.101"}}}, 201)
|
||||||
|
for _, v := range []string{"internal", "vps"} {
|
||||||
|
e.do("POST", "/zones", e.token, map[string]any{"name": "10.0.10.0/24", "view": v, "nameservers": []string{"ns1.example.net."}}, 201)
|
||||||
|
}
|
||||||
|
if z := e.do("GET", "/zones/10.0.10.in-addr.arpa?view=internal", e.token, nil, 200); z["ptr_missing"] != float64(1) || z["ptr_other_views"] != nil {
|
||||||
|
t.Errorf("internal: ptr_missing %v, ptr_other_views %v", z["ptr_missing"], z["ptr_other_views"])
|
||||||
|
}
|
||||||
|
if z := e.do("GET", "/zones/10.0.10.in-addr.arpa?view=vps", e.token, nil, 200); z["ptr_missing"] != nil || z["ptr_other_views"] != float64(1) {
|
||||||
|
t.Errorf("vps: ptr_missing %v, ptr_other_views %v", z["ptr_missing"], z["ptr_other_views"])
|
||||||
|
} else if l, _ := z["ptr_other_views_list"].([]any); len(l) != 1 || l[0].(map[string]any)["address"] != "10.0.10.101" || l[0].(map[string]any)["target"] != "claude.adbraun.lol." || l[0].(map[string]any)["from"] != "internal" {
|
||||||
|
t.Errorf("vps: ptr_other_views_list %v", z["ptr_other_views_list"])
|
||||||
|
}
|
||||||
|
e.do("POST", "/zones/10.0.10.in-addr.arpa/fill-ptr?view=vps", e.token, nil, 400)
|
||||||
|
e.do("POST", "/zones/10.0.10.in-addr.arpa/fill-ptr?view=vps&other_views=1", e.token, nil, 201)
|
||||||
|
if z := e.do("GET", "/zones/10.0.10.in-addr.arpa?view=vps", e.token, nil, 200); z["ptr_other_views"] != nil {
|
||||||
|
t.Errorf("vps after filling: ptr_other_views %v", z["ptr_other_views"])
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -444,6 +444,12 @@ func applyUserInput(u *model.User, in *api.UserInput) error {
|
|||||||
if in.Comment != nil {
|
if in.Comment != nil {
|
||||||
u.Comment = *in.Comment
|
u.Comment = *in.Comment
|
||||||
}
|
}
|
||||||
|
if in.CertLimit != nil {
|
||||||
|
u.CertLimit = in.CertLimit
|
||||||
|
if *in.CertLimit < 0 {
|
||||||
|
u.CertLimit = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
if in.Password != nil {
|
if in.Password != nil {
|
||||||
if *in.Password == "" {
|
if *in.Password == "" {
|
||||||
u.PasswordHash = "" // service account: tokens only
|
u.PasswordHash = "" // service account: tokens only
|
||||||
|
|||||||
+256
-6
@@ -2,13 +2,19 @@ package server
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/pem"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/netip"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"jarl/internal/api"
|
"jarl/internal/api"
|
||||||
"jarl/internal/auth"
|
"jarl/internal/auth"
|
||||||
"jarl/internal/certs"
|
"jarl/internal/certs"
|
||||||
|
"jarl/internal/encdns"
|
||||||
"jarl/internal/model"
|
"jarl/internal/model"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -21,8 +27,15 @@ type certView struct {
|
|||||||
NextTry *time.Time `json:"next_attempt,omitempty"` // acme: when the loop obtains it next
|
NextTry *time.Time `json:"next_attempt,omitempty"` // acme: when the loop obtains it next
|
||||||
Files *certFiles `json:"files,omitempty"` // dns use: where named reads it
|
Files *certFiles `json:"files,omitempty"` // dns use: where named reads it
|
||||||
Serving bool `json:"serving,omitempty"` // jarld serves it now (web use)
|
Serving bool `json:"serving,omitempty"` // jarld serves it now (web use)
|
||||||
|
// NamedUse says what of named's configuration uses it: "DNS over TLS
|
||||||
|
// on 10.0.10.5 port 853" (list and get only).
|
||||||
|
NamedUse []string `json:"named_use,omitempty"`
|
||||||
// IssuingSince is set while Jarl is obtaining it.
|
// IssuingSince is set while Jarl is obtaining it.
|
||||||
IssuingSince *time.Time `json:"issuing_since,omitempty"`
|
IssuingSince *time.Time `json:"issuing_since,omitempty"`
|
||||||
|
// Owner is the user a certificate for another host belongs to; PulledBy
|
||||||
|
// who downloaded it (list and get, administrators only).
|
||||||
|
Owner string `json:"owner,omitempty"`
|
||||||
|
PulledBy []model.CertificatePull `json:"pulled_by,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type certFiles struct {
|
type certFiles struct {
|
||||||
@@ -57,6 +70,78 @@ func (s *Server) certsReady(p *auth.Principal) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// certsAccess lets in administrators and users with a certificates grant
|
||||||
|
// (certificates for other hosts: their own only).
|
||||||
|
func (s *Server) certsAccess(p *auth.Principal) error {
|
||||||
|
if s.cfg.Certs == nil {
|
||||||
|
return &httpError{http.StatusNotFound, "disabled", "certificates aren't available in this jarld"}
|
||||||
|
}
|
||||||
|
if p.IsAdmin() || p.Can(auth.Action{Resource: auth.ResCerts}) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return forbidden("certificates need an administrator, or a certificates grant for their names (certificates:write names=*.example.com)")
|
||||||
|
}
|
||||||
|
|
||||||
|
// certAllowed reports whether p may see (or, with write, change) c: an
|
||||||
|
// administrator any; anyone else their own, while their grants cover all
|
||||||
|
// its names.
|
||||||
|
func certAllowed(p *auth.Principal, c *model.Certificate, write bool) bool {
|
||||||
|
if p.IsAdmin() {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if p.User == nil || c.OwnerID == 0 || c.OwnerID != p.User.ID {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, d := range c.Domains {
|
||||||
|
if !p.Can(auth.Action{Resource: auth.ResCerts, Write: write, Name: d}) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// certFor returns the certificate in the path, if p may see it (else not
|
||||||
|
// found, so names of others' certificates don't leak).
|
||||||
|
func (s *Server) certFor(r *http.Request, p *auth.Principal, write bool) (*model.Certificate, error) {
|
||||||
|
c, err := s.cfg.Certs.Get(r.Context(), r.PathValue("name"))
|
||||||
|
if err != nil {
|
||||||
|
return nil, certErr(err)
|
||||||
|
}
|
||||||
|
if !certAllowed(p, c, write) {
|
||||||
|
if write && certAllowed(p, c, false) {
|
||||||
|
return nil, forbidden("you may only download certificate %s", c.Name)
|
||||||
|
}
|
||||||
|
return nil, notFound("no such certificate")
|
||||||
|
}
|
||||||
|
return c, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// withOwners adds owners and downloads to views (for administrators).
|
||||||
|
func (s *Server) withOwners(ctx context.Context, views []certView) error {
|
||||||
|
pulls, err := s.store.CertificatePulls(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
users, err := s.store.ListUsers(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
names := map[int64]string{}
|
||||||
|
for _, u := range users {
|
||||||
|
names[u.ID] = u.Username
|
||||||
|
}
|
||||||
|
for i := range views {
|
||||||
|
if id := views[i].OwnerID; id != 0 {
|
||||||
|
views[i].Owner = names[id]
|
||||||
|
if views[i].Owner == "" {
|
||||||
|
views[i].Owner = fmt.Sprintf("deleted user %d", id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
views[i].PulledBy = pulls[views[i].ID]
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func certErr(err error) error {
|
func certErr(err error) error {
|
||||||
var ie *certs.InputError
|
var ie *certs.InputError
|
||||||
switch {
|
switch {
|
||||||
@@ -66,22 +151,40 @@ func certErr(err error) error {
|
|||||||
return notFound("no such certificate")
|
return notFound("no such certificate")
|
||||||
case errors.Is(err, certs.ErrConflict):
|
case errors.Is(err, certs.ErrConflict):
|
||||||
return conflict("a certificate with that name exists")
|
return conflict("a certificate with that name exists")
|
||||||
|
case errors.Is(err, certs.ErrTaken):
|
||||||
|
return conflict("the certificate name is taken by another user's certificate (or one Jarl uses): choose another name")
|
||||||
}
|
}
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// certList: GET /certificates.
|
// certList: GET /certificates.
|
||||||
func (s *Server) certList(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
func (s *Server) certList(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||||
if err := s.certsReady(p); err != nil {
|
if err := s.certsAccess(p); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
all, err := s.cfg.Certs.List(r.Context())
|
all, err := s.cfg.Certs.List(r.Context())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
st, err := s.store.LoadState(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
out := []certView{}
|
out := []certView{}
|
||||||
for _, c := range all {
|
for _, c := range all {
|
||||||
out = append(out, s.presentCert(c))
|
if !certAllowed(p, c, false) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
v := s.presentCert(c)
|
||||||
|
v.NamedUse = encdns.CertUses(st, s.cfg.Certs.FilesDir(c.Name))
|
||||||
|
out = append(out, v)
|
||||||
|
}
|
||||||
|
if !p.IsAdmin() {
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"certificates": out})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err := s.withOwners(r.Context(), out); err != nil {
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, map[string]any{"certificates": out, "account": s.cfg.Certs.AccountInfo(r.Context())})
|
writeJSON(w, http.StatusOK, map[string]any{"certificates": out, "account": s.cfg.Certs.AccountInfo(r.Context())})
|
||||||
return nil
|
return nil
|
||||||
@@ -89,17 +192,136 @@ func (s *Server) certList(w http.ResponseWriter, r *http.Request, p *auth.Princi
|
|||||||
|
|
||||||
// certGet: GET /certificates/{name}.
|
// certGet: GET /certificates/{name}.
|
||||||
func (s *Server) certGet(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
func (s *Server) certGet(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||||
if err := s.certsReady(p); err != nil {
|
if err := s.certsAccess(p); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
c, err := s.cfg.Certs.Get(r.Context(), r.PathValue("name"))
|
c, err := s.certFor(r, p, false)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
st, err := s.store.LoadState(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
v := s.presentCert(c)
|
||||||
|
v.NamedUse = encdns.CertUses(st, s.cfg.Certs.FilesDir(c.Name))
|
||||||
|
if p.IsAdmin() {
|
||||||
|
vs := []certView{v}
|
||||||
|
if err := s.withOwners(r.Context(), vs); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
v = vs[0]
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, v)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// certRequest: POST /certificates/{name}/request {"domains": [...],
|
||||||
|
// "key_type": "ec256"}, for jarl cert get: the certificate for another
|
||||||
|
// host, owned by the caller's user. A new one is created and obtained
|
||||||
|
// (202 while that runs; poll GET /certificates/{name} until
|
||||||
|
// issuing_since is gone). An existing one is the caller's, obtained again
|
||||||
|
// only when its names change; otherwise it is returned as it is (200).
|
||||||
|
func (s *Server) certRequest(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||||
|
if err := s.certsAccess(p); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if p.User == nil || p.User.ID == 0 {
|
||||||
|
return forbidden("certificates for other hosts belong to a user: use an API token, not the local socket as root")
|
||||||
|
}
|
||||||
|
var in struct {
|
||||||
|
Domains []string `json:"domains"`
|
||||||
|
KeyType string `json:"key_type"`
|
||||||
|
}
|
||||||
|
if err := decodeJSON(r, &in); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, d := range in.Domains {
|
||||||
|
d = strings.ToLower(strings.TrimSuffix(strings.TrimSpace(d), "."))
|
||||||
|
if !p.Can(auth.Action{Resource: auth.ResCerts, Write: true, Name: d}) {
|
||||||
|
return forbidden("you may not get a certificate for %s: that needs certificates:write with a names pattern that matches it", d)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
res, err := s.cfg.Certs.Ensure(r.Context(), r.PathValue("name"), in.Domains, in.KeyType, p)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return certErr(err)
|
return certErr(err)
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, s.presentCert(c))
|
v := s.presentCert(res.Certificate)
|
||||||
|
out := map[string]any{"certificate": v, "created": res.Created, "changed": res.Changed}
|
||||||
|
if res.Ignored != "" {
|
||||||
|
out["warnings"] = []string{res.Ignored}
|
||||||
|
}
|
||||||
|
status := http.StatusOK
|
||||||
|
if v.IssuingSince != nil {
|
||||||
|
status = http.StatusAccepted
|
||||||
|
}
|
||||||
|
writeJSON(w, status, out)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// certPEM: GET /certificates/{name}/pem?part=fullchain|cert|key|bundle,
|
||||||
|
// the certificate as PEM for use elsewhere: the chain (default), the
|
||||||
|
// certificate alone, the private key, or the key followed by the chain
|
||||||
|
// (one file, as HAProxy wants). Anything with the key is audited.
|
||||||
|
func (s *Server) certPEM(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||||
|
if err := s.certsAccess(p); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := s.certFor(r, p, false); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
part := r.URL.Query().Get("part")
|
||||||
|
if part == "" {
|
||||||
|
part = "fullchain"
|
||||||
|
}
|
||||||
|
if part == "key" || part == "bundle" {
|
||||||
|
if ok, why := s.keyTransport(r); !ok {
|
||||||
|
return &httpError{http.StatusForbidden, "https_required", "a private key is only handed out over HTTPS (or to this server itself), and this request came by " + why}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
c, chain, key, err := s.cfg.Certs.PEM(r.Context(), r.PathValue("name"))
|
||||||
|
if err != nil {
|
||||||
|
return certErr(err)
|
||||||
|
}
|
||||||
|
var out []byte
|
||||||
|
file := c.Name + "-" + part + ".pem"
|
||||||
|
switch part {
|
||||||
|
case "fullchain":
|
||||||
|
out = chain
|
||||||
|
case "cert":
|
||||||
|
if b, _ := pem.Decode(chain); b != nil {
|
||||||
|
out = pem.EncodeToMemory(b)
|
||||||
|
}
|
||||||
|
case "key":
|
||||||
|
out = key
|
||||||
|
case "bundle":
|
||||||
|
out = append(append(append([]byte{}, key...), '\n'), chain...)
|
||||||
|
default:
|
||||||
|
return badRequest("part must be fullchain, cert, key or bundle")
|
||||||
|
}
|
||||||
|
if len(out) == 0 {
|
||||||
|
return badRequest("certificate %s has no %s", c.Name, part)
|
||||||
|
}
|
||||||
|
if part == "key" || part == "bundle" {
|
||||||
|
s.eng.Audit(r.Context(), p, "certificate.export_key", "certificate", c.ID, c.Name, "success", "exported the private key ("+part+")")
|
||||||
|
}
|
||||||
|
pull := &model.CertificatePull{CertID: c.ID, Who: pullWho(p), Serial: c.Serial, IP: s.clientIP(r), At: time.Now().UTC()}
|
||||||
|
if p.User != nil {
|
||||||
|
pull.UserID = p.User.ID
|
||||||
|
}
|
||||||
|
if p.Token != nil {
|
||||||
|
pull.TokenID = p.Token.ID
|
||||||
|
}
|
||||||
|
if err := s.store.RecordCertificatePull(r.Context(), pull); err != nil {
|
||||||
|
s.log.Warn("recording a certificate download failed", "certificate", c.Name, "err", err)
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/x-pem-file")
|
||||||
|
w.Header().Set("Content-Disposition", `attachment; filename="`+file+`"`)
|
||||||
|
w.Header().Set("Cache-Control", "no-store")
|
||||||
|
_, err = w.Write(out)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
// certCreate: POST /certificates. With "issue": true (the default for
|
// certCreate: POST /certificates. With "issue": true (the default for
|
||||||
// certificates Jarl obtains) it is obtained before the answer.
|
// certificates Jarl obtains) it is obtained before the answer.
|
||||||
func (s *Server) certCreate(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
func (s *Server) certCreate(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||||
@@ -146,7 +368,10 @@ func (s *Server) certUpdate(w http.ResponseWriter, r *http.Request, p *auth.Prin
|
|||||||
|
|
||||||
// certDelete: DELETE /certificates/{name}.
|
// certDelete: DELETE /certificates/{name}.
|
||||||
func (s *Server) certDelete(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
func (s *Server) certDelete(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||||
if err := s.certsReady(p); err != nil {
|
if err := s.certsAccess(p); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := s.certFor(r, p, true); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := s.cfg.Certs.Delete(r.Context(), r.PathValue("name"), p); err != nil {
|
if err := s.cfg.Certs.Delete(r.Context(), r.PathValue("name"), p); err != nil {
|
||||||
@@ -241,3 +466,28 @@ func (s *Server) certSummary(ctx context.Context) *api.CertSummary {
|
|||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// keyTransport reports whether a private key may go back over r: HTTPS
|
||||||
|
// (also through a trusted proxy), the local socket, or loopback.
|
||||||
|
func (s *Server) keyTransport(r *http.Request) (bool, string) {
|
||||||
|
https, why := s.transport(r)
|
||||||
|
if https {
|
||||||
|
return true, why
|
||||||
|
}
|
||||||
|
host, _, _ := net.SplitHostPort(r.RemoteAddr)
|
||||||
|
if a, err := netip.ParseAddr(host); err == nil && a.Unmap().IsLoopback() && !s.trusted(a.Unmap()) {
|
||||||
|
return true, "plain HTTP from this server"
|
||||||
|
}
|
||||||
|
return false, why
|
||||||
|
}
|
||||||
|
|
||||||
|
// pullWho names a downloader: its token (and user), or the user.
|
||||||
|
func pullWho(p *auth.Principal) string {
|
||||||
|
switch {
|
||||||
|
case p.Token != nil:
|
||||||
|
return "token " + p.Token.Name + " (" + p.Name() + ")"
|
||||||
|
case p.User != nil && p.Method != "":
|
||||||
|
return p.Method + " " + p.Name()
|
||||||
|
}
|
||||||
|
return p.Name()
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
@@ -284,6 +285,21 @@ func (s *Server) status(w http.ResponseWriter, r *http.Request, p *auth.Principa
|
|||||||
resp.Backup = s.backupStatus(r.Context(), now)
|
resp.Backup = s.backupStatus(r.Context(), now)
|
||||||
resp.TOTP = s.totpOverview(r.Context())
|
resp.TOTP = s.totpOverview(r.Context())
|
||||||
resp.Certificates = s.certSummary(r.Context())
|
resp.Certificates = s.certSummary(r.Context())
|
||||||
|
resp.Storage = s.storage(r.Context())
|
||||||
|
}
|
||||||
|
if p.Can(auth.Action{Resource: auth.ResOptions}) {
|
||||||
|
if h := s.encryptedDNSHealth(); h != nil {
|
||||||
|
sum := &api.EncDNSSummary{Checked: h.Checked, OK: h.OK(), Problems: []string{}, Warnings: []string{}}
|
||||||
|
for _, pr := range h.Probes {
|
||||||
|
where := fmt.Sprintf("%s on %s port %d", map[string]string{"dot": "DNS over TLS", "doh": "DNS over HTTPS"}[pr.Kind], pr.Address, pr.Port)
|
||||||
|
if pr.Problem != "" {
|
||||||
|
sum.Problems = append(sum.Problems, where+": "+pr.Problem)
|
||||||
|
} else if pr.Warning != "" {
|
||||||
|
sum.Warnings = append(sum.Warnings, where+": "+pr.Warning)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
resp.EncryptedDNS = sum
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if p.Can(auth.Action{Resource: auth.ResBlocking}) {
|
if p.Can(auth.Action{Resource: auth.ResBlocking}) {
|
||||||
resp.BlocklistErrors = s.blocklistErrors(r.Context())
|
resp.BlocklistErrors = s.blocklistErrors(r.Context())
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"jarl/internal/auth"
|
||||||
|
"jarl/internal/engine"
|
||||||
|
"jarl/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
// zoneCompare: GET /zones/{zone}/compare compares a zone with the same
|
||||||
|
// zone in other views (docs/views.md#comparing-views), from the zone's own
|
||||||
|
// view: ?with=a,b picks the other views (default: every view with a
|
||||||
|
// primary zone of that name), ?name=www[&type=A] narrows it to one name,
|
||||||
|
// ?all=1 includes what is the same. A zone named without its view is
|
||||||
|
// compared from the first view that has it. (?views=a,b, the views to
|
||||||
|
// compare with the first as the base, works too.)
|
||||||
|
func (s *Server) zoneCompare(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||||
|
st, err := s.state(r)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
q := r.URL.Query()
|
||||||
|
list := func(k string) []string {
|
||||||
|
var out []string
|
||||||
|
for _, v := range strings.Split(q.Get(k), ",") {
|
||||||
|
if v = strings.TrimSpace(v); v != "" {
|
||||||
|
out = append(out, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
with, views := list("with"), list("views")
|
||||||
|
z, err := s.zoneByPath(r, st, p)
|
||||||
|
if err != nil {
|
||||||
|
// A name in several views is what there is to compare.
|
||||||
|
ref := r.PathValue("zone")
|
||||||
|
if ref == "root" {
|
||||||
|
ref = "."
|
||||||
|
}
|
||||||
|
name, perr := engine.ParseZoneName(ref)
|
||||||
|
zs := st.ZonesNamed(name)
|
||||||
|
if perr != nil || len(zs) < 2 {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
st.SortByView(zs)
|
||||||
|
z = zs[0]
|
||||||
|
if len(views) > 0 {
|
||||||
|
for _, o := range zs {
|
||||||
|
if strings.EqualFold(st.ViewName(o), views[0]) {
|
||||||
|
z = o
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
readable := func(o *model.Zone) bool {
|
||||||
|
vn := st.ViewName(o)
|
||||||
|
return o.Type == model.ZonePrimary && p.CanSeeZone(o.Name, vn) && p.Can(auth.Action{Resource: auth.ResRecords, Zone: o.Name, View: vn})
|
||||||
|
}
|
||||||
|
if !readable(z) {
|
||||||
|
return badRequest("zone %s isn't a primary zone whose records you may read", engine.Display(z.Name))
|
||||||
|
}
|
||||||
|
zones := []*model.Zone{z}
|
||||||
|
var others []*model.Zone
|
||||||
|
for _, o := range st.ZonesNamed(z.Name) {
|
||||||
|
vn := st.ViewName(o)
|
||||||
|
if o.ID == z.ID || !readable(o) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if len(with) > 0 && !containsFold(with, vn) || len(views) > 0 && !containsFold(views, vn) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
others = append(others, o)
|
||||||
|
}
|
||||||
|
st.SortByView(others)
|
||||||
|
zones = append(zones, others...)
|
||||||
|
if len(zones) < 2 {
|
||||||
|
return badRequest("zone %s is in only one view (that you may read): there is nothing to compare", engine.Display(z.Name))
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, engine.Compare(st, zones, engine.CompareFilter{Name: q.Get("name"), Type: q.Get("type"), All: queryBool(r, "all")}))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func containsFold(list []string, s string) bool {
|
||||||
|
for _, x := range list {
|
||||||
|
if strings.EqualFold(x, s) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
@@ -57,6 +57,8 @@ func (s *Server) listViews(w http.ResponseWriter, r *http.Request, p *auth.Princ
|
|||||||
for _, v := range st.OrderedViews() {
|
for _, v := range st.OrderedViews() {
|
||||||
if p.Can(auth.Action{Resource: auth.ResViews, View: v.Name}) {
|
if p.Can(auth.Action{Resource: auth.ResViews, View: v.Name}) {
|
||||||
out = append(out, presentView(st, v, colors))
|
out = append(out, presentView(st, v, colors))
|
||||||
|
} else if lv := limitedView(st, v, colors, p); lv != nil {
|
||||||
|
out = append(out, *lv)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, out)
|
writeJSON(w, http.StatusOK, out)
|
||||||
@@ -635,7 +637,7 @@ func (s *Server) search(w http.ResponseWriter, r *http.Request, p *auth.Principa
|
|||||||
}
|
}
|
||||||
sort.Slice(recs, func(i, j int) bool {
|
sort.Slice(recs, func(i, j int) bool {
|
||||||
if recs[i].Name != recs[j].Name {
|
if recs[i].Name != recs[j].Name {
|
||||||
return model.CanonicalLess(recs[i].Name, recs[j].Name)
|
return model.NameLess(recs[i].Name, recs[j].Name)
|
||||||
}
|
}
|
||||||
return recs[i].ID < recs[j].ID
|
return recs[i].ID < recs[j].ID
|
||||||
})
|
})
|
||||||
@@ -659,3 +661,22 @@ func (s *Server) search(w http.ResponseWriter, r *http.Request, p *auth.Principa
|
|||||||
writeJSON(w, http.StatusOK, out)
|
writeJSON(w, http.StatusOK, out)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// generatedOptions: GET /options/generated lists what Jarl adds to
|
||||||
|
// named.conf by itself (the statistics channel, its logging, blocking's
|
||||||
|
// response-policy, view keys), with why and what controls it.
|
||||||
|
func (s *Server) generatedOptions(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||||
|
if err := require(p, auth.Action{Resource: auth.ResOptions}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
st, err := s.store.LoadState(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
out := s.eng.GeneratedStatements(r.Context(), st)
|
||||||
|
if out == nil {
|
||||||
|
out = []render.Generated{}
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, out)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -36,24 +36,49 @@ func (s *Server) zoneDNSSEC(w http.ResponseWriter, r *http.Request, p *auth.Prin
|
|||||||
return badRequest("only primary zones are signed by Jarl (this is a %s zone)", z.Type)
|
return badRequest("only primary zones are signed by Jarl (this is a %s zone)", z.Type)
|
||||||
}
|
}
|
||||||
resp := api.DNSSECResponse{Zone: z.Name, View: st.ViewName(z), Type: z.Type, Status: s.eng.DNSSEC(r.Context(), st, z), Registrar: s.registrarFor(z)}
|
resp := api.DNSSECResponse{Zone: z.Name, View: st.ViewName(z), Type: z.Type, Status: s.eng.DNSSEC(r.Context(), st, z), Registrar: s.registrarFor(z)}
|
||||||
if pz := parentInJarl(st, z); pz != nil {
|
public := map[int64]bool{}
|
||||||
|
for _, e := range s.eng.Exposures(st) {
|
||||||
|
public[e.ViewID] = e.Public
|
||||||
|
}
|
||||||
|
name := dnsname.Display(z.Name)
|
||||||
|
reg, regOK := dnsname.RegisteredDomain(z.Name)
|
||||||
|
switch pz := parentInJarl(st, z, public); {
|
||||||
|
case pz != nil:
|
||||||
|
resp.DSAt = "parent_zone"
|
||||||
resp.ParentZone, resp.ParentZoneID, resp.ParentView = pz.Name, pz.ID, st.ViewName(pz)
|
resp.ParentZone, resp.ParentZoneID, resp.ParentView = pz.Name, pz.ID, st.ViewName(pz)
|
||||||
for _, rec := range st.ServedRecords(pz.ID) {
|
for _, rec := range st.ServedRecords(pz.ID) {
|
||||||
if rec.Name == z.Name && rec.Type == "DS" {
|
if rec.Name == z.Name && rec.Type == "DS" {
|
||||||
resp.ParentDS = append(resp.ParentDS, rec.Data)
|
resp.ParentDS = append(resp.ParentDS, rec.Data)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
resp.DSPlace = "in the parent zone " + dnsname.Display(pz.Name)
|
||||||
|
if pz.ViewID != z.ViewID {
|
||||||
|
resp.DSPlace += " (view " + st.ViewName(pz) + ")"
|
||||||
|
}
|
||||||
|
case dnsname.IsReverse(z.Name):
|
||||||
|
resp.DSAt = "reverse"
|
||||||
|
resp.DSPlace = "with whoever delegates " + name + " to you (your ISP, or your RIR for an address block of your own; many ISPs don't take DS records)"
|
||||||
|
case !regOK:
|
||||||
|
resp.DSAt = "none"
|
||||||
|
resp.DSNote = "No zone on the internet delegates " + name + ", so it can't get a DS record and resolvers never validate it. Signing only matters for a zone on the internet."
|
||||||
|
case reg == name:
|
||||||
|
resp.DSAt, resp.DSPlace = "registrar", "at your registrar"
|
||||||
|
default:
|
||||||
|
resp.DSAt = "parent"
|
||||||
|
resp.DSPlace = "in its parent zone (most likely " + reg + "), wherever that zone's DNS is hosted (at Porkbun: " + reg + "'s DNS records, as a DS record named " + strings.TrimSuffix(name, "."+reg) + ")"
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, resp)
|
writeJSON(w, http.StatusOK, resp)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// parentInJarl returns the primary zone in z's view that z is delegated
|
// parentInJarl returns the primary zone in Jarl that z is delegated from:
|
||||||
// from, if Jarl has it.
|
// in z's own view, else in another view, a public one first (the one the
|
||||||
func parentInJarl(st *model.State, z *model.Zone) *model.Zone {
|
// internet asks).
|
||||||
|
func parentInJarl(st *model.State, z *model.Zone, public map[int64]bool) *model.Zone {
|
||||||
|
in := func(viewID int64) *model.Zone {
|
||||||
var best *model.Zone
|
var best *model.Zone
|
||||||
for _, o := range st.ZonesInView(z.ViewID) {
|
for _, o := range st.ZonesInView(viewID) {
|
||||||
if o.ID == z.ID || o.Type != model.ZonePrimary || o.Name == z.Name || !dnsname.IsSubdomain(z.Name, o.Name) {
|
if o.Type != model.ZonePrimary || o.Name == z.Name || !dnsname.IsSubdomain(z.Name, o.Name) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if best == nil || len(o.Name) > len(best.Name) {
|
if best == nil || len(o.Name) > len(best.Name) {
|
||||||
@@ -62,6 +87,20 @@ func parentInJarl(st *model.State, z *model.Zone) *model.Zone {
|
|||||||
}
|
}
|
||||||
return best
|
return best
|
||||||
}
|
}
|
||||||
|
if pz := in(z.ViewID); pz != nil {
|
||||||
|
return pz
|
||||||
|
}
|
||||||
|
var other *model.Zone
|
||||||
|
for _, v := range st.OrderedViews() {
|
||||||
|
if v.ID == z.ViewID {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if pz := in(v.ID); pz != nil && (other == nil || public[v.ID] && !public[other.ViewID]) {
|
||||||
|
other = pz
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return other
|
||||||
|
}
|
||||||
|
|
||||||
// dsChecker returns the checker for parent DS lookups, with ?resolver=IP.
|
// dsChecker returns the checker for parent DS lookups, with ?resolver=IP.
|
||||||
func (s *Server) dsChecker(r *http.Request) (*nscheck.Checker, error) {
|
func (s *Server) dsChecker(r *http.Request) (*nscheck.Checker, error) {
|
||||||
|
|||||||
@@ -0,0 +1,351 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"net/netip"
|
||||||
|
"slices"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"jarl/internal/auth"
|
||||||
|
"jarl/internal/certs"
|
||||||
|
"jarl/internal/encdns"
|
||||||
|
"jarl/internal/model"
|
||||||
|
"jarl/internal/nscheck"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DNS over TLS and HTTPS (docs/encrypted-dns.md).
|
||||||
|
|
||||||
|
type encAddress struct {
|
||||||
|
Address string `json:"address"`
|
||||||
|
Private bool `json:"private"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type encView struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Public bool `json:"public"`
|
||||||
|
HasZone bool `json:"has_zone,omitempty"` // has a zone for the host name
|
||||||
|
}
|
||||||
|
|
||||||
|
type encCert struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Names []string `json:"names"`
|
||||||
|
Domains []string `json:"domains"`
|
||||||
|
DNS bool `json:"dns"` // has the dns use
|
||||||
|
Status certs.Status `json:"status"`
|
||||||
|
Obtained bool `json:"obtained"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type encStatus struct {
|
||||||
|
encdns.Status
|
||||||
|
// Hostnames are the names clients connect to (the setup's, else the
|
||||||
|
// certificate's first); Hostname is the first.
|
||||||
|
Hostnames []string `json:"hostnames"`
|
||||||
|
Hostname string `json:"hostname,omitempty"`
|
||||||
|
// AddressViews are, per address, the views whose records give a host
|
||||||
|
// name that address.
|
||||||
|
AddressViews map[string][]string `json:"address_views"`
|
||||||
|
// DoTHost and DoHURL are what to give clients.
|
||||||
|
DoTHost string `json:"dot_host,omitempty"`
|
||||||
|
DoHURL string `json:"doh_url,omitempty"`
|
||||||
|
// Health is the last check of the listeners (every 10 minutes).
|
||||||
|
Health *encdns.Health `json:"health,omitempty"`
|
||||||
|
// For the setup form.
|
||||||
|
Addresses []encAddress `json:"addresses"`
|
||||||
|
Views []encView `json:"views"`
|
||||||
|
Certificates []encCert `json:"certificates"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) encReady(p *auth.Principal, write bool) error {
|
||||||
|
if err := require(p, auth.Action{Resource: auth.ResOptions, Write: write}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if s.cfg.Certs == nil {
|
||||||
|
return &httpError{http.StatusNotFound, "disabled", "certificates aren't available in this jarld"}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// localAddrs returns this host's addresses (a test may replace it).
|
||||||
|
var localAddrs = nscheck.LocalAddrs
|
||||||
|
|
||||||
|
// encryptedDNS: GET /encrypted-dns.
|
||||||
|
func (s *Server) encryptedDNS(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||||
|
if err := s.encReady(p, false); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
st, err := s.store.LoadState(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
m := s.cfg.Certs
|
||||||
|
out := encStatus{Status: encdns.GetStatus(st, m.FilesDir("")), Addresses: []encAddress{}, Views: []encView{}, Certificates: []encCert{}}
|
||||||
|
all, err := m.List(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, c := range all {
|
||||||
|
out.Certificates = append(out.Certificates, encCert{Name: c.Name, Names: c.Names, Domains: c.Domains, DNS: c.HasUse(model.CertUseDNS),
|
||||||
|
Status: m.StatusOf(c), Obtained: c.NotAfter != nil})
|
||||||
|
if c.Name == out.Certificate {
|
||||||
|
for _, n := range append(c.Names, c.Domains...) {
|
||||||
|
if !strings.HasPrefix(n, "*.") {
|
||||||
|
out.Hostname = n
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if hs := st.Settings.EncryptedDNSHosts; len(hs) > 0 {
|
||||||
|
out.Hostname = hs[0]
|
||||||
|
out.Hostnames = hs
|
||||||
|
} else if out.Hostname != "" {
|
||||||
|
out.Hostnames = []string{out.Hostname}
|
||||||
|
}
|
||||||
|
if out.Hostnames == nil {
|
||||||
|
out.Hostnames = []string{}
|
||||||
|
}
|
||||||
|
if out.Hostname != "" {
|
||||||
|
if len(out.DoT) > 0 {
|
||||||
|
out.DoTHost = out.Hostname
|
||||||
|
if out.DoT[0].Port != encdns.DoTPort {
|
||||||
|
out.DoTHost += ":" + strconv.Itoa(out.DoT[0].Port)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(out.DoH) > 0 {
|
||||||
|
out.DoHURL = "https://" + out.Hostname
|
||||||
|
if out.DoH[0].Port != 443 {
|
||||||
|
out.DoHURL += ":" + strconv.Itoa(out.DoH[0].Port)
|
||||||
|
}
|
||||||
|
out.DoHURL += "/dns-query"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if out.Enabled {
|
||||||
|
out.Health = s.encryptedDNSHealth()
|
||||||
|
}
|
||||||
|
seen := map[netip.Addr]bool{}
|
||||||
|
for _, a := range localAddrs() {
|
||||||
|
a = a.Unmap()
|
||||||
|
if seen[a] || a.IsLinkLocalUnicast() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[a] = true
|
||||||
|
out.Addresses = append(out.Addresses, encAddress{Address: a.String(), Private: encdns.Private(a)})
|
||||||
|
}
|
||||||
|
slices.SortFunc(out.Addresses, func(a, b encAddress) int { return strings.Compare(a.Address, b.Address) })
|
||||||
|
hosts := out.Hostnames
|
||||||
|
if q := r.URL.Query().Get("hostname"); q != "" {
|
||||||
|
hosts = nil
|
||||||
|
for _, h := range strings.Split(q, ",") {
|
||||||
|
if h = strings.TrimSuffix(strings.TrimSpace(h), "."); h != "" {
|
||||||
|
hosts = append(hosts, h)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Where the records of these host names give each address now, the
|
||||||
|
// setup's or not: what the form starts from.
|
||||||
|
out.AddressViews = encdns.CurrentAddressViews(st, hosts)
|
||||||
|
for _, x := range s.eng.Exposures(st) {
|
||||||
|
if x.View == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
v := encView{Name: x.View, Public: x.Public}
|
||||||
|
if vv := st.ViewByName(x.View); vv != nil {
|
||||||
|
for _, host := range hosts {
|
||||||
|
for _, z := range st.ZonesInView(vv.ID) {
|
||||||
|
if z.Type == model.ZonePrimary && (host+"." == z.Name || strings.HasSuffix(host+".", "."+z.Name)) {
|
||||||
|
v.HasZone = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.Views = append(out.Views, v)
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, out)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// encryptedDNSCheck: POST /encrypted-dns/check {"addresses", "dot", "doh",
|
||||||
|
// "doh_port"} says what already listens where the listeners would go.
|
||||||
|
func (s *Server) encryptedDNSCheck(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||||
|
if err := s.encReady(p, false); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var in encdns.Setup
|
||||||
|
if err := decodeJSON(r, &in); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
st, err := s.store.LoadState(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if in.DoHPort == 0 {
|
||||||
|
in.DoHPort = 443
|
||||||
|
}
|
||||||
|
if in.DoTPort == 0 {
|
||||||
|
in.DoTPort = encdns.DoTPort
|
||||||
|
}
|
||||||
|
var ports []int
|
||||||
|
if in.DoT {
|
||||||
|
ports = append(ports, in.DoTPort)
|
||||||
|
}
|
||||||
|
if in.DoH {
|
||||||
|
ports = append(ports, in.DoHPort)
|
||||||
|
}
|
||||||
|
conflicts, err := encdns.PortCheck(st, in.Addresses, ports, s.cfg.Listen)
|
||||||
|
out := map[string]any{"conflicts": conflicts}
|
||||||
|
if err != nil {
|
||||||
|
out["error"] = "can't see which ports are in use: " + err.Error()
|
||||||
|
}
|
||||||
|
if conflicts == nil {
|
||||||
|
out["conflicts"] = []encdns.Conflict{}
|
||||||
|
}
|
||||||
|
if in.DoH {
|
||||||
|
out["free_doh_port"] = encdns.FreeDoHPort(st, in.Addresses, s.cfg.Listen)
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, out)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// encryptedDNSSetup: POST /encrypted-dns stages (or applies) the setup.
|
||||||
|
func (s *Server) encryptedDNSSetup(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||||
|
if err := s.encReady(p, true); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var in encdns.Setup
|
||||||
|
if err := decodeJSON(r, &in); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
c, err := s.cfg.Certs.Get(r.Context(), in.Certificate)
|
||||||
|
if err != nil {
|
||||||
|
return certErr(err)
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case !c.HasUse(model.CertUseDNS):
|
||||||
|
return badRequest("certificate %s doesn't have the dns use, so named can't read it: jarl cert update %s --use dns (and keep its other uses)", c.Name, c.Name)
|
||||||
|
case c.NotAfter == nil:
|
||||||
|
return conflict("certificate %s hasn't been obtained yet (%s); set up encrypted DNS once it has", c.Name, s.cfg.Certs.StatusOf(c).Message)
|
||||||
|
case len(in.Hosts()) > 0 && !certs.Covers(c.Names, in.Hosts()):
|
||||||
|
return badRequest("certificate %s is for %s, not all of %s: clients would reject it (add the names to it: jarl cert update %s --domain …)", c.Name, strings.Join(c.Names, ", "), strings.Join(in.Hosts(), ", "), c.Name)
|
||||||
|
}
|
||||||
|
st, err := s.store.LoadState(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
in.PublicViews = map[string]bool{}
|
||||||
|
for _, x := range s.eng.Exposures(st) {
|
||||||
|
in.PublicViews[x.View] = x.Public
|
||||||
|
}
|
||||||
|
reqs, notes, err := encdns.Plan(st, in, s.cfg.Certs.FilesDir(""))
|
||||||
|
if err != nil {
|
||||||
|
return badRequest("%v", err)
|
||||||
|
}
|
||||||
|
what := []string{}
|
||||||
|
if in.DoT {
|
||||||
|
what = append(what, "DNS over TLS")
|
||||||
|
}
|
||||||
|
if in.DoH {
|
||||||
|
what = append(what, "DNS over HTTPS")
|
||||||
|
}
|
||||||
|
return s.mutateNotes(w, r, p, "set up "+strings.Join(what, " and ")+" for "+strings.Join(in.Hosts(), ", "), reqs, nil, notes)
|
||||||
|
}
|
||||||
|
|
||||||
|
// encryptedDNSDisable: DELETE /encrypted-dns.
|
||||||
|
func (s *Server) encryptedDNSDisable(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||||
|
if err := s.encReady(p, true); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
st, err := s.store.LoadState(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
reqs := encdns.Disable(st)
|
||||||
|
if len(reqs) == 0 {
|
||||||
|
return conflict("encrypted DNS isn't set up")
|
||||||
|
}
|
||||||
|
return s.mutate(w, r, p, "turn DNS over TLS and HTTPS off", reqs, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// encTarget returns the host name clients connect to and the serial of
|
||||||
|
// the certificate Jarl last wrote for named.
|
||||||
|
func (s *Server) encTarget(ctx context.Context, st encdns.Status) (host, serial string) {
|
||||||
|
if s.cfg.Certs == nil || st.Certificate == "" {
|
||||||
|
return "", ""
|
||||||
|
}
|
||||||
|
c, err := s.cfg.Certs.Get(ctx, st.Certificate)
|
||||||
|
if err != nil {
|
||||||
|
return "", ""
|
||||||
|
}
|
||||||
|
for _, n := range append(c.Names, c.Domains...) {
|
||||||
|
if !strings.HasPrefix(n, "*.") {
|
||||||
|
host = n
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return host, c.Serial
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkEncryptedDNS checks the DoT and DoH listeners as a client would and
|
||||||
|
// keeps the result; a change for the worse is logged.
|
||||||
|
func (s *Server) checkEncryptedDNS(ctx context.Context) *encdns.Health {
|
||||||
|
if s.cfg.Certs == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
state, err := s.store.LoadState(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
st := encdns.GetStatus(state, s.cfg.Certs.FilesDir(""))
|
||||||
|
var h *encdns.Health
|
||||||
|
if st.Enabled {
|
||||||
|
if host, serial := s.encTarget(ctx, st); host != "" {
|
||||||
|
hosts := state.Settings.EncryptedDNSHosts
|
||||||
|
if len(hosts) > 0 {
|
||||||
|
host = hosts[0]
|
||||||
|
}
|
||||||
|
h = encdns.Check(ctx, st, host, serial, s.encRoots)
|
||||||
|
h.RequireNames(append([]string{host}, hosts...))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s.encHealth.Lock()
|
||||||
|
prev := s.encHealth.h
|
||||||
|
s.encHealth.h = h
|
||||||
|
s.encHealth.Unlock()
|
||||||
|
if h != nil {
|
||||||
|
for _, p := range h.Probes {
|
||||||
|
if p.OK {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
was := false
|
||||||
|
if prev != nil {
|
||||||
|
for _, q := range prev.Probes {
|
||||||
|
was = was || q.Kind == p.Kind && q.Address == p.Address && q.Port == p.Port && !q.OK
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !was {
|
||||||
|
s.log.Warn("encrypted DNS isn't working", "kind", p.Kind, "address", p.Address, "port", p.Port, "problem", p.Problem)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return h
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) encryptedDNSHealth() *encdns.Health {
|
||||||
|
s.encHealth.Lock()
|
||||||
|
defer s.encHealth.Unlock()
|
||||||
|
return s.encHealth.h
|
||||||
|
}
|
||||||
|
|
||||||
|
// encryptedDNSCheckNow: POST /encrypted-dns/health checks the listeners now.
|
||||||
|
func (s *Server) encryptedDNSCheckNow(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||||
|
if err := s.encReady(p, false); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
h := s.checkEncryptedDNS(r.Context())
|
||||||
|
if h == nil {
|
||||||
|
return conflict("encrypted DNS isn't set up")
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, h)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
package server
|
package server
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"jarl/internal/mailcheck"
|
||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -32,6 +33,11 @@ type exposureReport struct {
|
|||||||
// RateLimiting is what rate limiting limited, or would have, in the
|
// RateLimiting is what rate limiting limited, or would have, in the
|
||||||
// last 24 hours (with query statistics on).
|
// last 24 hours (with query statistics on).
|
||||||
RateLimiting *rateLimitActivity `json:"rate_limiting,omitempty"`
|
RateLimiting *rateLimitActivity `json:"rate_limiting,omitempty"`
|
||||||
|
// RateLimitLog is false when stats.rate_limit_log is off: what rate
|
||||||
|
// limiting does isn't counted then.
|
||||||
|
RateLimitLog bool `json:"rate_limit_log"`
|
||||||
|
// Mail is the last check of the mail addresses' reverse DNS.
|
||||||
|
Mail *mailcheck.Report `json:"mail,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type rateLimitActivity struct {
|
type rateLimitActivity struct {
|
||||||
@@ -52,7 +58,7 @@ func (s *Server) exposure(w http.ResponseWriter, r *http.Request, p *auth.Princi
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
problems := s.eng.Validate(st)
|
problems := s.eng.Validate(st)
|
||||||
out := exposureReport{Scopes: []exposureScope{}, Server: []exposureFinding{}}
|
out := exposureReport{Scopes: []exposureScope{}, Server: []exposureFinding{}, RateLimitLog: s.cfg.RateLimitLog}
|
||||||
finding := func(pr validate.Problem, viewID int64) exposureFinding {
|
finding := func(pr validate.Problem, viewID int64) exposureFinding {
|
||||||
_, desc := engine.ExposureFix(st, viewID, pr.Code)
|
_, desc := engine.ExposureFix(st, viewID, pr.Code)
|
||||||
return exposureFinding{Problem: pr, Fix: desc}
|
return exposureFinding{Problem: pr, Fix: desc}
|
||||||
@@ -96,6 +102,7 @@ func (s *Server) exposure(w http.ResponseWriter, r *http.Request, p *auth.Princi
|
|||||||
a.Names, _ = s.statsQ.Top(ctx, stats.KindRateLimitedName, since, 10)
|
a.Names, _ = s.statsQ.Top(ctx, stats.KindRateLimitedName, since, 10)
|
||||||
out.RateLimiting = a
|
out.RateLimiting = a
|
||||||
}
|
}
|
||||||
|
out.Mail = s.mailReport()
|
||||||
writeJSON(w, http.StatusOK, out)
|
writeJSON(w, http.StatusOK, out)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"jarl/internal/auth"
|
||||||
|
"jarl/internal/mailcheck"
|
||||||
|
)
|
||||||
|
|
||||||
|
// mailCheckEvery is how often jarld checks the reverse DNS of the
|
||||||
|
// addresses that send and receive mail (docs/exposure.md#mail).
|
||||||
|
const mailCheckEvery = 6 * time.Hour
|
||||||
|
|
||||||
|
// checkMail checks the reverse DNS of the public zones' mail addresses and
|
||||||
|
// keeps the report; it logs addresses that became wrong.
|
||||||
|
func (s *Server) checkMail(ctx context.Context) *mailcheck.Report {
|
||||||
|
st, err := s.store.LoadState(ctx)
|
||||||
|
if err != nil {
|
||||||
|
s.log.Warn("mail reverse DNS check: loading state", "err", err)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
public := map[int64]bool{}
|
||||||
|
for _, x := range s.eng.Exposures(st) {
|
||||||
|
public[x.ViewID] = x.Public
|
||||||
|
}
|
||||||
|
if len(st.Views) == 0 {
|
||||||
|
public[0] = true
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(ctx, 2*time.Minute)
|
||||||
|
defer cancel()
|
||||||
|
rep := mailcheck.Check(ctx, st, func(id int64) bool { return public[id] }, mailcheck.Resolver{Servers: s.cfg.Resolvers})
|
||||||
|
s.mail.Lock()
|
||||||
|
prev := s.mail.r
|
||||||
|
s.mail.r = rep
|
||||||
|
s.mail.Unlock()
|
||||||
|
was := map[string]bool{}
|
||||||
|
if prev != nil {
|
||||||
|
for _, r := range prev.Results {
|
||||||
|
was[r.Zone+"|"+r.Address] = r.OK
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, r := range rep.Results {
|
||||||
|
if ok, seen := was[r.Zone+"|"+r.Address]; !r.OK && (ok || !seen) {
|
||||||
|
s.log.Warn("mail reverse DNS", "zone", r.Zone, "address", r.Address, "problem", r.Message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return rep
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) mailReport() *mailcheck.Report {
|
||||||
|
s.mail.Lock()
|
||||||
|
defer s.mail.Unlock()
|
||||||
|
return s.mail.r
|
||||||
|
}
|
||||||
|
|
||||||
|
// mailCheckNow: POST /exposure/mail checks now and returns the report.
|
||||||
|
func (s *Server) mailCheckNow(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||||
|
if err := require(p, auth.Action{Resource: auth.ResOptions}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
rep := s.checkMail(r.Context())
|
||||||
|
if rep == nil {
|
||||||
|
rep = &mailcheck.Report{Checked: time.Now(), Results: []mailcheck.Result{}}
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, rep)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// mailLoop checks a minute after the start, then every mailCheckEvery.
|
||||||
|
func (s *Server) mailLoop(ctx context.Context) {
|
||||||
|
wait := time.Minute
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-time.After(wait):
|
||||||
|
s.checkMail(ctx)
|
||||||
|
wait = mailCheckEvery
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -35,11 +35,16 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// registrarFor names the registrar Jarl can manage z's delegation at:
|
// registrarFor names the registrar Jarl can manage z's delegation at:
|
||||||
// "porkbun" when a key is configured and allows the zone's name.
|
// "porkbun" when a key is configured and allows the zone's name, and the
|
||||||
|
// zone is a registered domain (not a subdomain or a reverse zone, which
|
||||||
|
// their parent zone delegates).
|
||||||
func (s *Server) registrarFor(z *model.Zone) string {
|
func (s *Server) registrarFor(z *model.Zone) string {
|
||||||
if s.cfg.Porkbun == nil || z.Type != model.ZonePrimary {
|
if s.cfg.Porkbun == nil || z.Type != model.ZonePrimary {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
if d, ok := dnsname.RegisteredDomain(z.Name); !ok || d != dnsname.Display(z.Name) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
if s.cfg.PorkbunAllows != nil && !s.cfg.PorkbunAllows(dnsname.Display(z.Name)) {
|
if s.cfg.PorkbunAllows != nil && !s.cfg.PorkbunAllows(dnsname.Display(z.Name)) {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
@@ -59,6 +64,16 @@ func (s *Server) registrarZone(r *http.Request, p *auth.Principal) (*model.State
|
|||||||
return nil, nil, "", err
|
return nil, nil, "", err
|
||||||
}
|
}
|
||||||
if s.registrarFor(z) == "" {
|
if s.registrarFor(z) == "" {
|
||||||
|
d, ok := dnsname.RegisteredDomain(z.Name)
|
||||||
|
switch {
|
||||||
|
case s.cfg.Porkbun == nil:
|
||||||
|
case dnsname.IsReverse(z.Name):
|
||||||
|
return nil, nil, "", badRequest("%s is a reverse zone: whoever holds the addresses (your ISP, or the RIR) delegates it, not a registrar. Check its delegation on the zone's Name servers tab (jarl zone delegation ZONE)", dnsname.Display(z.Name))
|
||||||
|
case !ok:
|
||||||
|
return nil, nil, "", badRequest("%s isn't a domain registered at a registrar (its suffix isn't a public one), so there is nothing to manage at Porkbun", dnsname.Display(z.Name))
|
||||||
|
case d != dnsname.Display(z.Name):
|
||||||
|
return nil, nil, "", badRequest("%s is a subdomain of %s: the NS (and DS) records in the zone %s delegate it, not the registrar. Manage %s at Porkbun instead", dnsname.Display(z.Name), d, d, d)
|
||||||
|
}
|
||||||
if s.cfg.Porkbun == nil {
|
if s.cfg.Porkbun == nil {
|
||||||
return nil, nil, "", badRequest("no registrar is configured: add a Porkbun API key to jarl.yaml (porkbun:, see docs/dnssec.md#porkbun)")
|
return nil, nil, "", badRequest("no registrar is configured: add a Porkbun API key to jarl.yaml (porkbun:, see docs/dnssec.md#porkbun)")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"jarl/internal/auth"
|
||||||
|
"jarl/internal/engine"
|
||||||
|
)
|
||||||
|
|
||||||
|
// zoneFillPTR: 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 (docs/reverse.md).
|
||||||
|
func (s *Server) zoneFillPTR(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||||
|
st, err := s.state(r)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
z, err := s.zoneByPath(r, st, p)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return s.mutate(w, r, p, "", []engine.Request{engine.NewRequest(engine.OpZoneFillPTR, z.ID, engine.FillPTRInput{ZoneID: z.ID, OtherViews: queryBool(r, "other_views")})}, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// reverseSuggestions: GET /reverse-zones/suggestions lists the reverse
|
||||||
|
// zones the private views lack for their records' addresses. ?all=1 adds
|
||||||
|
// the ones not suggested unasked (public views, views with suggestions
|
||||||
|
// off, dismissed), marked hidden; ?view= narrows it to one view.
|
||||||
|
func (s *Server) reverseSuggestions(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||||
|
st, err := s.state(r)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
out := []engine.ReverseSuggestion{}
|
||||||
|
for _, sg := range s.eng.ReverseSuggestions(st, engine.ReverseOptions{All: queryBool(r, "all"), View: r.URL.Query().Get("view")}) {
|
||||||
|
if p.CanSeeZone(sg.Zone+".", sg.View) {
|
||||||
|
out = append(out, sg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, out)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// reverseDismiss: POST /reverse-zones/dismiss {"view": "internal",
|
||||||
|
// "prefix": "185.199.108.153/32"} hides a suggestion; /undismiss shows it
|
||||||
|
// again.
|
||||||
|
func (s *Server) reverseDismiss(dismiss bool) handler {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||||
|
var in engine.DismissInput
|
||||||
|
if err := decodeJSON(r, &in); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
op := engine.OpReverseUndismiss
|
||||||
|
if dismiss {
|
||||||
|
op = engine.OpReverseDismiss
|
||||||
|
}
|
||||||
|
return s.mutate(w, r, p, "", []engine.Request{engine.NewRequest(op, 0, in)}, nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
package server
|
package server
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -14,14 +15,24 @@ import (
|
|||||||
"jarl/internal/stats"
|
"jarl/internal/stats"
|
||||||
)
|
)
|
||||||
|
|
||||||
// statsQuerier checks permission and that statistics are enabled.
|
// statsQuerier checks permission and that statistics are enabled. With
|
||||||
func (s *Server) statsQuerier(p *auth.Principal) (*stats.Querier, error) {
|
// ?views=internal,vps the figures are for those views only.
|
||||||
|
func (s *Server) statsQuerier(r *http.Request, p *auth.Principal) (*stats.Querier, error) {
|
||||||
if err := require(p, auth.Action{Resource: auth.ResStats}); err != nil {
|
if err := require(p, auth.Action{Resource: auth.ResStats}); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if s.statsQ == nil {
|
if s.statsQ == nil {
|
||||||
return nil, &httpError{http.StatusNotFound, "stats_disabled", "query statistics are disabled; set stats.enabled: true in /etc/jarl/jarl.yaml and restart jarl (see docs/stats.md)"}
|
return nil, &httpError{http.StatusNotFound, "stats_disabled", "query statistics are disabled; set stats.enabled: true in /etc/jarl/jarl.yaml and restart jarl (see docs/stats.md)"}
|
||||||
}
|
}
|
||||||
|
var views []string
|
||||||
|
for _, v := range strings.Split(r.URL.Query().Get("views"), ",") {
|
||||||
|
if v = strings.TrimSpace(v); v != "" {
|
||||||
|
views = append(views, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(views) > 0 {
|
||||||
|
return s.statsQ.ForViews(views), nil
|
||||||
|
}
|
||||||
return s.statsQ, nil
|
return s.statsQ, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,7 +57,7 @@ func (s *Server) statsStatus(w http.ResponseWriter, r *http.Request, p *auth.Pri
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) statsSummary(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
func (s *Server) statsSummary(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||||
q, err := s.statsQuerier(p)
|
q, err := s.statsQuerier(r, p)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -66,7 +77,7 @@ func (s *Server) statsSummary(w http.ResponseWriter, r *http.Request, p *auth.Pr
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) statsTop(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
func (s *Server) statsTop(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||||
q, err := s.statsQuerier(p)
|
q, err := s.statsQuerier(r, p)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -93,7 +104,7 @@ func (s *Server) statsTop(w http.ResponseWriter, r *http.Request, p *auth.Princi
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) statsTimeline(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
func (s *Server) statsTimeline(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||||
q, err := s.statsQuerier(p)
|
q, err := s.statsQuerier(r, p)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -124,7 +135,7 @@ func (s *Server) statsTimeline(w http.ResponseWriter, r *http.Request, p *auth.P
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) statsHeatmap(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
func (s *Server) statsHeatmap(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||||
q, err := s.statsQuerier(p)
|
q, err := s.statsQuerier(r, p)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -143,7 +154,7 @@ func (s *Server) statsHeatmap(w http.ResponseWriter, r *http.Request, p *auth.Pr
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) statsBreakdown(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
func (s *Server) statsBreakdown(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||||
q, err := s.statsQuerier(p)
|
q, err := s.statsQuerier(r, p)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -152,6 +163,9 @@ func (s *Server) statsBreakdown(w http.ResponseWriter, r *http.Request, p *auth.
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
es, err := q.Breakdown(r.Context(), r.URL.Query().Get("group"), since)
|
es, err := q.Breakdown(r.Context(), r.URL.Query().Get("group"), since)
|
||||||
|
if errors.Is(err, stats.ErrNotPerView) {
|
||||||
|
return &httpError{http.StatusBadRequest, "not_per_view", r.URL.Query().Get("group") + ": " + err.Error()}
|
||||||
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return badRequest("%v", err)
|
return badRequest("%v", err)
|
||||||
}
|
}
|
||||||
@@ -201,7 +215,7 @@ func clientNames(st *model.State, want map[netip.Addr]bool, readable func(*model
|
|||||||
}
|
}
|
||||||
slices.SortFunc(recs, func(a, b *model.Record) int {
|
slices.SortFunc(recs, func(a, b *model.Record) int {
|
||||||
if a.Name != b.Name {
|
if a.Name != b.Name {
|
||||||
if model.CanonicalLess(a.Name, b.Name) {
|
if model.NameLess(a.Name, b.Name) {
|
||||||
return -1
|
return -1
|
||||||
}
|
}
|
||||||
return 1
|
return 1
|
||||||
@@ -247,7 +261,7 @@ func clientNames(st *model.State, want map[netip.Addr]bool, readable func(*model
|
|||||||
// limits (rndc status), and the space left on the file systems Jarl and
|
// limits (rndc status), and the space left on the file systems Jarl and
|
||||||
// named write to.
|
// named write to.
|
||||||
func (s *Server) statsServer(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
func (s *Server) statsServer(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||||
if _, err := s.statsQuerier(p); err != nil {
|
if _, err := s.statsQuerier(r, p); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
out := map[string]any{"server": s.cfg.Stats.Server()}
|
out := map[string]any{"server": s.cfg.Stats.Server()}
|
||||||
@@ -269,6 +283,72 @@ func (s *Server) statsServer(w http.ResponseWriter, r *http.Request, p *auth.Pri
|
|||||||
labels = append(labels, "statistics database")
|
labels = append(labels, "statistics database")
|
||||||
}
|
}
|
||||||
out["disks"] = stats.DiskUsage(labels, dirs)
|
out["disks"] = stats.DiskUsage(labels, dirs)
|
||||||
|
// The statistics database: what the statistics cost on disk.
|
||||||
|
if db := s.cfg.Stats.Store().Path(); db != "" && db != ":memory:" {
|
||||||
|
info := map[string]any{"path": db, "bytes": dbBytes(db)}
|
||||||
|
if keep := s.cfg.Stats.Options().RetainQueries; keep > 0 && s.cfg.Stats.Options().QueryLog {
|
||||||
|
if n, err := s.cfg.Stats.Store().QueryLogSize(r.Context()); err == nil {
|
||||||
|
info["query_log_rows"] = n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out["database"] = info
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, out)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// statsExplore: GET /stats/explore?client=&name=&domain=&view=&type=&rcode=
|
||||||
|
// &blocked=&since=24h explores the query log (docs/stats.md#exploring):
|
||||||
|
// totals, a series, the top values of each dimension left open, and the
|
||||||
|
// latest answers, with client host names.
|
||||||
|
func (s *Server) statsExplore(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||||
|
q, err := s.statsQuerier(r, p)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
since, err := statsSince(r)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
v := r.URL.Query()
|
||||||
|
f := stats.ExploreFilter{Client: v.Get("client"), Name: v.Get("name"), Domain: v.Get("domain"), View: v.Get("view"),
|
||||||
|
Type: v.Get("type"), RCode: v.Get("rcode"), Blocked: v.Get("blocked"), Since: since}
|
||||||
|
ex, err := q.Explore(r.Context(), f, 15, 100)
|
||||||
|
if errors.Is(err, stats.ErrNoQueryLog) {
|
||||||
|
return &httpError{http.StatusNotFound, "query_log_off", err.Error()}
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := s.nameClients(r, p, ex.By["client"]); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// The recent answers' clients, named once each.
|
||||||
|
var es []stats.Entry
|
||||||
|
seen := map[string]int{}
|
||||||
|
for _, row := range ex.Recent {
|
||||||
|
if _, ok := seen[row.Client]; !ok {
|
||||||
|
seen[row.Client] = len(es)
|
||||||
|
es = append(es, stats.Entry{Key: row.Client})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := s.nameClients(r, p, es); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for i := range ex.Recent {
|
||||||
|
ex.Recent[i].ClientName = es[seen[ex.Recent[i].Client]].Name
|
||||||
|
}
|
||||||
|
// The filter's client, named too.
|
||||||
|
out := struct {
|
||||||
|
*stats.Exploration
|
||||||
|
ClientName string `json:"client_name,omitempty"`
|
||||||
|
}{Exploration: ex}
|
||||||
|
if f.Client != "" {
|
||||||
|
one := []stats.Entry{{Key: f.Client}}
|
||||||
|
if err := s.nameClients(r, p, one); err == nil {
|
||||||
|
out.ClientName = one[0].Name
|
||||||
|
}
|
||||||
|
}
|
||||||
writeJSON(w, http.StatusOK, out)
|
writeJSON(w, http.StatusOK, out)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"slices"
|
||||||
|
|
||||||
|
"jarl/internal/api"
|
||||||
|
"jarl/internal/stats"
|
||||||
|
)
|
||||||
|
|
||||||
|
// storage reports the size of Jarl's databases and how full the disks
|
||||||
|
// Jarl uses are (the dashboard's Storage card).
|
||||||
|
func (s *Server) storage(ctx context.Context) *api.Storage {
|
||||||
|
out := &api.Storage{Databases: []api.Database{}, Disks: []api.Disk{}}
|
||||||
|
dbFile := func(label, path string) *api.Database {
|
||||||
|
if path == "" || path == ":memory:" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
d := api.Database{Label: label, Path: path, Bytes: dbBytes(path)}
|
||||||
|
out.Databases = append(out.Databases, d)
|
||||||
|
out.Total += d.Bytes
|
||||||
|
return &out.Databases[len(out.Databases)-1]
|
||||||
|
}
|
||||||
|
labels := []string{}
|
||||||
|
dirs := map[string]string{}
|
||||||
|
add := func(label, dir string) {
|
||||||
|
if dir != "" {
|
||||||
|
labels = append(labels, label)
|
||||||
|
dirs[label] = dir
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const jarlDB, statsDB = "Jarl's database", "statistics database"
|
||||||
|
if p := s.store.Path(); p != "" {
|
||||||
|
dbFile(jarlDB, p)
|
||||||
|
add(jarlDB, filepath.Dir(p))
|
||||||
|
}
|
||||||
|
if s.cfg.Stats != nil {
|
||||||
|
if p := s.cfg.Stats.Store().Path(); p != "" && p != ":memory:" {
|
||||||
|
if d := dbFile(statsDB, p); d != nil && s.cfg.Stats.Options().RetainQueries > 0 {
|
||||||
|
if n, err := s.cfg.Stats.Store().QueryLogSize(ctx); err == nil {
|
||||||
|
d.QueryLogRows = &n
|
||||||
|
d.QueryLogRetention = s.cfg.Stats.Status().Retention.Queries
|
||||||
|
}
|
||||||
|
}
|
||||||
|
add(statsDB, filepath.Dir(p))
|
||||||
|
}
|
||||||
|
if lf := s.cfg.Stats.Options().LogFile; lf != "" {
|
||||||
|
add("named's logs", filepath.Dir(lf))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
paths := s.eng.Config().Paths
|
||||||
|
add("zone files", paths.ZoneDir)
|
||||||
|
add("named.conf", paths.ConfigDir)
|
||||||
|
add("Jarl's cache and blocklists", paths.CacheDir)
|
||||||
|
add("backups", s.cfg.BackupDir)
|
||||||
|
for _, d := range stats.DiskUsage(labels, dirs) {
|
||||||
|
disk := api.Disk{Path: d.Path, Holds: d.Holds, Total: d.Total, Free: d.Free}
|
||||||
|
for _, db := range out.Databases {
|
||||||
|
if slices.Contains(d.Holds, db.Label) {
|
||||||
|
disk.Databases += db.Bytes
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.Disks = append(out.Disks, disk)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// dbBytes is the size of a SQLite database: its file, -wal and -shm.
|
||||||
|
func dbBytes(path string) int64 {
|
||||||
|
var n int64
|
||||||
|
for _, suffix := range []string{"", "-wal", "-shm"} {
|
||||||
|
if fi, err := os.Stat(path + suffix); err == nil {
|
||||||
|
n += fi.Size()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
@@ -34,7 +34,7 @@ func (s *Server) zoneByPath(r *http.Request, st *model.State, p *auth.Principal)
|
|||||||
name = "."
|
name = "."
|
||||||
}
|
}
|
||||||
var err error
|
var err error
|
||||||
z, err = engine.FindZoneRef(st, name, r.URL.Query().Get("view"))
|
z, err = engine.FindZoneRef(st, name, r.URL.Query().Get("view"), func(z *model.Zone) bool { return p.CanSeeZone(z.Name, st.ViewName(z)) })
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -289,6 +289,20 @@ func (s *Server) collectState(ctx context.Context, pw *promWriter, now time.Time
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if h := s.encryptedDNSHealth(); h != nil {
|
||||||
|
for _, pr := range h.Probes {
|
||||||
|
pw.gauge("jarl_encrypted_dns_up", "1 when the DNS over TLS or HTTPS listener answered with the right, current certificate at the last check (every 10 minutes).", b2f(pr.OK),
|
||||||
|
"kind", pr.Kind, "address", pr.Address, "port", strconv.Itoa(pr.Port))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if m := s.mailReport(); m != nil {
|
||||||
|
for _, r := range m.Results {
|
||||||
|
pw.gauge("jarl_mail_reverse_dns_ok", "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).", b2f(r.OK),
|
||||||
|
"zone", r.Zone, "address", r.Address)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if tokens, err := s.store.ListTokens(ctx, 0); err == nil {
|
if tokens, err := s.store.ListTokens(ctx, 0); err == nil {
|
||||||
for _, t := range tokens {
|
for _, t := range tokens {
|
||||||
if t.ExpiresAt != nil && t.RevokedAt == nil {
|
if t.ExpiresAt != nil && t.RevokedAt == nil {
|
||||||
|
|||||||
@@ -22,6 +22,42 @@ func (s *Server) presentZone(st *model.State, z *model.Zone, counts map[int64]re
|
|||||||
out.RName = dnsname.EmailFromMailbox(z.SOA.RName)
|
out.RName = dnsname.EmailFromMailbox(z.SOA.RName)
|
||||||
}
|
}
|
||||||
out.Registrar = s.registrarFor(z)
|
out.Registrar = s.registrarFor(z)
|
||||||
|
if z.Type == model.ZonePrimary && z.ViewID != 0 {
|
||||||
|
for _, o := range st.ZonesNamed(z.Name) {
|
||||||
|
if o.ID != z.ID && o.Type == model.ZonePrimary {
|
||||||
|
out.OtherViews = append(out.OtherViews, st.ViewName(o))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if z.ViewID != 0 {
|
||||||
|
others := st.ZonesNamed(z.Name)
|
||||||
|
st.SortByView(others)
|
||||||
|
for _, o := range others {
|
||||||
|
if o.ID != z.ID {
|
||||||
|
out.InViews = append(out.InViews, api.ZoneLink{ID: o.ID, View: st.ViewName(o), Type: o.Type, Linked: z.LinkGroup != 0 && o.LinkGroup == z.LinkGroup})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if pf, ok := dnsname.PrefixFromReverse(z.Name); ok {
|
||||||
|
out.Prefix = pf.String()
|
||||||
|
if z.Type == model.ZonePrimary {
|
||||||
|
fills := engine.PTRFills(st, z.ViewID, z.Name, st.ZoneRecords(z.ID), false)
|
||||||
|
out.PTRMissing = len(fills)
|
||||||
|
for _, f := range fills[:min(len(fills), 25)] {
|
||||||
|
out.PTRMissingList = append(out.PTRMissingList, api.PTRFill(f))
|
||||||
|
}
|
||||||
|
if z.ViewID != 0 {
|
||||||
|
for _, f := range engine.PTRFills(st, z.ViewID, z.Name, st.ZoneRecords(z.ID), true) {
|
||||||
|
if f.From == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if out.PTROtherViews++; out.PTROtherViews <= 25 {
|
||||||
|
out.PTROtherViewsList = append(out.PTROtherViewsList, api.PTRFill(f))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
for _, o := range st.LinkedWith(z) {
|
for _, o := range st.LinkedWith(z) {
|
||||||
out.LinkedWith = append(out.LinkedWith, st.ViewName(o))
|
out.LinkedWith = append(out.LinkedWith, st.ViewName(o))
|
||||||
out.Links = append(out.Links, api.ZoneLink{ID: o.ID, View: st.ViewName(o)})
|
out.Links = append(out.Links, api.ZoneLink{ID: o.ID, View: st.ViewName(o)})
|
||||||
@@ -60,6 +96,22 @@ func presentView(st *model.State, v *model.View, colors map[int64]string) api.Vi
|
|||||||
return api.View{View: *v, Zones: len(st.ZonesInView(v.ID)), Color: colors[v.ID]}
|
return api.View{View: *v, Zones: len(st.ZonesInView(v.ID)), Color: colors[v.ID]}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// limitedView presents a view to a principal without views:read who can
|
||||||
|
// see zones in it (the zone list names the view anyway): its name and
|
||||||
|
// colour, and how many of its zones they can see. nil when they see none.
|
||||||
|
func limitedView(st *model.State, v *model.View, colors map[int64]string, p *auth.Principal) *api.View {
|
||||||
|
n := 0
|
||||||
|
for _, z := range st.ZonesInView(v.ID) {
|
||||||
|
if p.CanSeeZone(z.Name, v.Name) {
|
||||||
|
n++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if n == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &api.View{View: model.View{ID: v.ID, Name: v.Name, Position: v.Position}, Zones: n, Color: colors[v.ID], Limited: true}
|
||||||
|
}
|
||||||
|
|
||||||
func presentACL(st *model.State, a *model.ACL) api.ACL {
|
func presentACL(st *model.State, a *model.ACL) api.ACL {
|
||||||
used := engine.FindACLRefs(st, a.Name)
|
used := engine.FindACLRefs(st, a.Name)
|
||||||
if used == nil {
|
if used == nil {
|
||||||
@@ -111,7 +163,7 @@ func presentOption(st *model.State, o *model.Option) api.Option {
|
|||||||
if c, ok := catalog.Default().Lookup(scope, o.Name); ok {
|
if c, ok := catalog.Default().Lookup(scope, o.Name); ok {
|
||||||
out.Description, out.Status = c.Description, c.Status
|
out.Description, out.Status = c.Description, c.Status
|
||||||
}
|
}
|
||||||
if f := namedconf.FormatValue(o.Name, o.Value); f != o.Value {
|
if f := namedconf.FormatValueList(o.Name, o.Value, 3); f != o.Value {
|
||||||
out.Formatted = f
|
out.Formatted = f
|
||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
|
|||||||
@@ -170,3 +170,29 @@ func writeKey(t *testing.T, dir, zone string) uint16 {
|
|||||||
os.WriteFile(base+".state", []byte("Algorithm: 13\nKSK: yes\nZSK: yes\nGoalState: omnipresent\nDNSKEYState: omnipresent\nKRRSIGState: omnipresent\nZRRSIGState: omnipresent\nDSState: rumoured\n"), 0o644)
|
os.WriteFile(base+".state", []byte("Algorithm: 13\nKSK: yes\nZSK: yes\nGoalState: omnipresent\nDNSKEYState: omnipresent\nKRRSIGState: omnipresent\nZRRSIGState: omnipresent\nDSState: rumoured\n"), 0o644)
|
||||||
return k.KeyTag()
|
return k.KeyTag()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// With no porkbun.domains, only zones that are registered domains get the
|
||||||
|
// registrar: not subdomains or reverse zones, which their parent delegates.
|
||||||
|
func TestRegistrarOnlyRegisteredDomains(t *testing.T) {
|
||||||
|
fake := porkbun.NewFake("pk1_a", "sk1_b", "adbraun.lol")
|
||||||
|
pts := httptest.NewServer(fake)
|
||||||
|
t.Cleanup(pts.Close)
|
||||||
|
e := newAPIEnvConfig(t, Config{Version: "test", Porkbun: &porkbun.Client{BaseURL: pts.URL + "/api/json/v3", APIKey: "pk1_a", Secret: "sk1_b"}})
|
||||||
|
for _, z := range []string{"adbraun.lol", "lab.adbraun.lol", "10.0.10.0/24", "home.lan"} {
|
||||||
|
e.do("POST", "/zones", e.token, map[string]any{"name": z, "nameservers": []string{"ns1.example.net."}}, 201)
|
||||||
|
}
|
||||||
|
for zone, want := range map[string]string{"adbraun.lol": "porkbun", "lab.adbraun.lol": "", "10.0.10.in-addr.arpa": "", "home.lan": ""} {
|
||||||
|
got, _ := e.do("GET", "/zones/"+zone, e.token, nil, 200)["registrar"].(string)
|
||||||
|
if got != want {
|
||||||
|
t.Errorf("%s: registrar %q, want %q", zone, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for zone, msg := range map[string]string{"lab.adbraun.lol": "subdomain of adbraun.lol", "10.0.10.in-addr.arpa": "reverse zone", "home.lan": "isn't a domain registered"} {
|
||||||
|
out := e.do("GET", "/zones/"+zone+"/registrar", e.token, nil, 400)
|
||||||
|
errObj, _ := out["error"].(map[string]any)
|
||||||
|
if m, _ := errObj["message"].(string); !strings.Contains(m, msg) {
|
||||||
|
t.Errorf("%s: %v", zone, out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
e.do("GET", "/zones/adbraun.lol/registrar", e.token, nil, 200)
|
||||||
|
}
|
||||||
Loaded 100 of 135 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user