Compare commits

...
46 Commits
Author SHA1 Message Date
adamandClaude Opus 5.5 67a829be34 Release 0.6.4-rc.2
release / release (push) Successful in 4m44s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 00:24:45 +01:00
adamandClaude Opus 5.5 42e79af678 cert get: combined file for HAProxy, --local, saved NAME; cert list shows get and last download
- jarl cert get --combined PATH writes the key and full chain in one file
  (mode 600, atomic), kept outside --dir so it can live in HAProxy's crt
  directory; JARL_CERT_COMBINED for the hook. Turning it on, moving it or
  removing it ('') applies from the files on disk and runs the hook.
- jarl cert get --local writes the combined file and runs the hook from
  this host's files, without asking jarld.
- jarl cert saved NAME shows all of one certificate's settings.
- jarl cert list: USES "get" for certificates for other hosts, and a
  PULLED column (last download, "(old)" while a host is behind). Owners
  now see their own downloads (pulled_by), not only administrators.
- jarl cert get without a name refuses per-certificate flags.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 00:24:33 +01:00
adamandClaude Opus 5.5 3fb159cf4d Release 0.6.4-rc.1
release / release (push) Successful in 4m42s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 18:58:56 +01:00
adamandClaude Opus 5.5 71ebed2b73 Log failed logins and refused tokens, address first
release / release (push) Failing after 18s
For Guard's jarl preset and fail2ban: msg="login failed" ip=... user=...
and msg="token refused" ip=..., beside the audit records.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 16:29:37 +01:00
adamandClaude Opus 5.5 95125e7f50 Release 0.6.3
Documentation: lego 5's commands beside lego 4's, and lego on a host
inside a split DNS (its propagation check and the ways round it).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 22:44:01 +01:00
adamandClaude Opus 5.5 c10ebe0d38 Docs: lego 5, and split DNS with lego's propagation check
docs/acme.md and jarl-lego show lego 5's commands (lego run, which also
renews; lego migrate when upgrading) beside lego 4's. A new section
explains why lego on a host inside a split DNS fails its own propagation
check when the challenge goes into the external view only (JARL_VIEW, a
one-view token, httpreq's /view/...), and the ways round it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 22:44:00 +01:00
adamandClaude Opus 5.5 3d1911dd38 Release 0.6.2
release / release (push) Successful in 4m45s
A certificates role (template) for tokens and users that get
certificates for other hosts with jarl cert get.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 22:08:54 +01:00
adamandClaude Opus 5.5 310ed8061e Permissions: a certificates role
A template for tokens and users that get certificates for other hosts
(jarl cert get): certificates:write, to narrow with names= in the web
UI's "Insert a template…", or --role certificates in the CLI. The token
form's template filter no longer counts "*" as covering certificates,
as the server doesn't.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 22:07:49 +01:00
adamandClaude Opus 5.5 b367e0c830 Release 0.6.1
release / release (push) Successful in 4m42s
Certificates for other hosts (jarl cert get, with a daily timer),
certificate export, PTR record and override fixes in linked zones,
one-view tokens with linked zones, numeric order in reverse zones, and
cert get refusing to take over a name saved for another Jarl server.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 22:42:42 +01:00
adamandClaude Opus 5.5 254867c61d CLI: cert get doesn't take over another server's saved name
Each Jarl server has its own certificate names, but a host keeps one
saved NAME.json (and, by default, one /etc/jarl/certs/NAME). Asking a
second server for the same name overwrote the first one's settings and
files, so the first certificate quietly stopped being renewed. Now that
is refused, as is a --dir holding another certificate's files;
--replace takes the name or directory over. A --dir another saved
certificate uses is always refused.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 22:42:42 +01:00
adamandClaude Opus 5.5 3ddd32f97f Release 0.6.1-rc.4
release / release (push) Successful in 4m43s
0.6.1's fourth release candidate: the Stats page's range buttons follow
the retention settings, jarl changeset show prints local time, and the CLI
no longer prints "and N more" when the server sends no PTR list.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 22:32:19 +01:00
adamandClaude Opus 5.5 0976faba3f CLI: changeset show prints local time
release / release (push) Failing after 18s
Created and Applied were printed in UTC, while history and status print
local time.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 17:29:43 +01:00
adamandClaude Opus 5.5 1d535c8063 Stats: range buttons follow the retention settings
The Statistics page offers only the ranges kept (longest of the minute,
hour and day retentions), adds 1y and 2y when the daily data covers them,
caps the heatmap at the hourly retention and links to Explore within
retention.queries. Status and explore send retention_seconds. The query
log error names stats.retention.queries.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 15:19:12 +01:00
adamandClaude Opus 5.5 7f3564c0a5 CLI: don't print "and N more" when the server sends no PTR list
A 0.6.1-rc.2 server sends ptr_other_views without ptr_other_views_list,
so zone show printed only "and 1 more" under "Other views:".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 15:05:50 +01:00
adamandClaude Opus 5.5 71d9a06e43 Release 0.6.1-rc.3
release / release (push) Successful in 4m39s
0.6.1's third release candidate: reverse zones list the addresses only
other views' records use, with clearer wording, and jarl zone show lists
missing PTR records.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 14:46:12 +01:00
adamandClaude Opus 5.5 2fa9828bcf Reverse zones: list other views' addresses, clearer wording
The zone page and jarl zone show list the addresses only other views'
records use (ptr_other_views_list), with the name each would get and the
view it comes from, and say plainly that they aren't missing here. The
page's button reads 'Add PTR records for them in view X'. zone show
lists the missing PTRs too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 14:46:12 +01:00
adamandClaude Opus 5.5 a93373687e Release 0.6.1-rc.2
release / release (push) Successful in 6m30s
0.6.1's second release candidate: reverse zones sort numeric labels by
value (2 before 101), and a reverse zone no longer reports addresses
only other views' records use as missing PTR records; filling names
them from the other view only with --other-views.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 11:42:34 +01:00
adamandClaude Opus 5.5 6cd3f6ef4e Reverse zones: other views' addresses aren't missing PTRs
A reverse zone's missing PTR count covers only its own view's A and
AAAA records. Addresses only other views' records use are counted apart
(ptr_other_views, 'Other views' in zone show, a note on the zone page)
and filled only when asked: fill-ptr --other-views, zone create
--fill --other-views, or the New reverse zone dialog's checkbox. This
keeps one view's host names out of another view's reverse zones.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 11:40:05 +01:00
adamandClaude Opus 5.5 d18fe18ccd Sort numeric labels by value
Records, zones and findings now order all-digit labels numerically, so
reverse zones list 2 before 101. model.CanonicalLess is replaced by
model.NameLess; other labels keep byte order.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 08:49:58 +01:00
adamandClaude Opus 5.5 fa8feb6bd9 Release 0.6.1-rc.1
release / release (push) Successful in 4m41s
0.6.1's first release candidate: certificates for other hosts (jarl cert
get, cert schedule, the certificates grant), certificate export, the
missing PTR list, links to a zone in other views, one-view tokens with
linked zones, PTR records and overrides in linked zones per view, the
registrar and DS guidance for subdomain and reverse zones, the stats
heatmap and cname_chain fixes. Upgrading a 0.6.0 database was tested.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 17:00:20 +01:00
adamandClaude Opus 5.5 12013b12d5 Stats heatmap: empty hours same height; cname_chain only for long chains
The "Busiest times" grid's hours without data used the class "empty",
which picked up the padding of empty messages. One CNAME to another no
longer warns; three or more get an info note showing the chain.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 16:58:40 +01:00
adamandClaude Opus 5.5 9db76a113c Certificates for other hosts: jarl cert get
jarld obtains and renews a certificate for another host; the host asks
for it with one command, waits, downloads it when it is newer than its
copy and runs a hook with the paths in JARL_CERT_* variables.

- Certificates have an owner user; another user can't take the name.
  An existing one is obtained again only when a name is added or
  removed. A new certificates grant (names=) 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.
- POST /certificates/{name}/request; owners may list, show, export and
  delete theirs; keys only over HTTPS, the socket or loopback; every
  download is recorded and shown as "Pulled by".
- CLI: jarl cert get (saved settings, all saved when no name), cert
  saved, cert forget, cert schedule (daily systemd timer, Linux, root).
- Migration 14. Tests: server, auth, and e2e against Pebble.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 16:58:40 +01:00
adamandClaude Opus 5.5 b4bb99b482 Certificate export, missing PTR list, simpler "Used for"
- Certificates can be exported for use outside Jarl: Export… on the
  Certificates page (download or copy), jarl cert export, and
  GET /certificates/{name}/pem?part=fullchain|cert|key|bundle.
  Administrators only; key exports are audited.
- A reverse zone's missing-PTR banner lists the addresses and names
  (ptr_missing_list in the zone API).
- "Used for" shows DoT/DoH for named, and "not used by Jarl" instead of
  "nothing yet".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 16:07:26 +01:00
adamandClaude Opus 5.5 57012fece8 One-view tokens: local records in linked zones, zone lookup, view list
A token for one view of a linked zone (an ACME token for external)
couldn't add records: a new record was shared and reached the other
view. A new name and type is now local when the user may not change it
in the other views. A zone name in several views resolves to the only
one the user can see, and the error no longer names views they can't.
GET /views lists the views a user sees zones in (name, colour, count;
limited) without views:read.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 15:57:06 +01:00
adamandClaude Opus 5.5 4cc2ab2287 Zone page: links to the same zone in the other views
The header says "also in VIEW" for every zone of the same name in
another view, linked or not, in the view's colour, keeping the open tab
where the other zone has it. The zone API gains in_views.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 15:45:41 +01:00
adamandClaude Opus 5.5 a36c54ce60 DNSSEC: say where the DS record really goes
The DNSSEC tab said "give this to your registrar" for every zone whose
parent wasn't in Jarl in the same view. The DNSSEC response now has
ds_at/ds_place/ds_note: the parent zone in Jarl (any view, its own
first, then a public one), the registrar for a registered domain, the
parent zone wherever it is hosted for a subdomain, the ISP or RIR for a
reverse zone, and nowhere for a private name. The page and the CLI use
them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 15:40:58 +01:00
adamandClaude Opus 5.5 82c282cd32 Registrar: only for zones that are a registered domain
With porkbun.domains empty the Registrar tab was offered for every zone
and always failed for subdomain zones, reverse zones and private names.
dnsname.RegisteredDomain (public suffix list) now decides; the others
get a message naming what delegates them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 15:36:39 +01:00
adamandClaude Opus 5.5 65086e314a Linked zones: PTR records follow each view, deleting an override stays deleted
Auto-PTR (and --ptr) changed PTR records in every linked view, whatever
view the address record was in. ptrSync now compares each touched A/AAAA
set before and after, per view, and changes only that view's reverse
zone. PTR records in linked reverse zones are local (fill too); a shared
PTR set is made local in each view before one view changes it.

Deleting a linked zone's local records no longer brings the shared ones
back into the view: the name and type are gone from that view and the
other views keep theirs, local to each. Share still restores.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 15:29:12 +01:00
adamandClaude Opus 5.5 e38390df7d Zone settings: no empty changes, keep an outside name server absolute
- A zone update that changes nothing stages nothing: saving a zone's
  settings unchanged says "Nothing changed" instead of staging an empty
  change. Staging operations that change nothing is a 400 "nothing to
  change".
- The settings form showed the SOA MNAME without its final dot, which the
  server reads as relative to the zone, so saving wg.adbraun.lol turned
  ns1.adbraun.lol. into ns1.adbraun.lol.wg.adbraun.lol. Names outside the
  zone keep the dot, and MNAME is only sent when edited.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 15:13:01 +01:00
adamandClaude Opus 5.5 0e663ab36c New token form: only templates within what the token's user may do
A token never does more than its user, so offering wider templates only
misled. On the Users page the templates follow the token's user, not the
administrator making it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 13:42:37 +01:00
adamandClaude Opus 5.5 5cd22f52d4 Compare views: start with a view the zone is linked to
The tab picked the first other view in view order, so a zone linked with
one view opened compared with an unrelated one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 20:09:52 +01:00
adamandClaude Opus 5.5 aca0abc780 Add the jarld.service alias for jarl.service
systemctl restart/status jarld now work too. The package relinks a unit
enabled by an older version on upgrade, so the alias appears without
re-enabling; make uninstall removes the link.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 19:54:57 +01:00
adamandClaude Opus 5.5 9175d75d61 Add AGENTS.md and CLAUDE.md for coding agents
Ground rules, building and testing with real BIND, where things live,
web UI rules, BIND gotchas, and the git and release workflow.
docs/development.md lists the packages its layout was missing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 19:50:14 +01:00
adamandClaude Opus 5.5 48f0082d8b Release 0.6.0
release / release (push) Successful in 2m14s
Reverse zones made easy, comparing views rebuilt, exploring the statistics through a query log (filters, several values, exclusions), storage on the dashboard, encrypted DNS with several host names and views per listen address, option set matching named statements, forwarding over TLS documented. Upgrade from 0.5.2 tested in Debian 13.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 19:44:10 +01:00
adamandClaude Opus 5.5 d4399cf630 Release 0.6.0-rc.3
release / release (push) Successful in 2m11s
Since rc.2: the Encrypted DNS page and docs test with BIND's dig +tls / +https instead of kdig and curl's JSON API.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 19:38:27 +01:00
adamandClaude Opus 5.5 d2a25d654d Encrypted DNS: test with BIND's dig, not kdig or curl's JSON API
The client hints and docs tested DoT with kdig (Knot's, often not installed) and DoH with curl's application/dns-json, which named's DoH doesn't speak (RFC 8484 wire format only). They use dig +tls / dig +https with +tls-ca and +tls-hostname now, with the port when it isn't the default.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 19:38:25 +01:00
adamandClaude Opus 5.5 87368c4aaf Release 0.6.0-rc.2
release / release (push) Successful in 2m13s
Since rc.1: the explore page has a filter bar, several values per filter, !value to leave one out and *.domain, a hide link on list entries, and refreshes with its own filters (the Statistics page's refresh drew over it).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 19:19:04 +01:00
adamandClaude Opus 5.5 7d93c373d1 Explore: refresh with its own filters; the Statistics refresh stays on its page
The Statistics page's once-a-minute refresh also ran on #/stats/explore and drew the Statistics page over it, losing the filters. It now runs on the Statistics page only, and the explore page refreshes itself every minute with the same filters (not while the filter bar is being edited).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 19:16:10 +01:00
adamandClaude Opus 5.5 e94d3634b6 Explore: several values per filter, !value to leave one out, *.domain
Each explore filter takes several values, comma separated (any of them), !value to leave one out, and *.example.com for a name and the names below it. Every value is a chip with its own ✕; clicking a value in the lists adds it, and hide leaves it out. The filter bar's fields take lists. domain= still works.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 19:10:00 +01:00
adamandClaude Opus 5.5 a8c7760fc8 Explore: a filter bar, and Explore the answers on the Statistics page
The explore page has a filter bar to type filters in (client, name or it and below, view, type, result, blocked), starting from the current filters and suggesting values from what is shown, so taking every filter out no longer means going back. The Statistics page opens it with Explore the answers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 18:58:45 +01:00
adamandClaude Opus 5.5 5443e95897 Release 0.6.0-rc.1
release / release (push) Successful in 2m13s
0.6.0's first release candidate: reverse zones made easy, comparing views rebuilt, exploring the statistics through a query log, storage on the dashboard, encrypted DNS with several host names and views per listen address, option set matching named statements, forwarding over TLS documented. The upgrade from 0.5.2 packages was tested in a Debian 13 container.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 18:44:50 +01:00
adamandClaude Opus 5.5 ea26171875 option set: statements with a name replace only that one; forwarding over TLS documented
jarl option set tls NAME (and key, remote-servers, primaries, http, dnssec-policy, key-store, server, acl, view, zone, parental-agents) matches the name after the keyword too: setting tls cloudflare no longer replaces tls quad9. jarl option unset tls NAME removes one by its name. docs/encrypted-dns.md has forwarding to Quad9 or Cloudflare over TLS: one changeset, the options explained, several providers, checking it works, and why not over HTTPS.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 18:39:28 +01:00
adamandClaude Opus 5.5 250e734046 Query log wording: N answers so far, each kept for 7 days
"The query log holds 7 answers (stats.retention.queries)" read like 7 days' worth. The dashboard and the Statistics page now say how many answers it holds and how long each is kept; the dashboard's storage info has query_log_retention.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 18:27:56 +01:00
adamandClaude Opus 5.5 1b102a78f6 Statistics page: the statistics database's size, and a fullness bar on the disk tiles
The server section shows the statistics database's size (with its -wal and -shm) and how many answers the query log holds, linking to the dashboard's Storage panel; the disk tiles get the same bar as the dashboard. GET /stats/server has database {path, bytes, query_log_rows}.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 18:24:32 +01:00
adamandClaude Opus 5.5 01ec37b9b2 Explore the statistics: a query log to drill down by client, name, view and more; storage on the dashboard
jarld keeps every answer for a while (stats.retention.queries, default 7 days) in the statistics database: time, client, view, name, type, result and what blocked it. 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 values of every other dimension (each a link that narrows further) and the latest answers. GET /stats/explore.

The dashboard shows the size of Jarl's databases (and the query log's rows) and, per disk Jarl uses, how full it is and the databases' share.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 18:15:36 +01:00
adamandClaude Opus 5.5 fef916b7ab Encrypted DNS: several host names, views per listen address, what named uses a certificate for
The setup takes several host names (one certificate for all; the health check says when the served certificate misses one), stored in the settings. Each listen address gets its own views for the host names' A/AAAA records (--address ADDR=VIEW,..., a view checkbox per address in the form), always local to the view, with a warning when a public view would get a private address; a view no longer given loses the address, other records of the names stay, and the form starts from where the records are now. Certificates (page and jarl cert show) say what named uses each for: DNS over TLS or HTTPS, on which addresses and ports.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 17:49:49 +01:00
109 changed files with 6574 additions and 603 deletions

No files matched your search

+197
View File
@@ -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.
+273 -1
View File
@@ -8,6 +8,238 @@ All notable changes to Jarl are documented here. The format follows
### Added ### Added
- **Failed logins in the journal.** A failed login (a wrong password, or
a wrong two-factor code) and a refused API token are logged as well as
audited, with the address first (`msg="login failed" ip=… user=…`,
`msg="token refused" ip=…`), for Guard's `jarl` preset or a fail2ban
filter to ban such addresses (docs/operations.md).
- **`jarl cert get --combined PATH`** also writes the key and the full
chain in one file (mode 600, written all at once), for HAProxy. It
belongs outside `--dir`, so a directory of combined files can be
HAProxy's `crt` directory. The hook gets it as `JARL_CERT_COMBINED`.
Adding it to a certificate this host already has writes it from the
files on disk and runs the hook; `--combined ''` removes it.
- **`jarl cert get --local`** doesn't ask jarld: it writes the combined
file and runs the hook from this host's files, to apply a changed
setting or test a hook.
- **`jarl cert saved NAME`** shows all of one certificate's saved
settings (names, key type, dir, combined file, hook, server) and this
host's copy. The token is never shown.
- `jarl cert list` has a PULLED column: when the certificate was last
downloaded, with `(old)` while a host hasn't fetched the renewed one.
The owner of a certificate obtained with `jarl cert get` now sees their
own downloads (`pulled_by`) in `jarl cert list` and `jarl cert show`, not
only administrators.
### Changed
- `jarl cert list` shows `get` as USES for a certificate obtained with
`jarl cert get`, not `-`.
- `jarl cert get` without a name refuses `--dir`, `--hook`, `--key-type`,
`--combined`, `--replace` and `--no-save`, which change one
certificate; they used to be ignored.
## [0.6.3] - 2026-10-21
### Added
- A docs/acme.md section: lego on a host inside a split DNS fails its own
propagation check when the challenge is written into the external view
only (`JARL_VIEW`, a one-view token, `.../httpreq/view/...`): why, and
the ways round it (no view limit, `--dns.propagation.disable-ans` or
`--dns.propagation.wait`, Traefik's `propagation.disableANSChecks`).
- docs/acme.md and `jarl-lego` show lego 5's commands (`lego run ...`,
which also renews; `lego migrate` when upgrading), with lego 4's
beside them. Jarl's side is the same for both.
## [0.6.2] - 2026-10-21
### Added
- A `certificates` role (template): `certificates:write`, for tokens and
users that get certificates for other hosts with `jarl cert get`. In
the web UI's user and token forms it is in "Insert a template…", to
narrow with `names=`; in the CLI, `--role certificates`.
## [0.6.1] - 2026-10-20
### Added
- Certificates for other hosts: `jarl cert get NAME -d DOMAIN --dir DIR
--hook CMD` on a mail server or proxy has jarld obtain the certificate
(and renew it), waits, downloads it when it is newer than the host's
copy, and runs the hook with the file paths in `JARL_CERT_*`
variables. Its settings are saved, so `jarl cert get` alone refreshes
them all; `jarl cert schedule` writes a daily systemd timer for that;
`jarl cert saved` lists them with their expiry; `jarl cert forget`
stops one. A certificate belongs to the user that asked for it (other
users can't take the name), is obtained again only when a name is
added or removed, and the Certificates page shows its owner and who
downloaded it, flagging hosts that haven't fetched a renewal. A new
`certificates` grant (`certificates:write names=*.adbraun.lol`) says
which names a user may have; it is never part of `*`. Users may own
`certificates.max_per_user` certificates (10), or their own
`--cert-limit`. API: `POST /certificates/{name}/request`.
- Certificates can be exported for use outside Jarl: **Export…** on the
Certificates page (download, or copy to the clipboard),
`jarl cert export NAME [--dir DIR | --part PART]`
and `GET /certificates/{name}/pem?part=fullchain|cert|key|bundle`.
Administrators only; every export of the private key is audited
(`certificate.export_key`).
- A reverse zone's "addresses have no PTR record" banner lists them, with
the name each would get, instead of only counting them
(`ptr_missing_list` in the zone API).
- A zone's page links to the same zone in the other views ("also in
external"), in each view's colour, keeping the tab you are on. It
works for zones that aren't linked too, and the zone API's new
`in_views` lists them.
- `jarl.service` has the alias `jarld.service`, so `systemctl restart
jarld` works too. The alias is linked when the unit is enabled; the
package relinks a unit enabled by an older version on upgrade.
### Changed
- The Statistics page's range buttons follow `stats.retention`: ranges
longer than any data kept are left out, and **1y** and **2y** appear
when `retention.day` (or `retention.hour`) covers them (with the
default 730d, both do). The busiest-times heatmap says how far back it
really goes (at most `retention.hour`), and links to the Explore page
keep within `retention.queries` instead of assuming 7 days.
`GET /stats/status` and `GET /stats/explore` add `retention_seconds`.
- A reverse zone's missing PTR records count only its own view's A and
AAAA records. An address only another view's records use (an internal
host, seen from the vps view's copy of `10.0.10.in-addr.arpa`) is no
longer reported as missing; it shows apart, as `Other views` in `jarl
zone show`, `ptr_other_views` in the API and a note on the zone's page.
Filling (`--fill`, `jarl zone fill-ptr`, the page's button) names those
from the other view only when asked, with `--other-views`
(`fill_other_views`, `?other_views=1`, a checkbox in the New reverse
zone dialog), so one view's host names don't leak into another. When
there is nothing else to fill, Jarl says how many there are and how to
add them. The page and `jarl zone show` list those addresses with the
name each would get, and the view it comes from
(`ptr_other_views_list`); `jarl zone show` lists the missing ones too.
- Records, zones and validation findings sort numeric labels by value, so
`2.10.0.10.in-addr.arpa` comes before `101.10.0.10.in-addr.arpa` in the
web UI, the CLI and the rendered zone files. Names still group by label
from the right (subdomains stay under their parent); other labels keep
the old byte order (`host10` before `host2`). Zone files are rewritten
in the new order the next time they change; named doesn't care about
record order.
- `cname_chain` no longer warns about one CNAME pointing at another (a
service name at a host alias is common, and named answers the chain at
once). A chain of three or more CNAMEs gets an info note that shows
it.
- A certificate's private key is only exported over HTTPS, the local
socket, or to the server itself (plain HTTP from another host is
refused with `https_required`).
- The Certificates page's "Used for" column says DoT and DoH for named,
with the addresses in the tooltip, instead of listing every address. A
certificate with no use says "not used by Jarl" rather than "nothing
yet": it may well be used elsewhere.
- Deleting a linked zone's local records (an override) removes that name
and type from that view only. It used to bring the shared records back
into the view, so a delete looked undone. The other views keep their
records, local to each now; **Share** one view's version (`jarl record
update ID --shared`) to have it everywhere again.
- A linked zone's Compare views tab starts with a view it is linked to,
not the first other view.
- The New token form only offers templates within what the token's user
may do. A token never does more than its user, so offering more only
misled.
- A zone update that changes nothing is no change: saving a zone's
settings without changing them says "Nothing changed" instead of
staging an empty change. Staging operations that change nothing
(`POST /changesets/{id}/ops`, or `?stage=`) is a 400 "nothing to
change".
### Fixed
- `jarl cert get NAME` no longer takes over a name this host has saved
for another Jarl server, which overwrote that certificate's settings
(and, with the default `--dir`, its files), so it stopped being
renewed. It is refused, as is a `--dir` holding another certificate's
files; `--replace` takes the name or directory over.
- The "query log is off" error named a setting that doesn't exist,
`stats.query_retention`; it's `stats.retention.queries`.
- `jarl zone show` for a reverse zone printed only "and 1 more" under
"Other views:" when the server was older than the CLI (0.6.1-rc.2
sends the count but not the addresses). It now prints just the count
line then.
- `jarl changeset show` printed its Created and Applied times in UTC,
while `jarl history` and `jarl status` print local time. It uses local
time now too.
- The statistics' "Busiest times" grid drew hours without data taller
than the others (they picked up the page's padding for empty
messages).
- A token for one view of a linked zone (such as an ACME token with
`views=external`) couldn't add records: a new record is shared, and
that reached the other view. A new name and type is now local to the
view when the user may not change it in the other views.
- A zone name in several views had to be given with `--view` even when
the user could see it in only one (`jarl record list adbraun.lol` with
a one-view token), and the error named views the user can't see. Now
the one they can see is used.
- `jarl view list` was empty for a user without `views:read`, though
they can see zones in views. It now lists those views by name, colour
and visible zone count (marked `limited` in the API), without their
settings.
- The DNSSEC tab told you to give the DS record to your registrar for
every zone whose parent isn't in Jarl in the same view. Now it (and
`jarl zone dnssec status`, and the API's new `ds_at`, `ds_place` and
`ds_note`) says where it really goes: the parent zone in Jarl (in any
view now, the zone's own first), the registrar for a registered
domain, the parent zone wherever it is hosted for a subdomain, your
ISP or RIR for a reverse zone, and nowhere for a private name such as
`home.lan` (signing it has no effect).
- The Registrar tab (and `jarl zone registrar`, and the DNSSEC page's
registrar button) was offered for every zone when `porkbun.domains` is
empty, and always failed for subdomain zones (`lab.adbraun.lol`),
reverse zones and private names (`home.lan`): Porkbun only knows
registered domains. Only zones that are a registered domain (by the
public suffix list) get it now; the others say what delegates them
instead.
- Auto-PTR in linked zones changed PTR records in every linked view, not
just where the address record is. A record local to one view got PTR
records in the other views too; deleting or changing the address in
one view deleted or moved the PTR records of views that still had it.
PTR records now follow each view's own address records, are local to
their view in a linked reverse zone, and each zone's Reverse DNS
setting counts for its own view. **Fill** also adds local PTR records
now, not shared ones. The same applies to **Update PTR** (`--ptr`).
- Saving the settings of a zone whose primary name server is outside it
(`ns1.adbraun.lol.` for `wg.adbraun.lol`) changed the name server to
`ns1.adbraun.lol.wg.adbraun.lol.`. The form showed the name without its
final dot, which the server reads as relative to the zone. Names outside
the zone now keep the dot, and the name server is only sent when it was
changed.
### Upgrading
- Tested: a 0.6.0 install with views, linked and reverse zones and
statistics, opened by 0.6.1-rc.1: every zone, record and view kept,
named answered as before, and the new grant and limits worked.
- The database gains a migration (certificate owners, per-user
certificate limits, certificate downloads). Going back to an older
version afterwards means restoring a backup made before the upgrade.
## [0.6.0] - 2026-10-13
### Added
- Reverse zones made easy (docs/reverse.md): - Reverse zones made easy (docs/reverse.md):
- **Missing reverse zones**: the Zones page and `jarl zone - **Missing reverse zones**: the Zones page and `jarl zone
suggest-reverse` list the reverse zones the private views lack for suggest-reverse` list the reverse zones the private views lack for
@@ -48,11 +280,42 @@ All notable changes to Jarl are documented here. The format follows
manageable: the public addresses fold away, show 25 at a time, and manageable: the public addresses fold away, show 25 at a time, and
**Dismiss these N** dismisses everything a search found in one change **Dismiss these N** dismisses everything a search found in one change
(`--dismiss` takes several networks). (`--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]`, - Comparing one name across views: `jarl zone diff adbraun.lol nas [A]`,
and a **Compare** button on each record. 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 ### 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 - **Compare views** and `jarl zone diff` are rebuilt around the view
you're on (docs/views.md#comparing-views): a summary per view, then 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, what is answered differently (a view's own records in linked zones,
@@ -64,16 +327,25 @@ All notable changes to Jarl are documented here. The format follows
### Fixed ### 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 - Server options → Added by Jarl: long statements didn't wrap, and the
browser console reported a Content Security Policy violation (an inline browser console reported a Content Security Policy violation (an inline
style the page's policy blocks). style the page's policy blocks).
### Upgrading ### 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 - 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 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 jarld backup`). Nothing changes until you turn `auto_ptr` on for a
zone. 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 ## [0.5.2] - 2026-10-12
+8
View File
@@ -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.
+1
View File
@@ -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
+17 -1
View File
@@ -202,7 +202,23 @@ review step.
## Status ## Status
Version 0.5.2 is the current release. It adds Prometheus metrics, Version 0.6.3 is the current release; 0.6.4 is in testing (0.6.4-rc.2):
failed logins and refused tokens are logged, address first, for Guard
and fail2ban, and `jarl cert get` can write a combined file for HAProxy
(`--combined`) and apply settings without jarld (`--local`). 0.6.3 brings the lego docs up to
lego 5 and explains lego with split DNS. 0.6.2 added a `certificates` role
for tokens that get certificates for other hosts. 0.6.1 gets certificates onto your
other hosts (`jarl cert get`: Jarl obtains and renews them, a host downloads them and runs a
hook, a daily timer keeps them current), exports certificates, fixes
PTR records and overrides in linked zones, and makes one-view tokens
(such as ACME tokens for the external view) work with linked zones.
Reverse zones list addresses in numeric order and no longer report
another view's hosts as missing PTR records.
0.6.0 makes reverse zones easy (suggested from your records,
created in the right view and filled, PTR records kept in step), rebuilds
comparing a zone's views, lets you explore the statistics down to each
client and name (a query log), shows the databases' size, and gives
encrypted DNS several host names and views per listen address. 0.5 added Prometheus metrics,
address ranges in ACLs and other address match lists, safer changeset address ranges in ACLs and other address match lists, safer changeset
handling, internet exposure checks with response rate limiting, TLS handling, internet exposure checks with response rate limiting, TLS
certificates Jarl obtains and renews itself, linked zones for split DNS, certificates Jarl obtains and renews itself, linked zones for split DNS,
+1 -1
View File
@@ -1 +1 @@
0.5.2 0.6.4-rc.2
+12 -3
View File
@@ -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))
} }
@@ -389,7 +392,8 @@ ACCESS is read or write, and filters take comma separated glob patterns:
records:write zones=adbraun.lol names=_acme-challenge.* types=TXT records:write zones=adbraun.lol names=_acme-challenge.* types=TXT
*:read *:read
Roles are ready-made grant sets: readonly, dns-editor, operator, acme.` Roles are ready-made grant sets: readonly, dns-editor, operator, acme,
certificates.`
func userCmd() *cobra.Command { func userCmd() *cobra.Command {
cmd := &cobra.Command{Use: "user", Aliases: []string{"users"}, Short: "Manage users (admin)", Long: "Manage users (admin).\n\n" + grantHelp} cmd := &cobra.Command{Use: "user", Aliases: []string{"users"}, Short: "Manage users (admin)", Long: "Manage users (admin).\n\n" + grantHelp}
@@ -444,6 +448,7 @@ func userCmd() *cobra.Command {
} }
var admin, noPassword, disabled bool var 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,13 +471,17 @@ 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) {
c.Flags().BoolVar(&admin, "admin", false, "make the user an administrator (--admin=false to remove)") c.Flags().BoolVar(&admin, "admin", false, "make the user an administrator (--admin=false to remove)")
c.Flags().StringVar(&role, "role", "", "replace the grants with a role: readonly, dns-editor, operator or acme") c.Flags().StringVar(&role, "role", "", "replace the grants with a role: readonly, dns-editor, operator, acme or certificates")
c.Flags().StringArrayVar(&grants, "grant", nil, `grant (repeatable), e.g. "records:write zones=example.com"`) c.Flags().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",
@@ -750,7 +759,7 @@ func tokenCmd() *cobra.Command {
}, },
} }
create.Flags().StringVar(&user, "user", "", "create the token for this user (admin)") create.Flags().StringVar(&user, "user", "", "create the token for this user (admin)")
create.Flags().StringVar(&role, "role", "", "restrict the token to a role: readonly, dns-editor, operator or acme") create.Flags().StringVar(&role, "role", "", "restrict the token to a role: readonly, dns-editor, operator, acme or certificates")
create.Flags().StringArrayVar(&grants, "grant", nil, "restrict the token to these grants (repeatable)") create.Flags().StringArrayVar(&grants, "grant", nil, "restrict the token to these grants (repeatable)")
create.Flags().StringVar(&expires, "expires", "", "lifetime, e.g. 90d or 720h (default: never)") create.Flags().StringVar(&expires, "expires", "", "lifetime, e.g. 90d or 720h (default: never)")
create.Flags().BoolVar(&save, "save", false, "store the new token in the CLI configuration file") create.Flags().BoolVar(&save, "save", false, "store the new token in the CLI configuration file")
+972
View File
@@ -0,0 +1,972 @@
package main
// jarl cert get: certificates for other hosts (docs/certificates.md#certificates-for-other-hosts).
// jarld obtains and renews the certificate; this host asks for it, waits,
// downloads it when it is newer than its copy, writes the combined file
// (--combined) and runs a hook. The
// settings of each certificate are saved, so a daily timer (jarl cert
// schedule) can run 'jarl cert get' for all of them.
import (
"context"
"crypto/tls"
"encoding/json"
"encoding/pem"
"errors"
"fmt"
"io"
"net/url"
"os"
"os/exec"
"path/filepath"
"runtime"
"slices"
"strings"
"text/tabwriter"
"time"
"github.com/spf13/cobra"
"jarl/internal/client"
)
// savedCert is a certificate's settings, saved by jarl cert get.
type savedCert struct {
Name string `json:"name"`
Domains []string `json:"domains"`
KeyType string `json:"key_type,omitempty"`
Dir string `json:"dir"`
Hook string `json:"hook,omitempty"`
// Combined is a file with the key and the full chain together, for
// HAProxy (--combined); kept outside Dir.
Combined string `json:"combined,omitempty"`
Conn connection `json:"connection"`
SavedAt time.Time `json:"saved_at"`
}
// localMeta is what this host has of a certificate, kept next to its
// files as jarl-cert.json.
type localMeta struct {
Name string `json:"name"`
Domains []string `json:"domains"`
Serial string `json:"serial"`
NotAfter time.Time `json:"not_after"`
Fetched time.Time `json:"fetched"`
// Combined is the combined file written for these files, so a changed
// --combined removes the old one.
Combined string `json:"combined,omitempty"`
// HookFailed is set when the hook failed for these files: the next
// run runs it again.
HookFailed string `json:"hook_failed,omitempty"`
}
const metaFile = "jarl-cert.json"
// certConfigDir is where saved settings live: /etc/jarl/cert-get for
// root, ~/.config/jarl/cert-get otherwise.
func certConfigDir(flag string) string {
if flag != "" {
return flag
}
if os.Geteuid() == 0 {
return "/etc/jarl/cert-get"
}
if d, err := os.UserConfigDir(); err == nil {
return filepath.Join(d, "jarl", "cert-get")
}
return ".jarl-cert-get"
}
// defaultCertDir is where a certificate's files go without --dir.
func defaultCertDir(name string) string {
if os.Geteuid() == 0 {
return filepath.Join("/etc/jarl/certs", name)
}
return name
}
func loadSaved(dir string) ([]savedCert, error) {
entries, err := os.ReadDir(dir)
if errors.Is(err, os.ErrNotExist) {
return nil, nil
}
if err != nil {
return nil, err
}
var out []savedCert
for _, e := range entries {
if e.IsDir() || !strings.HasSuffix(e.Name(), ".json") {
continue
}
b, err := os.ReadFile(filepath.Join(dir, e.Name()))
if err != nil {
return nil, err
}
var sc savedCert
if err := json.Unmarshal(b, &sc); err != nil {
return nil, fmt.Errorf("%s: %w", filepath.Join(dir, e.Name()), err)
}
out = append(out, sc)
}
return out, nil
}
// save writes a certificate's settings, readable only by this user: they
// may hold the API token.
func (sc savedCert) save(dir string) error {
if err := os.MkdirAll(dir, 0o700); err != nil {
return err
}
b, _ := json.MarshalIndent(sc, "", " ")
return writeAtomic(filepath.Join(dir, sc.Name+".json"), append(b, '\n'), 0o600)
}
// checkCertClash refuses a certificate that would take over what this
// host keeps for another one: a name saved for another Jarl server
// (each server has its own names, so the server can't refuse it), or a
// directory with another certificate's files. saved[i] is sc's saved
// settings (i < 0: none).
func checkCertClash(saved []savedCert, i int, sc savedCert, replace bool) error {
if i >= 0 && !replace && !sameServer(saved[i].Conn.Server, sc.Conn.Server) {
return fmt.Errorf("%s is saved here for the Jarl server %s, not %s: choose another name, or take it over with --replace", sc.Name, saved[i].Conn.Server, sc.Conn.Server)
}
for j, o := range saved {
if j != i && o.Name != sc.Name && filepath.Clean(o.Dir) == filepath.Clean(sc.Dir) {
return fmt.Errorf("%s holds the files of %s, saved for %s: give %s another --dir", sc.Dir, o.Name, o.Conn.Server, sc.Name)
}
}
if err := checkCombined(saved, i, sc); err != nil {
return err
}
if m, err := readMeta(sc.Dir); err == nil && m.Name != "" && m.Name != sc.Name && !replace {
return fmt.Errorf("%s holds the files of the certificate %s: give %s another --dir, or overwrite them with --replace", sc.Dir, m.Name, sc.Name)
}
return nil
}
// checkCombined refuses a --combined path that is a directory, one of
// the files jarl cert get writes in a certificate's directory, or another
// saved certificate's combined file.
func checkCombined(saved []savedCert, i int, sc savedCert) error {
if sc.Combined == "" {
return nil
}
if st, err := os.Stat(sc.Combined); err == nil && st.IsDir() {
return fmt.Errorf("--combined %s is a directory: give the file's path, such as %s", sc.Combined, filepath.Join(sc.Combined, sc.Name+".pem"))
}
dir, base := filepath.Split(sc.Combined)
for j, o := range append(slices.Clone(saved), sc) {
if j == i {
continue // sc itself is last
}
if filepath.Clean(dir) == filepath.Clean(o.Dir) && (base == metaFile || slices.Contains(certFiles, base)) {
return fmt.Errorf("--combined %s would overwrite %s's %s: give another path", sc.Combined, o.Name, base)
}
if j < len(saved) && o.Name != sc.Name && o.Combined != "" && filepath.Clean(o.Combined) == filepath.Clean(sc.Combined) {
return fmt.Errorf("--combined %s is the combined file of %s: give %s another path", sc.Combined, o.Name, sc.Name)
}
}
return nil
}
// sameNames reports whether two lists hold the same names, in any order.
func sameNames(a, b []string) bool {
norm := func(l []string) []string {
out := make([]string, len(l))
for i, n := range l {
out[i] = strings.TrimSuffix(strings.ToLower(n), ".")
}
slices.Sort(out)
return slices.Compact(out)
}
return slices.Equal(norm(a), norm(b))
}
// sameServer reports whether two server addresses name the same jarld.
func sameServer(a, b string) bool {
norm := func(s string) string { return strings.TrimRight(strings.ToLower(s), "/") }
return norm(a) == norm(b)
}
func readMeta(dir string) (*localMeta, error) {
b, err := os.ReadFile(filepath.Join(dir, metaFile))
if err != nil {
return nil, err
}
var m localMeta
return &m, json.Unmarshal(b, &m)
}
func (m *localMeta) write(dir string) error {
b, _ := json.MarshalIndent(m, "", " ")
return writeAtomic(filepath.Join(dir, metaFile), append(b, '\n'), 0o644)
}
// writeAtomic replaces path with data, so a program never reads half a
// file.
func writeAtomic(path string, data []byte, mode os.FileMode) error {
tmp, err := os.CreateTemp(filepath.Dir(path), "."+filepath.Base(path)+".*")
if err != nil {
return err
}
defer os.Remove(tmp.Name())
if err := tmp.Chmod(mode); err != nil {
tmp.Close()
return err
}
if _, err := tmp.Write(data); err != nil {
tmp.Close()
return err
}
if err := tmp.Close(); err != nil {
return err
}
return os.Rename(tmp.Name(), path)
}
// requestAnswer is POST /certificates/{name}/request.
type requestAnswer struct {
Certificate certInfo `json:"certificate"`
Created bool `json:"created"`
Changed bool `json:"changed"`
Warnings []string `json:"warnings"`
}
// getResult is what one jarl cert get did.
type getResult struct {
Name string `json:"name"`
Dir string `json:"dir"`
Serial string `json:"serial"`
NotAfter time.Time `json:"not_after"`
Updated bool `json:"updated"` // new files written
// Combined is the combined file; CombinedWritten is set when it was
// written now.
Combined string `json:"combined,omitempty"`
CombinedWritten bool `json:"combined_written,omitempty"`
Hook string `json:"hook,omitempty"`
}
// fetchCert asks jarld for the certificate, waits while it is obtained,
// downloads it when it is newer than this host's copy (or force), and
// then applies it (applyCert).
func fetchCert(c *client.Client, sc savedCert, force bool, wait time.Duration) (*getResult, error) {
var ans requestAnswer
err := c.Do(ctx(), "POST", certPath(sc.Name)+"/request", nil, map[string]any{"domains": sc.Domains, "key_type": sc.KeyType}, &ans)
if err != nil {
return nil, err
}
for _, w := range ans.Warnings {
fmt.Fprintf(os.Stderr, "%s: %s\n", sc.Name, w)
}
ci := ans.Certificate
if ci.IssuingSince != nil {
tty := !flagQuiet && flagOutput == "table"
if tty {
what := "Obtaining"
if ans.Changed {
what = "Obtaining (its names changed)"
}
fmt.Fprintf(os.Stderr, "%s %s from the CA on jarld", what, sc.Name)
}
deadline := time.Now().Add(wait)
for ci.IssuingSince != nil && time.Now().Before(deadline) {
time.Sleep(2 * time.Second)
if tty {
fmt.Fprint(os.Stderr, ".")
}
ci = certInfo{}
if err := c.Get(ctx(), certPath(sc.Name), nil, &ci); err != nil {
return nil, err
}
}
if tty {
fmt.Fprintln(os.Stderr)
}
if ci.IssuingSince != nil {
return nil, fmt.Errorf("%s: jarld is still obtaining it; run jarl cert get again later", sc.Name)
}
}
if ci.NotAfter == nil {
msg := firstNonEmpty(ci.LastError, ci.Status.Message, "jarld has no certificate for it yet")
return nil, fmt.Errorf("%s: %s", sc.Name, msg)
}
if ci.LastError != "" && ci.LastAttempt != nil && (ci.LastSuccess == nil || ci.LastAttempt.After(*ci.LastSuccess)) {
fmt.Fprintf(os.Stderr, "%s: jarld's last attempt failed (%s); this is the certificate it has\n", sc.Name, ci.LastError)
}
meta, _ := readMeta(sc.Dir)
have := meta != nil && meta.Serial == ci.Serial && filesPresent(sc.Dir)
renewed := meta != nil && meta.Serial != "" && meta.Serial != ci.Serial
updated := !have || force
if updated {
if err := downloadCert(c, sc, ci); err != nil {
return nil, err
}
old := ""
if meta != nil {
old = meta.Combined
}
meta = &localMeta{Name: sc.Name, Domains: ci.Domains, Serial: ci.Serial, NotAfter: *ci.NotAfter, Fetched: time.Now().UTC(), Combined: old}
}
return applyCert(sc, meta, updated, renewed, force)
}
// localCert applies the files this host has, without asking jarld
// (--local): it writes the combined file and runs the hook.
func localCert(sc savedCert) (*getResult, error) {
meta, err := readMeta(sc.Dir)
if err != nil || !filesPresent(sc.Dir) {
return nil, fmt.Errorf("%s: this host has no files for it in %s yet: run jarl cert get %s without --local", sc.Name, sc.Dir, sc.Name)
}
if meta.Name != "" && meta.Name != sc.Name {
return nil, fmt.Errorf("%s holds the files of the certificate %s, not %s", sc.Dir, meta.Name, sc.Name)
}
return applyCert(sc, meta, false, false, true)
}
// applyCert brings what lies outside the certificate's directory in line
// with its files: it writes the combined file when the files are new, its
// path changed or it is missing (and removes one written for an older
// path), then runs the hook when anything changed, when it failed last
// time, or always.
func applyCert(sc savedCert, meta *localMeta, updated, renewed, always bool) (*getResult, error) {
res := &getResult{Name: sc.Name, Dir: sc.Dir, Serial: meta.Serial, NotAfter: meta.NotAfter, Updated: updated, Combined: sc.Combined}
changed := updated
if meta.Combined != "" && filepath.Clean(meta.Combined) != filepath.Clean(sc.Combined) {
if err := os.Remove(meta.Combined); err != nil && !errors.Is(err, os.ErrNotExist) {
return nil, fmt.Errorf("%s: removing the old combined file: %w", sc.Name, err)
}
meta.Combined = ""
changed = true
}
if sc.Combined != "" {
_, err := os.Stat(sc.Combined)
if updated || always || meta.Combined == "" || err != nil {
if err := writeCombined(sc); err != nil {
return nil, err
}
meta.Combined = sc.Combined
res.CombinedWritten = true
changed = true
}
}
if !changed && !always && meta.HookFailed == "" {
return res, nil
}
if sc.Hook != "" {
res.Hook = "ok"
if err := runHook(sc, meta, renewed); err != nil {
meta.HookFailed = err.Error()
res.Hook = "failed: " + err.Error()
} else {
meta.HookFailed = ""
}
}
if err := meta.write(sc.Dir); err != nil {
return nil, err
}
if meta.HookFailed != "" {
return res, fmt.Errorf("%s: the hook failed (%s); the next jarl cert get runs it again", sc.Name, meta.HookFailed)
}
return res, nil
}
var certFiles = []string{"cert.pem", "chain.pem", "fullchain.pem", "privkey.pem"}
// writeCombined writes the combined file from the certificate's files:
// the key, then the full chain (as jarl cert export --part bundle), mode
// 600, all at once.
func writeCombined(sc savedCert) error {
full, err := os.ReadFile(filepath.Join(sc.Dir, "fullchain.pem"))
if err != nil {
return err
}
key, err := os.ReadFile(filepath.Join(sc.Dir, "privkey.pem"))
if err != nil {
return err
}
if _, err := tls.X509KeyPair(full, key); err != nil {
return fmt.Errorf("%s: the files in %s don't match (%v): run jarl cert get %s --force", sc.Name, sc.Dir, err, sc.Name)
}
if err := os.MkdirAll(filepath.Dir(sc.Combined), 0o755); err != nil {
return err
}
data := append(append(append([]byte{}, key...), '\n'), full...)
if err := writeAtomic(sc.Combined, data, 0o600); err != nil {
return fmt.Errorf("%s: writing the combined file: %w", sc.Name, err)
}
return nil
}
func filesPresent(dir string) bool {
for _, f := range certFiles {
if _, err := os.Stat(filepath.Join(dir, f)); err != nil {
return false
}
}
return true
}
// downloadCert writes the certificate's files: cert.pem, chain.pem (the
// intermediates), fullchain.pem and privkey.pem (mode 600).
func downloadCert(c *client.Client, sc savedCert, ci certInfo) error {
get := func(part string) ([]byte, error) {
var out string
err := c.Get(ctx(), certPath(sc.Name)+"/pem", url.Values{"part": {part}}, &out)
return []byte(out), err
}
var full, key []byte
// Twice at most: a renewal between the two downloads gives a key
// that doesn't match.
for try := 0; ; try++ {
var err error
if full, err = get("fullchain"); err != nil {
return err
}
if key, err = get("key"); err != nil {
return err
}
if _, err = tls.X509KeyPair(full, key); err == nil {
break
} else if try == 1 {
return fmt.Errorf("%s: the key jarld sent doesn't match the certificate: %w", sc.Name, err)
}
}
block, rest := pem.Decode(full)
if block == nil {
return fmt.Errorf("%s: jarld sent no certificate", sc.Name)
}
if err := os.MkdirAll(sc.Dir, 0o755); err != nil {
return err
}
files := []struct {
name string
data []byte
mode os.FileMode
}{
{"cert.pem", pem.EncodeToMemory(block), 0o644},
{"chain.pem", []byte(strings.TrimLeft(string(rest), "\n")), 0o644},
{"fullchain.pem", full, 0o644},
{"privkey.pem", key, 0o600},
}
// The key first: a program that reads both never pairs a new
// certificate with the old key for long.
slices.Reverse(files)
for _, f := range files {
if err := writeAtomic(filepath.Join(sc.Dir, f.name), f.data, f.mode); err != nil {
return err
}
}
return nil
}
// runHook runs the certificate's hook with sh, its files in the
// environment.
func runHook(sc savedCert, meta *localMeta, renewed bool) error {
c, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
defer cancel()
cmd := exec.CommandContext(c, "sh", "-c", sc.Hook)
ren := "0"
if renewed {
ren = "1"
}
cmd.Env = append(os.Environ(),
"JARL_CERT_NAME="+sc.Name,
"JARL_CERT_DIR="+sc.Dir,
"JARL_CERT_CERT="+filepath.Join(sc.Dir, "cert.pem"),
"JARL_CERT_CHAIN="+filepath.Join(sc.Dir, "chain.pem"),
"JARL_CERT_FULLCHAIN="+filepath.Join(sc.Dir, "fullchain.pem"),
"JARL_CERT_KEY="+filepath.Join(sc.Dir, "privkey.pem"),
"JARL_CERT_COMBINED="+sc.Combined,
"JARL_CERT_DOMAINS="+strings.Join(meta.Domains, " "),
"JARL_CERT_SERIAL="+meta.Serial,
"JARL_CERT_NOT_AFTER="+meta.NotAfter.UTC().Format(time.RFC3339),
"JARL_CERT_RENEWED="+ren,
)
cmd.Stdout, cmd.Stderr = os.Stderr, os.Stderr
return cmd.Run()
}
func certGetCmd() *cobra.Command {
var domains []string
var dir, hook, keyType, combined, configDir string
var noSave, force, replace, local bool
var wait time.Duration
cmd := &cobra.Command{
Use: "get [NAME]",
Short: "Get a certificate for this host: jarld obtains it, this host downloads it",
Long: `Gets a certificate for this host (a mail server, a reverse proxy). jarld
obtains it from the CA and renews it, answering the challenges in its own
zones; this command asks for it, waits until it is there, downloads it
when it is newer than this host's copy, writes --combined and runs --hook.
The token needs a certificates grant for the names, and the certificate
belongs to the token's user: another user can't take the name. Asking
again with the same names returns the certificate jarld has; adding or
removing a name gets a new one.
Files in --dir (default /etc/jarl/certs/NAME as root, ./NAME otherwise):
cert.pem, chain.pem (the intermediates), fullchain.pem, privkey.pem (mode
600), and jarl-cert.json (serial, expiry, when it was fetched). Don't
point HAProxy's crt at --dir: it loads every file there.
--combined PATH also writes the key and the full chain in one file (mode
600), as HAProxy wants. Keep it outside --dir; a directory of combined
files can be HAProxy's crt directory. --combined '' stops writing it and
removes the file.
The settings (names, --dir, --combined, --hook, the server and token) are
saved in
/etc/jarl/cert-get/NAME.json (as root; ~/.config/jarl/cert-get otherwise,
readable only by you). To change one later, run 'jarl cert get NAME' with
just that option; the others stay. 'jarl cert get' without a name does every
saved certificate: run it daily (jarl cert schedule does that with a
systemd timer). Nothing is downloaded until jarld has renewed one.
A name saved here for another Jarl server isn't taken over, nor a --dir
holding another certificate's files, unless --replace is given; a --dir
another saved certificate uses is always refused.
--hook runs with sh after new files are written or the combined file
changes (and again on the next run if it failed), with JARL_CERT_NAME,
JARL_CERT_DIR, JARL_CERT_CERT, JARL_CERT_CHAIN, JARL_CERT_FULLCHAIN,
JARL_CERT_KEY, JARL_CERT_COMBINED (empty without --combined),
JARL_CERT_DOMAINS, JARL_CERT_SERIAL, JARL_CERT_NOT_AFTER and
JARL_CERT_RENEWED (1 when it replaced an older certificate).
--local doesn't ask jarld: it uses the files this host has, writes the
combined file and runs the hook. Use it after changing --combined or
--hook, or to test a hook.`,
Example: ` jarl cert get mail -d mail.adbraun.lol --hook 'systemctl reload postfix dovecot'
jarl cert get proxy -d adbraun.lol -d '*.adbraun.lol' \
--combined /etc/haproxy/certs/adbraun.lol.pem --hook 'systemctl reload haproxy'
jarl cert get proxy --hook 'systemctl reload haproxy' --local # change a setting, apply it now
jarl cert get # every saved certificate (what the timer runs)
jarl cert saved # what this host has, and when it expires`,
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
cdir := certConfigDir(configDir)
saved, err := loadSaved(cdir)
if err != nil {
return err
}
var todo []savedCert
f := cmd.Flags()
if local && force {
return errors.New("--local and --force don't go together: --local always writes the combined file and runs the hook; --force downloads again from jarld")
}
switch {
case len(args) == 0:
if len(domains) > 0 {
return errors.New("give the certificate a name: jarl cert get NAME -d DOMAIN")
}
if len(saved) == 0 {
return fmt.Errorf("no saved certificates in %s: get one first (jarl cert get NAME -d DOMAIN)", cdir)
}
for _, n := range []string{"dir", "hook", "key-type", "combined", "replace", "no-save"} {
if f.Changed(n) {
return fmt.Errorf("--%s changes one certificate: give its name (jarl cert get NAME --%s ...)", n, n)
}
}
todo = saved
default:
name := args[0]
i := slices.IndexFunc(saved, func(s savedCert) bool { return s.Name == name })
var sc savedCert
if i >= 0 {
sc = saved[i]
} else if len(domains) == 0 {
return fmt.Errorf("%s isn't saved here: give its names (-d DOMAIN)", name)
}
sc.Name = name
if len(domains) > 0 {
if local && i >= 0 && !sameNames(domains, sc.Domains) {
return fmt.Errorf("%s: new names need a new certificate from jarld: run jarl cert get %s without --local", name, name)
}
sc.Domains = domains
}
if f.Changed("dir") || sc.Dir == "" {
sc.Dir = firstNonEmpty(dir, defaultCertDir(name))
}
if f.Changed("hook") {
sc.Hook = hook
}
if f.Changed("key-type") {
sc.KeyType = keyType
}
if f.Changed("combined") {
sc.Combined = ""
if combined != "" {
abs, err := filepath.Abs(combined)
if err != nil {
return err
}
sc.Combined = abs
}
}
if abs, err := filepath.Abs(sc.Dir); err == nil {
sc.Dir = abs
}
// The server and token given now (flags, environment, the
// CLI configuration) replace the saved ones.
if i < 0 || f.Changed("server") || cmd.Root().PersistentFlags().Changed("server") || cmd.Root().PersistentFlags().Changed("token") || os.Getenv("JARL_TOKEN") != "" || os.Getenv("JARL_SERVER") != "" {
cn, err := resolveConnection()
if err != nil {
return err
}
sc.Conn = cn
}
if err := checkCertClash(saved, i, sc, replace); err != nil {
return err
}
todo = []savedCert{sc}
}
var results []*getResult
var failed []string
var lastErr error
for _, sc := range todo {
var r *getResult
var err error
if local {
r, err = localCert(sc)
} else {
var c *client.Client
if c, err = sc.Conn.open(); err == nil {
r, err = fetchCert(c, sc, force, wait)
}
}
if r != nil {
results = append(results, r)
}
if err != nil {
if len(todo) > 1 {
fmt.Fprintln(os.Stderr, "error:", err)
}
failed = append(failed, sc.Name)
lastErr = err
continue
}
if len(args) == 1 && !noSave {
sc.SavedAt = time.Now().UTC()
if err := sc.save(cdir); err != nil {
return fmt.Errorf("saving the settings: %w", err)
}
}
}
if err := output(results, func(w io.Writer) {
for _, r := range results {
what := "up to date"
switch {
case r.Updated:
what = "new files"
case local:
what = "this host's files"
case r.CombinedWritten:
what = "combined file written"
}
if r.Combined != "" {
what += ", combined " + r.Combined
}
fmt.Fprintf(w, "%s\t%s\t%s, expires %s\t%s\n", r.Name, r.Dir, what, r.NotAfter.Local().Format("2006-01-02"), hookText(r.Hook))
}
}); err != nil {
return err
}
switch {
case len(failed) == 1 && len(todo) == 1:
return lastErr // its own exit code (3 forbidden, 5 conflict, ...)
case len(failed) > 0:
return fmt.Errorf("failed: %s", strings.Join(failed, ", "))
}
return nil
},
}
f := cmd.Flags()
f.StringArrayVarP(&domains, "domain", "d", nil, "a name for the certificate (repeatable; a wildcard as '*.adbraun.lol')")
f.StringVar(&dir, "dir", "", "directory for the files (default /etc/jarl/certs/NAME as root, ./NAME otherwise)")
f.StringVar(&hook, "hook", "", "shell command to run after new files are written ('' removes it)")
f.StringVar(&combined, "combined", "", "also write the key and full chain in one file at this path, for HAProxy ('' stops it)")
f.BoolVar(&local, "local", false, "don't ask jarld: write the combined file and run the hook from this host's files")
f.StringVar(&keyType, "key-type", "", "key type of a new certificate: ec256 (default), ec384, rsa2048, rsa3072, rsa4096")
f.BoolVar(&force, "force", false, "download and run the hook even when this host has the current certificate")
f.BoolVar(&noSave, "no-save", false, "don't save the settings for later runs")
f.BoolVar(&replace, "replace", false, "take over a name saved here for another Jarl server, or a --dir holding another certificate's files")
f.DurationVar(&wait, "wait", 15*time.Minute, "how long to wait while jarld obtains it")
f.StringVar(&configDir, "config-dir", "", "where the settings are saved (default /etc/jarl/cert-get as root, ~/.config/jarl/cert-get otherwise)")
_ = cmd.RegisterFlagCompletionFunc("key-type", cobra.FixedCompletions([]string{"ec256", "ec384", "rsa2048", "rsa3072", "rsa4096"}, cobra.ShellCompDirectiveNoFileComp))
return cmd
}
func hookText(h string) string {
if h == "" {
return ""
}
return "hook " + h
}
func certSavedCmd() *cobra.Command {
var configDir string
cmd := &cobra.Command{
Use: "saved [NAME]",
Short: "The certificates this host gets with jarl cert get, and when they expire",
Long: `Lists the certificates saved on this host by jarl cert get. With a name,
shows all of that certificate's settings and what this host has of it.
The token is never shown.`,
Example: ` jarl cert saved
jarl cert saved proxy
jarl cert saved -o json`,
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
cdir := certConfigDir(configDir)
saved, err := loadSaved(cdir)
if err != nil {
return err
}
type row struct {
savedCert
Local *localMeta `json:"local,omitempty"`
}
var rows []row
for _, sc := range saved {
if len(args) == 1 && sc.Name != args[0] {
continue
}
sc.Conn.Token = "" // never print it
m, _ := readMeta(sc.Dir)
rows = append(rows, row{sc, m})
}
if len(args) == 1 {
if len(rows) == 0 {
return fmt.Errorf("%s isn't saved in %s (jarl cert saved lists what is)", args[0], cdir)
}
r := rows[0]
return output(r, func(w io.Writer) { printSaved(w, r.savedCert, r.Local) })
}
return output(rows, func(w io.Writer) {
if len(rows) == 0 {
fmt.Fprintf(w, "No saved certificates in %s.\n", cdir)
return
}
tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0)
fmt.Fprintln(tw, "NAME\tNAMES\tDIR\tCOMBINED\tEXPIRES\tFETCHED\tSERVER\tHOOK")
for _, r := range rows {
exp, fetched := "-", "-"
if r.Local != nil {
exp = r.Local.NotAfter.Local().Format("2006-01-02") + fmt.Sprintf(" (%dd)", int(time.Until(r.Local.NotAfter).Hours()/24))
fetched = r.Local.Fetched.Local().Format("2006-01-02 15:04")
if r.Local.HookFailed != "" {
fetched += " HOOK FAILED"
}
}
fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n", r.Name, strings.Join(r.Domains, ","), r.Dir, dash(r.Combined), exp, fetched, r.Conn.Server, firstNonEmpty(r.Hook, "-"))
}
tw.Flush()
})
},
}
cmd.Flags().StringVar(&configDir, "config-dir", "", "where the settings are saved")
return cmd
}
// printSaved shows one saved certificate: its settings, then this
// host's copy.
func printSaved(w io.Writer, sc savedCert, m *localMeta) {
fmt.Fprintf(w, "Name:\t%s\n", sc.Name)
fmt.Fprintf(w, "Names:\t%s\n", strings.Join(sc.Domains, " "))
fmt.Fprintf(w, "Key type:\t%s\n", firstNonEmpty(sc.KeyType, "ec256 (default)"))
fmt.Fprintf(w, "Dir:\t%s\n", sc.Dir)
fmt.Fprintf(w, "Combined:\t%s\n", dash(sc.Combined))
fmt.Fprintf(w, "Hook:\t%s\n", dash(sc.Hook))
fmt.Fprintf(w, "Server:\t%s\n", sc.Conn.Server)
if sc.Conn.CACert != "" {
fmt.Fprintf(w, "CA cert:\t%s\n", sc.Conn.CACert)
}
if sc.Conn.Insecure {
fmt.Fprintf(w, "TLS:\tnot verified (--insecure)\n")
}
if !sc.SavedAt.IsZero() {
fmt.Fprintf(w, "Saved:\t%s\n", fmtTime(&sc.SavedAt))
}
if m == nil {
fmt.Fprintf(w, "Files:\tnone yet (jarl cert get %s fetches them)\n", sc.Name)
return
}
fmt.Fprintf(w, "Serial:\t%s\n", m.Serial)
fmt.Fprintf(w, "Expires:\t%s (%dd)\n", fmtTime(&m.NotAfter), int(time.Until(m.NotAfter).Hours()/24))
fmt.Fprintf(w, "Fetched:\t%s\n", fmtTime(&m.Fetched))
if m.HookFailed != "" {
fmt.Fprintf(w, "Hook failed:\t%s (the next jarl cert get runs it again)\n", m.HookFailed)
}
}
func certForgetCmd() *cobra.Command {
var configDir string
var del, yes bool
cmd := &cobra.Command{
Use: "forget NAME",
Short: "Stop getting a certificate on this host (its files stay)",
Long: `Removes a certificate's saved settings, so 'jarl cert get' (and the timer)
no longer fetches it. Its files stay where they are. With --delete, the
certificate is deleted on jarld too, so it is no longer renewed.`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
cdir := certConfigDir(configDir)
path := filepath.Join(cdir, args[0]+".json")
saved, err := loadSaved(cdir)
if err != nil {
return err
}
i := slices.IndexFunc(saved, func(s savedCert) bool { return s.Name == args[0] })
if i < 0 {
return fmt.Errorf("%s isn't saved in %s", args[0], cdir)
}
if del {
if !confirm("Delete certificate "+args[0]+" on jarld too?", yes) {
return errors.New("aborted")
}
c, err := saved[i].Conn.open()
if err != nil {
return err
}
if err := c.Do(ctx(), "DELETE", certPath(args[0]), nil, nil, nil); err != nil {
return err
}
}
if err := os.Remove(path); err != nil {
return err
}
if !flagQuiet {
fmt.Printf("forgot %s (its files stay in %s)\n", args[0], saved[i].Dir)
}
return nil
},
}
cmd.Flags().StringVar(&configDir, "config-dir", "", "where the settings are saved")
cmd.Flags().BoolVar(&del, "delete", false, "delete the certificate on jarld too")
cmd.Flags().BoolVarP(&yes, "yes", "y", false, "don't ask")
return cmd
}
// Units written by jarl cert schedule.
const (
scheduleUnit = "jarl-cert-get"
unitDir = "/etc/systemd/system"
)
func scheduleUnits(bin, configDir string) (service, timer string) {
service = fmt.Sprintf(`# Written by 'jarl cert schedule'; 'jarl cert schedule --remove' removes it.
[Unit]
Description=Fetch certificates from Jarl (jarl cert get)
Wants=network-online.target
After=network-online.target
[Service]
Type=oneshot
ExecStart=%s cert get --config-dir %s
`, bin, configDir)
timer = `# Written by 'jarl cert schedule'; 'jarl cert schedule --remove' removes it.
[Unit]
Description=Fetch certificates from Jarl daily
[Timer]
OnCalendar=daily
RandomizedDelaySec=1h
Persistent=true
[Install]
WantedBy=timers.target
`
return service, timer
}
func certScheduleCmd() *cobra.Command {
var remove, noSystemctl bool
var dir string
cmd := &cobra.Command{
Use: "schedule",
Short: "Run jarl cert get daily with a systemd timer (Linux, as root)",
Long: `Writes /etc/systemd/system/jarl-cert-get.service and .timer, which run
'jarl cert get' once a day for every certificate saved in /etc/jarl/cert-get,
and enables the timer. jarld renews certificates well before they expire;
the daily run downloads a renewed one and runs its hook.
Linux with systemd only, as root. Elsewhere, run 'jarl cert get' daily
yourself (cron, launchd, Task Scheduler).`,
Example: ` sudo jarl cert schedule
systemctl list-timers jarl-cert-get.timer
sudo jarl cert schedule --remove`,
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
if runtime.GOOS != "linux" {
return fmt.Errorf("jarl cert schedule writes systemd units, so it only works on Linux; run 'jarl cert get' daily with this system's scheduler")
}
if dir == unitDir && os.Geteuid() != 0 {
return errors.New("jarl cert schedule writes to /etc/systemd/system: run it as root (sudo)")
}
service := filepath.Join(dir, scheduleUnit+".service")
timer := filepath.Join(dir, scheduleUnit+".timer")
systemctl := func(a ...string) error {
if noSystemctl {
return nil
}
out, err := exec.Command("systemctl", a...).CombinedOutput()
if err != nil {
return fmt.Errorf("systemctl %s: %v: %s", strings.Join(a, " "), err, strings.TrimSpace(string(out)))
}
return nil
}
if remove {
_ = systemctl("disable", "--now", scheduleUnit+".timer")
for _, f := range []string{timer, service} {
if err := os.Remove(f); err != nil && !errors.Is(err, os.ErrNotExist) {
return err
}
}
if err := systemctl("daemon-reload"); err != nil {
return err
}
if !flagQuiet {
fmt.Println("removed the timer; saved certificates stay in", certConfigDir(""))
}
return nil
}
bin, err := os.Executable()
if err != nil {
return err
}
if b, err := filepath.EvalSymlinks(bin); err == nil {
bin = b
}
cdir := certConfigDir("")
saved, err := loadSaved(cdir)
if err != nil {
return err
}
if len(saved) == 0 {
fmt.Fprintf(os.Stderr, "note: no certificates are saved in %s yet; get one with: jarl cert get NAME -d DOMAIN\n", cdir)
}
svc, tmr := scheduleUnits(bin, cdir)
if err := writeAtomic(service, []byte(svc), 0o644); err != nil {
return err
}
if err := writeAtomic(timer, []byte(tmr), 0o644); err != nil {
return err
}
if err := systemctl("daemon-reload"); err != nil {
return err
}
if err := systemctl("enable", "--now", scheduleUnit+".timer"); err != nil {
return err
}
if !flagQuiet {
fmt.Printf("wrote %s and %s; the timer runs jarl cert get daily (systemctl list-timers %s.timer)\n", service, timer, scheduleUnit)
}
return nil
},
}
cmd.Flags().BoolVar(&remove, "remove", false, "disable and remove the timer")
cmd.Flags().StringVar(&dir, "unit-dir", unitDir, "where to write the units")
cmd.Flags().BoolVar(&noSystemctl, "no-systemctl", false, "only write the units")
_ = cmd.Flags().MarkHidden("unit-dir")
_ = cmd.Flags().MarkHidden("no-systemctl")
return cmd
}
+203
View File
@@ -0,0 +1,203 @@
package main
import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"math/big"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
func TestCheckCertClash(t *testing.T) {
tmp := t.TempDir()
a := connection{Server: "https://dns-a.example:8053"}
b := connection{Server: "https://dns-b.example:8053"}
saved := []savedCert{
{Name: "proxy", Dir: filepath.Join(tmp, "proxy"), Conn: a},
{Name: "mail", Dir: filepath.Join(tmp, "mail"), Conn: a},
}
// mail's files are on disk; "other" has files but no saved settings.
if err := (&localMeta{Name: "mail"}).write(mkdir(t, saved[1].Dir)); err != nil {
t.Fatal(err)
}
if err := (&localMeta{Name: "other"}).write(mkdir(t, filepath.Join(tmp, "other"))); err != nil {
t.Fatal(err)
}
cases := []struct {
what string
i int
sc savedCert
replace bool
want string // "" = allowed
}{
{"same server again", 0, savedCert{Name: "proxy", Dir: saved[0].Dir, Conn: a}, false, ""},
{"same server, other spelling", 0, savedCert{Name: "proxy", Dir: saved[0].Dir, Conn: connection{Server: "HTTPS://dns-a.example:8053/"}}, false, ""},
{"other server", 0, savedCert{Name: "proxy", Dir: saved[0].Dir, Conn: b}, false, "saved here for the Jarl server"},
{"other server, replace", 0, savedCert{Name: "proxy", Dir: saved[0].Dir, Conn: b}, true, ""},
{"new name, saved cert's dir", -1, savedCert{Name: "web", Dir: saved[1].Dir, Conn: b}, false, "holds the files of mail"},
{"new name, saved cert's dir, replace", -1, savedCert{Name: "web", Dir: saved[1].Dir + "/", Conn: b}, true, "holds the files of mail"},
{"new name, unsaved cert's files", -1, savedCert{Name: "web", Dir: filepath.Join(tmp, "other"), Conn: b}, false, "certificate other"},
{"new name, unsaved cert's files, replace", -1, savedCert{Name: "web", Dir: filepath.Join(tmp, "other"), Conn: b}, true, ""},
{"new name, empty dir", -1, savedCert{Name: "web", Dir: filepath.Join(tmp, "web"), Conn: b}, false, ""},
}
for _, c := range cases {
err := checkCertClash(saved, c.i, c.sc, c.replace)
switch {
case c.want == "" && err != nil:
t.Errorf("%s: %v", c.what, err)
case c.want != "" && (err == nil || !strings.Contains(err.Error(), c.want)):
t.Errorf("%s: got %v, want %q", c.what, err, c.want)
}
}
}
func mkdir(t *testing.T, dir string) string {
t.Helper()
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
return dir
}
func TestCheckCombined(t *testing.T) {
tmp := t.TempDir()
saved := []savedCert{
{Name: "proxy", Dir: filepath.Join(tmp, "proxy"), Combined: filepath.Join(tmp, "haproxy", "proxy.pem")},
{Name: "mail", Dir: filepath.Join(tmp, "mail")},
}
mkdir(t, filepath.Join(tmp, "haproxy"))
cases := []struct {
what string
i int
sc savedCert
want string
}{
{"none", 1, savedCert{Name: "mail", Dir: saved[1].Dir}, ""},
{"own again", 0, saved[0], ""},
{"beside the others", 1, savedCert{Name: "mail", Dir: saved[1].Dir, Combined: filepath.Join(tmp, "haproxy", "mail.pem")}, ""},
{"inside its own dir", 1, savedCert{Name: "mail", Dir: saved[1].Dir, Combined: filepath.Join(saved[1].Dir, "combined.pem")}, ""},
{"another's combined", 1, savedCert{Name: "mail", Dir: saved[1].Dir, Combined: saved[0].Combined}, "combined file of proxy"},
{"own fullchain", 1, savedCert{Name: "mail", Dir: saved[1].Dir, Combined: filepath.Join(saved[1].Dir, "fullchain.pem")}, "overwrite mail's fullchain.pem"},
{"another's key", 1, savedCert{Name: "mail", Dir: saved[1].Dir, Combined: filepath.Join(saved[0].Dir, "privkey.pem")}, "overwrite proxy's privkey.pem"},
{"a directory", 1, savedCert{Name: "mail", Dir: saved[1].Dir, Combined: filepath.Join(tmp, "haproxy")}, "is a directory"},
}
for _, c := range cases {
err := checkCombined(saved, c.i, c.sc)
switch {
case c.want == "" && err != nil:
t.Errorf("%s: %v", c.what, err)
case c.want != "" && (err == nil || !strings.Contains(err.Error(), c.want)):
t.Errorf("%s: got %v, want %q", c.what, err, c.want)
}
}
}
// TestApplyCert: the combined file follows --combined, and the hook runs
// only when something changed (always with --local).
func TestApplyCert(t *testing.T) {
tmp := t.TempDir()
dir := mkdir(t, filepath.Join(tmp, "proxy"))
full, key := testKeyPair(t)
for name, data := range map[string][]byte{"cert.pem": full, "chain.pem": nil, "fullchain.pem": full, "privkey.pem": key} {
if err := os.WriteFile(filepath.Join(dir, name), data, 0o600); err != nil {
t.Fatal(err)
}
}
if err := (&localMeta{Name: "proxy", Serial: "1", NotAfter: time.Now().Add(time.Hour)}).write(dir); err != nil {
t.Fatal(err)
}
runs := filepath.Join(tmp, "runs")
sc := savedCert{Name: "proxy", Dir: dir, Hook: `echo "$JARL_CERT_COMBINED" >> ` + runs}
hookRuns := func() []string {
b, _ := os.ReadFile(runs)
return strings.Fields(string(b))
}
apply := func(local bool) *getResult {
t.Helper()
var r *getResult
var err error
if local {
r, err = localCert(sc)
} else {
meta, _ := readMeta(dir)
r, err = applyCert(sc, meta, false, false, false)
}
if err != nil {
t.Fatal(err)
}
return r
}
if r := apply(false); r.CombinedWritten || len(hookRuns()) != 0 {
t.Fatalf("nothing changed, but: %+v, hook ran %d times", r, len(hookRuns()))
}
first := filepath.Join(tmp, "haproxy", "proxy.pem")
sc.Combined = first
if r := apply(false); !r.CombinedWritten || len(hookRuns()) != 1 {
t.Fatalf("new --combined: %+v, hook ran %d times", r, len(hookRuns()))
}
b, err := os.ReadFile(first)
if err != nil {
t.Fatal(err)
}
if _, err := tls.X509KeyPair(b, b); err != nil || !strings.HasPrefix(string(b), string(key)) {
t.Errorf("combined file isn't the key, then the chain: %v", err)
}
if st, _ := os.Stat(first); st.Mode().Perm() != 0o600 {
t.Errorf("combined file mode %v, want 600", st.Mode().Perm())
}
if r := apply(false); r.CombinedWritten || len(hookRuns()) != 1 {
t.Fatalf("second run changed something: %+v, hook ran %d times", r, len(hookRuns()))
}
os.Remove(first)
if r := apply(false); !r.CombinedWritten || len(hookRuns()) != 2 {
t.Fatalf("missing combined file not rewritten: %+v", r)
}
if r := apply(true); !r.CombinedWritten || len(hookRuns()) != 3 {
t.Fatalf("--local: %+v, hook ran %d times", r, len(hookRuns()))
}
second := filepath.Join(tmp, "haproxy", "adbraun.lol.pem")
sc.Combined = second
apply(false)
if _, err := os.Stat(first); !os.IsNotExist(err) {
t.Errorf("old combined file %s not removed: %v", first, err)
}
if _, err := os.Stat(second); err != nil {
t.Errorf("new combined file: %v", err)
}
sc.Combined = ""
apply(false)
if _, err := os.Stat(second); !os.IsNotExist(err) {
t.Errorf("--combined '' left %s: %v", second, err)
}
// Five runs; the last had JARL_CERT_COMBINED empty, so echo wrote no field.
if got := hookRuns(); len(got) != 4 || got[3] != second {
t.Errorf("hook runs: %q", got)
}
}
func testKeyPair(t *testing.T) (certPEM, keyPEM []byte) {
t.Helper()
k, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatal(err)
}
tpl := &x509.Certificate{SerialNumber: big.NewInt(1), Subject: pkix.Name{CommonName: "proxy.example"}, NotBefore: time.Now(), NotAfter: time.Now().Add(time.Hour)}
der, err := x509.CreateCertificate(rand.Reader, tpl, tpl, &k.PublicKey, k)
if err != nil {
t.Fatal(err)
}
kd, err := x509.MarshalECPrivateKey(k)
if err != nil {
t.Fatal(err)
}
return pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}), pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: kd})
}
+135 -4
View File
@@ -45,10 +45,19 @@ 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"`
OwnerID int64 `json:"owner_id,omitempty"`
Owner string `json:"owner,omitempty"`
PulledBy []struct {
Who string `json:"who"`
Serial string `json:"serial"`
IP string `json:"ip"`
At time.Time `json:"at"`
} `json:"pulled_by,omitempty"`
} }
type certAccount struct { type certAccount struct {
@@ -98,9 +107,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
} }
@@ -122,7 +132,7 @@ func certListCmd() *cobra.Command {
return err return err
} }
return output(r.Certificates, func(w io.Writer) { return output(r.Certificates, func(w io.Writer) {
fmt.Fprintln(w, "NAME\tSOURCE\tUSES\tNAMES\tEXPIRES\tSTATUS") fmt.Fprintln(w, "NAME\tSOURCE\tUSES\tNAMES\tEXPIRES\tPULLED\tSTATUS")
for _, ci := range r.Certificates { for _, ci := range r.Certificates {
names := ci.Names names := ci.Names
if len(names) == 0 { if len(names) == 0 {
@@ -132,13 +142,32 @@ func certListCmd() *cobra.Command {
if ci.IssuingSince != nil { if ci.IssuingSince != nil {
state = "obtaining" state = "obtaining"
} }
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\n", ci.Name, ci.Source, dash(strings.Join(ci.Uses, ",")), strings.Join(names, " "), fmtDate(ci.NotAfter), state) uses := strings.Join(ci.Uses, ",")
if uses == "" && ci.OwnerID != 0 {
uses = "get" // for another host (jarl cert get)
}
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\t%s\n", ci.Name, ci.Source, dash(uses), strings.Join(names, " "), fmtDate(ci.NotAfter), lastPull(ci), state)
} }
}) })
}, },
} }
} }
// lastPull is when the certificate was last downloaded, marked "(old)"
// while someone who downloaded it still has the one before its renewal.
func lastPull(ci certInfo) string {
if len(ci.PulledBy) == 0 {
return "-"
}
s := fmtDate(&ci.PulledBy[0].At) // newest first
for _, p := range ci.PulledBy {
if p.Serial != ci.Serial {
return s + " (old)"
}
}
return s
}
func fmtDate(t *time.Time) string { func fmtDate(t *time.Time) string {
if t == nil { if t == nil {
return "-" return "-"
@@ -173,9 +202,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 +535,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
View File
@@ -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 {
+57 -6
View File
@@ -30,7 +30,12 @@ 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))
} }
}) })
}, },
@@ -586,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; }'
@@ -610,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 {
@@ -631,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()
@@ -645,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 {
@@ -658,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)
@@ -847,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
View File
@@ -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 {
+88 -32
View File
@@ -17,15 +17,17 @@ type encEndpoint struct {
} }
type encStatus struct { type encStatus struct {
Enabled bool `json:"enabled"` Enabled bool `json:"enabled"`
Certificate string `json:"certificate"` Certificate string `json:"certificate"`
Hostname string `json:"hostname"` Hostname string `json:"hostname"`
DoTHost string `json:"dot_host"` Hostnames []string `json:"hostnames"`
DoHURL string `json:"doh_url"` AddrViews map[string][]string `json:"address_views"`
DoT []encEndpoint `json:"dot"` DoTHost string `json:"dot_host"`
DoH []encEndpoint `json:"doh"` DoHURL string `json:"doh_url"`
Other bool `json:"other"` DoT []encEndpoint `json:"dot"`
Health *encHealth `json:"health"` DoH []encEndpoint `json:"doh"`
Other bool `json:"other"`
Health *encHealth `json:"health"`
Addresses []struct { Addresses []struct {
Address string `json:"address"` Address string `json:"address"`
Private bool `json:"private"` Private bool `json:"private"`
@@ -87,7 +89,14 @@ views.`,
if !s.Enabled { if !s.Enabled {
fmt.Fprintln(w, "Encrypted DNS:\toff (jarl encrypted-dns setup --hostname NAME --address ADDR)") fmt.Fprintln(w, "Encrypted DNS:\toff (jarl encrypted-dns setup --hostname NAME --address ADDR)")
} else { } else {
fmt.Fprintf(w, "Host names:\t%s\n", strings.Join(s.Hostnames, ", "))
fmt.Fprintf(w, "Certificate:\t%s\n", s.Certificate) 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 { if len(s.DoT) > 0 {
fmt.Fprintf(w, "DNS over TLS:\t%s\n", endpoints(s.DoT)) fmt.Fprintf(w, "DNS over TLS:\t%s\n", endpoints(s.DoT))
} }
@@ -119,12 +128,13 @@ views.`,
}) })
}, },
} }
var host, certName string var hostList []string
var certName string
var addrs, views []string var addrs, views []string
var noDoT, noDoH bool var noDoT, noDoH bool
var dohPort, dotPort int var dohPort, dotPort int
setup := &cobra.Command{ setup := &cobra.Command{
Use: "setup --hostname NAME --address ADDR...", Use: "setup --hostname NAME... --address ADDR[=VIEW,...]...",
Short: "Set up DNS over TLS and HTTPS (obtaining a certificate if needed)", 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 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: given, which should be this server's addresses on your own networks:
@@ -135,25 +145,57 @@ given, which should be this server's addresses on your own networks:
- named's tls statement and listen-on statements (and an explicit - named's tls statement and listen-on statements (and an explicit
listen-on { any; } when there was none, as the first listen-on would listen-on { any; } when there was none, as the first listen-on would
otherwise stop named listening on port 53 everywhere); otherwise stop named listening on port 53 everywhere);
- the host name's A and AAAA records, in the private views that have its - the host names' A and AAAA records, local to each view: per address,
zone (or --view), local to the view when the zone is linked. 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.
It is one change, staged or applied like any other. Running it again Several --hostname: the certificate must be for all of them. It is one
replaces the earlier setup.`, 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, Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient() c, err := newClient()
if err != nil { if err != nil {
return err return err
} }
host = strings.ToLower(strings.TrimSuffix(host, ".")) var hosts []string
if host == "" { 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)") return errors.New("give --hostname, the name clients connect to (e.g. dns.adbraun.lol)")
} }
host := hosts[0]
var s encStatus var s encStatus
if err := c.Get(ctx(), "/encrypted-dns", q("hostname", host), &s); err != nil { if err := c.Get(ctx(), "/encrypted-dns", q("hostname", strings.Join(hosts, ",")), &s); err != nil {
return err 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 { if len(addrs) == 0 {
var list []string var list []string
for _, a := range s.Addresses { for _, a := range s.Addresses {
@@ -163,20 +205,24 @@ replaces the earlier setup.`,
} }
// The certificate. // The certificate.
if certName == "" { if certName == "" {
certs:
for _, ci := range s.Certificates { for _, ci := range s.Certificates {
if coversName(append(ci.Names, ci.Domains...), host) { for _, h := range hosts {
certName = ci.Name if !coversName(append(ci.Names, ci.Domains...), h) {
break continue certs
}
} }
certName = ci.Name
break
} }
} }
if certName == "" { if certName == "" {
certName = strings.SplitN(host, ".", 2)[0] certName = strings.SplitN(host, ".", 2)[0]
fmt.Printf("Creating certificate %s for %s\n", certName, host) fmt.Printf("Creating certificate %s for %s\n", certName, strings.Join(hosts, ", "))
var r struct { var r struct {
Certificate certInfo `json:"certificate"` Certificate certInfo `json:"certificate"`
} }
if err := c.Do(ctx(), "POST", "/certificates", nil, map[string]any{"name": certName, "domains": []string{host}, "uses": []string{"dns"}}, &r); err != nil { if err := c.Do(ctx(), "POST", "/certificates", nil, map[string]any{"name": certName, "domains": hosts, "uses": []string{"dns"}}, &r); err != nil {
return err return err
} }
if err := waitIssue(c, certName); err != nil { if err := waitIssue(c, certName); err != nil {
@@ -204,7 +250,7 @@ replaces the earlier setup.`,
} }
} }
} }
body := map[string]any{"hostname": host, "certificate": certName, "addresses": addrs, "dot": !noDoT, "doh": !noDoH} body := map[string]any{"hostnames": hosts, "certificate": certName, "addresses": addrs, "dot": !noDoT, "doh": !noDoH}
if cmd.Flags().Changed("doh-port") { if cmd.Flags().Changed("doh-port") {
body["doh_port"] = dohPort body["doh_port"] = dohPort
} }
@@ -234,14 +280,24 @@ replaces the earlier setup.`,
if chk.Error != "" && !flagQuiet { if chk.Error != "" && !flagQuiet {
fmt.Println("Note:", chk.Error) fmt.Println("Note:", chk.Error)
} }
if len(views) == 0 { switch {
for _, v := range s.Views { case perAddress:
if !v.Public && v.HasZone { for _, a := range addrs {
views = append(views, v.Name) 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
} }
body["views"] = views
resp, err := c.Mutate(ctx(), "POST", "/encrypted-dns", nil, body) resp, err := c.Mutate(ctx(), "POST", "/encrypted-dns", nil, body)
if err != nil { if err != nil {
return err return err
@@ -250,14 +306,14 @@ replaces the earlier setup.`,
}, },
} }
f := setup.Flags() f := setup.Flags()
f.StringVar(&host, "hostname", "", "the name clients connect to (the certificate's)") 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.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 (repeat for several)") 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(&noDoT, "no-dot", false, "no DNS over TLS")
f.BoolVar(&noDoH, "no-doh", false, "no DNS over HTTPS") f.BoolVar(&noDoH, "no-doh", false, "no DNS over HTTPS")
f.IntVar(&dohPort, "doh-port", 443, "port for 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.IntVar(&dotPort, "dot-port", 853, "port for DNS over TLS (clients expect 853)")
f.StringArrayVar(&views, "view", nil, "views to add the host name's records to (default: the private views that have its zone)") 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 cAddrs []string
var cDoH int var cDoH int
+36 -9
View File
@@ -103,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
@@ -125,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 {
@@ -141,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
} }
@@ -167,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
} }
+17
View File
@@ -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)
}
}
}
+25
View File
@@ -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())
}
}
+73 -7
View File
@@ -8,6 +8,7 @@ import (
"net/netip" "net/netip"
"net/url" "net/url"
"os" "os"
"slices"
"strconv" "strconv"
"strings" "strings"
"unicode/utf8" "unicode/utf8"
@@ -100,6 +101,16 @@ func zoneCmd() *cobra.Command {
fmt.Fprintf(w, "Network:\t%s\n", z.Prefix) fmt.Fprintf(w, "Network:\t%s\n", z.Prefix)
if z.PTRMissing > 0 { 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]) 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 {
@@ -119,7 +130,7 @@ 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 bool var fill, fillOther bool
create := &cobra.Command{ create := &cobra.Command{
Use: "create NAME", Use: "create NAME",
Short: "Create a zone", Short: "Create a zone",
@@ -128,7 +139,8 @@ 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). Without --view, a reverse zone goes to the private (created together). Without --view, a reverse zone goes to the private
view whose records use its addresses; --fill adds a PTR record for each view whose records use its addresses; --fill adds a PTR record for each
of them (docs/reverse.md). 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
@@ -169,9 +181,12 @@ of them (docs/reverse.md).
if linkWith != "" { if linkWith != "" {
body["link_with"] = linkWith body["link_with"] = linkWith
} }
if fill { if fill || fillOther {
body["fill"] = true 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)
@@ -245,6 +260,7 @@ of them (docs/reverse.md).
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(&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
@@ -1166,17 +1182,61 @@ func terminalWidth() int {
return width 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 { func zoneFillPTRCmd() *cobra.Command {
var view string var view string
var otherViews bool
cmd := &cobra.Command{ cmd := &cobra.Command{
Use: "fill-ptr ZONE [--view VIEW]", 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", 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 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 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 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).`, 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 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.in-addr.arpa --view internal
jarl zone fill-ptr 10.0.10.0/24 --view vps --other-views`,
Args: cobra.ExactArgs(1), Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient() c, err := newClient()
@@ -1184,6 +1244,9 @@ than the domain's, else the shortest (docs/reverse.md).`,
return err return err
} }
path, qv := zonePath(args[0], view) path, qv := zonePath(args[0], view)
if otherViews {
qv.Set("other_views", "1")
}
resp, err := c.Mutate(ctx(), "POST", path+"/fill-ptr", qv, nil) resp, err := c.Mutate(ctx(), "POST", path+"/fill-ptr", qv, nil)
if err != nil { if err != nil {
return err return err
@@ -1192,6 +1255,7 @@ than the domain's, else the shortest (docs/reverse.md).`,
}, },
} }
cmd.Flags().StringVar(&view, "view", "", "view of the zone") 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 return cmd
} }
@@ -1231,7 +1295,9 @@ again). A view can stop suggestions: jarl view update V
--all lists every network the views' records use without a reverse zone, --all lists every network the views' records use without a reverse zone,
also where nothing is suggested unasked (a view with suggestions off, a also where nothing is suggested unasked (a view with suggestions off, a
dismissed network, a public view), to create one by hand; --view narrows dismissed network, a public view), to create one by hand; --view narrows
the list to one view. Change the size as you need: a /48 suggested for 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.`, IPv6 can be created as a /56 or /64 instead.`,
Example: ` jarl zone suggest-reverse Example: ` jarl zone suggest-reverse
jarl zone suggest-reverse --create jarl zone suggest-reverse --create
+3 -1
View File
@@ -151,7 +151,8 @@ 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),
Anonymize: sc.AnonymizeClients, MainConfig: cfg.BIND.MainConfig} RetainQueries: time.Duration(sc.Retention.Queries),
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 {
o.Exclude = append(o.Exclude, pf.Masked()) o.Exclude = append(o.Exclude, pf.Masked())
@@ -317,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 != "" {
+6 -1
View File
@@ -7,12 +7,17 @@
# #
# Install it, then: # Install it, then:
# EXEC_PATH=/usr/local/bin/jarl-lego JARL_TOKEN=@/etc/jarl/lego.token \ # EXEC_PATH=/usr/local/bin/jarl-lego JARL_TOKEN=@/etc/jarl/lego.token \
# lego --email you@example.com --dns exec -d example.com -d '*.example.com' run # lego run --accept-tos --email you@example.com --dns exec -d example.com -d '*.example.com'
# (lego 4: lego --accept-tos --email ... --dns exec -d ... run, and "renew" to renew)
# #
# Options (environment): # Options (environment):
# JARL_SERVER Jarl server URL, e.g. https://ns1.example.com:8053 (optional if the jarl CLI is configured) # JARL_SERVER Jarl server URL, e.g. https://ns1.example.com:8053 (optional if the jarl CLI is configured)
# JARL_TOKEN API token, or @FILE (optional if the jarl CLI is configured) # JARL_TOKEN API token, or @FILE (optional if the jarl CLI is configured)
# JARL_VIEW Only write into this view (optional; default every view that has the zone) # JARL_VIEW Only write into this view (optional; default every view that has the zone)
# On a host inside a split DNS, lego's own propagation check then can't
# see the record: leave it unset, or run lego with
# --dns.propagation.disable-ans (lego 5; --dns.propagation-disable-ans in lego 4)
# (docs/acme.md, "Split DNS and lego's propagation check").
# JARL_BIN Path of the jarl binary (optional; default "jarl") # JARL_BIN Path of the jarl binary (optional; default "jarl")
JARL_BIN="${JARL_BIN:-jarl}" JARL_BIN="${JARL_BIN:-jarl}"
+7
View File
@@ -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.
+2
View File
@@ -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
+90 -6
View File
@@ -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.
@@ -96,10 +98,23 @@ sudo install -m 0755 contrib/lego/jarl-lego.sh /usr/local/bin/jarl-lego # or f
export EXEC_PATH=/usr/local/bin/jarl-lego export EXEC_PATH=/usr/local/bin/jarl-lego
export JARL_SERVER=https://ns1.adbraun.lol:8053 export JARL_SERVER=https://ns1.adbraun.lol:8053
export JARL_TOKEN=@/etc/jarl/certbot.token export JARL_TOKEN=@/etc/jarl/certbot.token
lego --email you@adbraun.lol --dns exec -d adbraun.lol -d '*.adbraun.lol' run lego run --accept-tos --email you@adbraun.lol --dns exec -d adbraun.lol -d '*.adbraun.lol'
``` ```
Renew with the same variables and `lego ... renew` in place of `run`. That is lego 5. `lego run` also renews: run the same command daily (a
cron job or systemd timer), and it renews once a third of the
certificate's lifetime is left. **lego 4** takes its options before the
command, and renews with `renew`:
```sh
lego --accept-tos --email you@adbraun.lol --dns exec -d adbraun.lol -d '*.adbraun.lol' run # first time
lego --email you@adbraun.lol --dns exec -d adbraun.lol -d '*.adbraun.lol' renew # then daily
```
Upgrading from lego 4 to 5, run `lego migrate` once (with `--path` if you
keep lego's data somewhere other than `./.lego`) before anything else:
lego 5 stores accounts and certificates differently. The exec and httpreq
providers, and so Jarl's side, are the same in both.
* lego runs `jarl-lego present FQDN VALUE` and `jarl-lego cleanup FQDN VALUE`, * lego runs `jarl-lego present FQDN VALUE` and `jarl-lego cleanup FQDN VALUE`,
and the script passes them to `jarl acme set|unset --exact`. FQDN is the and the script passes them to `jarl acme set|unset --exact`. FQDN is the
@@ -112,7 +127,66 @@ Renew with the same variables and `lego ... renew` in place of `run`.
* Leave `EXEC_MODE` unset: the script needs lego's default mode, not `RAW`. * Leave `EXEC_MODE` unset: the script needs lego's default mode, not `RAW`.
* lego waits until the record is visible on the zone's name servers * lego waits until the record is visible on the zone's name servers
(`EXEC_PROPAGATION_TIMEOUT`, 60 seconds by default). Jarl answers only (`EXEC_PROPAGATION_TIMEOUT`, 60 seconds by default). Jarl answers only
once named serves the record, so this check passes on its first try. once named serves the record, so this check passes on its first try,
as long as the view that answers lego's host has the record: with
`JARL_VIEW` on a host inside your network it doesn't. See
[Split DNS and lego's propagation check](#split-dns-and-legos-propagation-check).
### Split DNS and lego's propagation check
Before lego asks the CA to look, it checks the record itself, asking the
zone's name servers (`ns1.adbraun.lol`) directly. With views, they answer
lego's host from the view that matches *it*. On a host inside your
network that is the internal view. If the challenge was written into
another view only (`JARL_VIEW=external`, `--view external`, a token
limited to `views=external`, or the httpreq endpoint
`.../httpreq/view/external`), lego never sees it, and gives up after
its propagation timeout without the CA having looked:
```
propagation: time limit exceeded: last error: authoritative nameservers:
NS ns1.adbraun.lol.:53 returned NXDOMAIN for _acme-challenge.stats.adbraun.lol.
```
A name whose authorization is still valid from an earlier run ("authorization
already valid; skipping challenge") isn't checked at all, so one name can
work and the next fail with the same settings.
The ways round it, best first:
1. **Don't limit the view.** Leave `JARL_VIEW` unset (and give the token
no `views=` filter, the httpreq endpoint no `/view/...`): the record
goes into every view that has the zone, the internal one too, and
lego's check passes at once. The challenge is a random value that is
useless once the CA has checked it, and it is removed straight after.
2. **Skip lego's check of the name servers.** The record is already
served when Jarl answers, so lego's check adds nothing:
```sh
lego run --dns exec --dns.propagation.disable-ans ... # lego 5
lego --dns exec --dns.propagation-disable-ans ... run # lego 4.20 to 4.x
lego --dns exec --dns.disable-cp ... run # older lego 4
```
or replace the checks with a fixed wait, which also gives secondary
name servers time to catch up: `--dns.propagation.wait 30s` (lego 5;
`--dns.propagation-wait 30s` in lego 4.20 and later). In lego 5 these
can also be set in the environment: `LEGO_DNS_PROPAGATION_DISABLE_ANS=true`,
`LEGO_DNS_PROPAGATION_WAIT=30s`.
3. **In Traefik**, the same under `dnsChallenge` (Traefik 3.2 and later;
older versions have `disablePropagationCheck: true`):
```yaml
dnsChallenge:
provider: httpreq
propagation:
disableANSChecks: true # or delayBeforeChecks: 30s
```
4. **Run lego where the external view answers it**, such as a host outside
your network. Rarely the easy option.
certbot (section 3) doesn't check by itself, and acme.sh (section 4)
checks through public DNS-over-HTTPS resolvers, so neither runs into this.
## 6. lego and Traefik over HTTP (httpreq), without the jarl CLI ## 6. lego and Traefik over HTTP (httpreq), without the jarl CLI
@@ -297,14 +371,20 @@ of the `acme` role.
export HTTPREQ_ENDPOINT=https://ns1.adbraun.lol:8053/api/v1/acme/httpreq export HTTPREQ_ENDPOINT=https://ns1.adbraun.lol:8053/api/v1/acme/httpreq
export HTTPREQ_USERNAME=lego export HTTPREQ_USERNAME=lego
export HTTPREQ_PASSWORD_FILE=/etc/jarl/lego.token # or HTTPREQ_PASSWORD=jarl_... export HTTPREQ_PASSWORD_FILE=/etc/jarl/lego.token # or HTTPREQ_PASSWORD=jarl_...
lego --email you@adbraun.lol --dns httpreq -d adbraun.lol -d '*.adbraun.lol' run lego run --accept-tos --email you@adbraun.lol --dns httpreq -d adbraun.lol -d '*.adbraun.lol'
``` ```
That is lego 5; in lego 4 the options go before the command (`lego
--email ... --dns httpreq -d ... run`, and `renew` to renew). See
[section 5](#5-lego).
Leave `HTTPREQ_MODE` unset (its `RAW` mode sends a different body, which Leave `HTTPREQ_MODE` unset (its `RAW` mode sends a different body, which
jarld refuses with a message saying so). If jarld's certificate doesn't come from a public jarld refuses with a message saying so). If jarld's certificate doesn't come from a public
CA, lego has to trust yours: `SSL_CERT_FILE=/path/to/your-ca.pem` (lego's CA, lego has to trust yours: `SSL_CERT_FILE=/path/to/your-ca.pem` (lego's
`LEGO_CA_CERTIFICATES` covers only the ACME server). To write into one view only, put `LEGO_CA_CERTIFICATES` covers only the ACME server). To write into one view only, put
it in the endpoint: `.../api/v1/acme/httpreq/view/external`. it in the endpoint: `.../api/v1/acme/httpreq/view/external`; on a host inside
your network, read [Split DNS and lego's propagation check](#split-dns-and-legos-propagation-check)
first.
### 4. Traefik ### 4. Traefik
@@ -410,4 +490,8 @@ the main zone is never modified by automation.
`dig @ns1.adbraun.lol _acme-challenge.adbraun.lol TXT`. With views, make `dig @ns1.adbraun.lol _acme-challenge.adbraun.lol TXT`. With views, make
sure the record is in the view that matches the CA (it is by default) sure the record is in the view that matches the CA (it is by default)
and that `allow-query` lets the internet query the zone. and that `allow-query` lets the internet query the zone.
* lego says `propagation: time limit exceeded ... returned NXDOMAIN for
_acme-challenge...`: the record is in a view that doesn't answer lego's
host, usually `JARL_VIEW=external` on a host inside your network. See
[Split DNS and lego's propagation check](#split-dns-and-legos-propagation-check).
* `jarl audit --user certbot` shows every attempt. * `jarl audit --user certbot` shows every attempt.
+33 -11
View File
@@ -191,6 +191,7 @@ ZoneInput:
{ {
"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": 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
@@ -212,8 +213,12 @@ ZoneInput:
A reverse zone created without a view goes to the private view whose A reverse zone created without a view goes to the private view whose
records use its addresses. Zones come back with `prefix` (reverse zones: records use its addresses. Zones come back with `prefix` (reverse zones:
the network) and `ptr_missing` (addresses in it that the view's A and AAAA the network), `ptr_missing` (addresses in it that the view's A and AAAA
records use, without a PTR record). 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)).
@@ -294,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)).
@@ -354,13 +368,16 @@ 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 ([reverse.md](reverse.md)) | | 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` | | 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 | | 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 | | 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 |
@@ -411,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
@@ -510,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}` |
@@ -560,12 +579,13 @@ Needs `stats:read`. When statistics are disabled, every endpoint but
| 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
@@ -583,12 +603,14 @@ Administrators only. See [certificates.md](certificates.md#api).
| Method | Path | Body | | Method | Path | Body |
|--------|------|------| |--------|------|------|
| GET | `/certificates` | `certificates` (with `status`, `next_attempt`, `issuing_since`, `serving`, `files`) and `account` | | GET | `/certificates` | `certificates` (with `status`, `next_attempt`, `issuing_since`, `serving`, `files`, and for one obtained with `jarl cert get` `owner_id`, `owner` and `pulled_by`: every download for an administrator, the caller's own for the owner) and `account` |
| POST | `/certificates` | `{"name", "domains", "uses", "challenge", "key_type", "comment"}`, or `{"name", "source": "file", "cert_file", "key_file", "uses"}`; `"issue": false` doesn't start obtaining it | | POST | `/certificates` | `{"name", "domains", "uses", "challenge", "key_type", "comment"}`, or `{"name", "source": "file", "cert_file", "key_file", "uses"}`; `"issue": false` doesn't start obtaining it |
| POST | `/certificates/check` | `{"domains", "challenge"}`: the route of each challenge | | POST | `/certificates/check` | `{"domains", "challenge"}`: the route of each challenge |
| 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`,
@@ -615,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 |
+161 -1
View File
@@ -192,6 +192,10 @@ jarl option set tls 'dot { cert-file "/etc/bind/jarl/tls/ns1/fullchain.pem"; key
jarl option set listen-on 'port 853 tls dot { 10.0.10.5; }' --add jarl option set listen-on 'port 853 tls dot { 10.0.10.5; }' --add
``` ```
The Certificates page (and `jarl cert show`) says what named uses each
for: `DNS over TLS on 10.0.10.5, fd00::5 port 853`, `DNS over HTTPS on
… port 443`, or a hand-written `tls` statement no listener uses.
A certificate named uses can't be deleted or lose its `dns` use until 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 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 writes the certificates to the new place when it starts and points the
@@ -212,11 +216,166 @@ 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
jarl cert saved mail # all of mail's settings
```
* **Asking again** with the same names returns the certificate jarld has:
nothing is obtained, nothing is downloaded until jarld renews it.
**Adding or removing a name** gets a new certificate. A different
`--key-type` alone doesn't; delete the certificate for that.
* **Files**, in `--dir` (default `/etc/jarl/certs/NAME` as root, `./NAME`
otherwise): `cert.pem`, `chain.pem` (the intermediates),
`fullchain.pem`, `privkey.pem` (mode 600) and `jarl-cert.json` (serial,
expiry, when it was fetched). Each file is replaced at once, never
half written. The key is checked against the certificate before
anything is written.
* **A combined file** (`--combined PATH`): the key, then the full chain,
in one file (mode 600), as HAProxy wants. See [HAProxy](#haproxy).
* **Saved settings**: the names, `--dir`, `--combined`, `--hook`, the
server and the token are saved in `/etc/jarl/cert-get/NAME.json` (as
root; `~/.config/jarl/cert-get` otherwise), readable only by their
owner. `jarl cert get` without a name does every saved certificate. A
token given as `@FILE` is saved as that reference, not copied. `jarl
cert saved NAME` shows them all (the token never). `jarl cert forget
NAME` stops getting one (`--delete` deletes it on jarld too).
* **Changing a setting**: run `jarl cert get NAME` with just that option
(`--hook '...'`, `--combined PATH`, `--dir DIR`); the others stay.
`--hook ''` removes the hook, `--combined ''` stops writing the
combined file and deletes it. The run still asks jarld for the
certificate, but downloads nothing while this host has the current one.
A new or moved combined file is written from the files this host
already has, and the hook runs.
* **`--local`** doesn't ask jarld at all: it writes the combined file and
runs the hook from the files this host has. Use it to apply a changed
setting while jarld is unreachable, or to test a hook. New names still
need jarld.
* **Several Jarl servers on one host**: each server has its own
certificate names, so two servers may both have a `proxy`. The host
keeps one `proxy` per settings directory, though: asking another server
for a name saved here for the first one is refused, as is a `--dir`
holding another certificate's files. Choose another name or `--dir`, or
pass `--replace` to take the name (or directory) over. A `--dir` that
another saved certificate uses is always refused.
* **The hook** runs with `sh` after new files are written or the
combined file changes (and with `--local`), with `JARL_CERT_NAME`,
`JARL_CERT_DIR`, `JARL_CERT_CERT`, `JARL_CERT_CHAIN`,
`JARL_CERT_FULLCHAIN`, `JARL_CERT_KEY`, `JARL_CERT_COMBINED` (empty
without `--combined`), `JARL_CERT_DOMAINS`, `JARL_CERT_SERIAL`,
`JARL_CERT_NOT_AFTER` and `JARL_CERT_RENEWED` (1 when it replaced an
older certificate). If it fails, `jarl cert get` exits non-zero and runs
it again next time. It has 5 minutes.
* **`jarl cert schedule`** (Linux with systemd, as root) writes
`/etc/systemd/system/jarl-cert-get.service` and `.timer`, which run
`jarl cert get` daily at a random time within the hour after midnight,
and catch up after the host was off. `--remove` takes them away. On
other systems, run `jarl cert get` daily with their scheduler.
**On the Certificates page**, the administrators see each certificate's
owner and who downloaded it: the token, from where, when, and whether
that host has the current certificate or hasn't fetched the renewed one
yet. `jarl cert show NAME` says the same. The owner's own tokens see
their own downloads there too. In `jarl cert list`, such a certificate's
USES is `get`, and PULLED is the last download, with `(old)` while a host
hasn't fetched the renewed one.
### HAProxy
HAProxy wants the key and the chain in one file. Give `jarl cert get` a
`--combined` path outside `--dir`:
```sh
jarl cert get proxy -d adbraun.lol -d '*.adbraun.lol' \
--combined /etc/haproxy/certs/adbraun.lol.pem --hook 'systemctl reload haproxy'
jarl cert get webmail -d webmail.adbraun.lol \
--combined /etc/haproxy/certs/webmail.pem --hook 'systemctl reload haproxy'
```
```
frontend https
bind :443 ssl crt /etc/haproxy/certs/
```
HAProxy loads every file in a `crt` directory and picks one by SNI, so
`/etc/haproxy/certs` should hold only combined files. Don't point `crt`
at `--dir` (`/etc/jarl/certs/NAME`): HAProxy would try to load
`cert.pem`, `privkey.pem` and `jarl-cert.json` there too, and refuse
to start. A `--combined` path that would overwrite a file in a saved
certificate's directory, or another certificate's combined file, is
refused.
The combined file is mode 600, owned by the user running `jarl cert get`
(root under the timer). HAProxy reads its certificates as root before it
drops privileges, so that's fine.
To add a combined file to a certificate this host already has, run
`jarl cert get proxy --combined /etc/haproxy/certs/adbraun.lol.pem`: it's
written from the files on disk and the hook runs, with nothing
downloaded.
## Security notes ## 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
@@ -236,6 +395,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
+25 -1
View File
@@ -179,6 +179,7 @@ jarl zone create 10.0.8.0/22 # four zones, 8-11.0.10.i
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 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 # 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 [--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 --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 zone suggest-reverse --all [--view V] # every network without a reverse zone, also the hidden ones
@@ -318,7 +319,8 @@ host name (a bare `nas` is refused, since it would mean
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); `jarl record update ID --ptr` with nothing else adds the PTR record 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 if it is missing, e.g. after creating the reverse zone. In linked zones, the
reverse zones of every linked view count. Every record type BIND supports can be used. Jarl knows 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.
@@ -445,6 +447,17 @@ 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 get proxy -s other --replace # take over a name saved here for another server
jarl cert get proxy --combined /etc/haproxy/certs/proxy.pem # also key + chain in one file (HAProxy); '' stops it
jarl cert get proxy --hook 'systemctl reload haproxy' --local # change a setting; apply it from this host's files, without jarld
jarl cert saved # this host's certificates and their expiry
jarl cert saved proxy # all of one certificate's settings (never the token)
jarl cert forget mail [--delete] # stop getting it here
sudo jarl cert schedule [--remove] # daily systemd timer for jarl cert get
jarl cert delete ns1 jarl cert delete ns1
jarl cert account # the ACME account (set in jarl.yaml) jarl cert account # the ACME account (set in jarl.yaml)
``` ```
@@ -482,12 +495,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 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 +540,7 @@ jarl token list --all --expiring [60d] # expiring within 14 days
jarl token create laptop --expires 365d jarl token create laptop --expires 365d
jarl token create backup-script --grant "*:read" jarl token create backup-script --grant "*:read"
jarl token create acme --user certbot --role acme jarl token create acme --user certbot --role acme
jarl token create web01 --grant "certificates:write names=web01.adbraun.lol" # jarl cert get on one host (or --role certificates)
jarl token rotate laptop --save # new secret, same token and grants jarl token rotate laptop --save # new secret, same token and grants
jarl token rotate --current --save --grace 10m # the token this CLI uses jarl token rotate --current --save --grace 10m # the token this CLI uses
jarl token revoke 7 jarl token revoke 7
+2 -1
View File
@@ -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
+6
View File
@@ -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
+23
View File
@@ -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`:
| | | | | |
+97 -17
View File
@@ -14,10 +14,15 @@ host name's address records in your private views.
In the web UI: **Encrypted DNS** (administrators). In the CLI: `jarl In the web UI: **Encrypted DNS** (administrators). In the CLI: `jarl
encrypted-dns`. In the API: `/api/v1/encrypted-dns`. 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 ## Setting it up
```sh ```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 --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: This:
@@ -47,25 +52,37 @@ This:
`listen-on` statement at all. The first one, for DoT, would end that, so `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 when you have none the setup adds `listen-on { any; };` (and the same
for IPv6) to keep plain DNS where it was. for IPv6) to keep plain DNS where it was.
4. **adds the host name's A and AAAA records** with the chosen addresses, 4. **adds the host names' A and AAAA records**, each address in the views
in the private views that have a zone for it (`--view` to choose). In a you give it: `--address 10.0.10.5=internal,lab`, or every address in
linked zone they are local to the view, so the name doesn't reach your the views of `--view` (default: the private views that have a zone for
external view. 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.
Running it again replaces the earlier setup (other addresses, ports, DoT **Several host names** (`--hostname dns.adbraun.lol --hostname
or DoH only with `--no-doh` / `--no-dot`). `jarl encrypted-dns disable` doh.adbraun.lol`, or comma separated): each gets the records, the
takes the listeners and the `tls` statement out; the certificate and the certificate must be for all of them (a new one is obtained with all of
records stay. 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 | | | Option | Default | |
|--|--|--| |--|--|--|
| `--hostname` | | The name clients connect to; the certificate is for it | | `--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). Choose your own networks' addresses: a listener on a public address answers anyone who reaches it, like port 53 there | | `--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 | | `--certificate` | one for the host name, obtained if needed | A certificate Jarl manages |
| `--no-dot`, `--no-doh` | both on | | | `--no-dot`, `--no-doh` | both on | |
| `--dot-port` | 853 | Clients expect 853 | | `--dot-port` | 853 | Clients expect 853 |
| `--doh-port` | 443 | Any free port; clients take it in the URL | | `--doh-port` | 443 | Any free port; clients take it in the URL |
| `--view` | private views with the zone | Where the host name's records go | | `--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 In the web UI the same form checks the ports as you choose, offers the
free port, and obtains the certificate before setting up. free port, and obtains the certificate before setting up.
@@ -94,14 +111,21 @@ For DoH clients: https://dns.adbraun.lol/dns-query
| Chrome, Edge | Settings → Privacy and security → Security → Use secure DNS → 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 | | 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: Test from a machine on your network, with BIND's own `dig` (9.18 or
later; `bind9-dnsutils` on Debian):
```sh ```sh
kdig +tls-ca +tls-host=dns.adbraun.lol @10.0.10.5 example.com dig +tls +tls-ca +tls-hostname=dns.adbraun.lol @10.0.10.5 example.com # DoT, port 853
dig +https @10.0.10.5 example.com dig +https +tls-ca +tls-hostname=dns.adbraun.lol @10.0.10.5 example.com # DoH, port 443, /dns-query
curl -s -H 'accept: application/dns-json' 'https://dns.adbraun.lol/dns-query?name=example.com'
``` ```
`+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 **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 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 resolves nothing at all ("Private DNS server cannot be accessed"). Use a
@@ -138,6 +162,62 @@ What it can say:
| not from a CA this server trusts (warning) | a staging or test CA: clients reject it unless they trust it | | 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 | | 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 ## What Jarl checks
* A `tls` statement whose files don't exist is an error * A `tls` statement whose files don't exist is an error
@@ -150,8 +230,8 @@ What it can say:
| Method | Path | Body | | Method | Path | Body |
|--------|------|------| |--------|------|------|
| GET | `/encrypted-dns` | `enabled`, `certificate`, `hostname`, `dot` and `doh` (address and port), `dot_host`, `doh_url`, `listeners`; for the form: `addresses` (this server's, with `private`), `views` (with `public`, and `has_zone` for `?hostname=`), `certificates` | | 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/check` | `{"addresses", "dot", "doh", "dot_port", "doh_port"}`: `conflicts` (address, port, what) and `free_doh_port` |
| POST | `/encrypted-dns` | `{"hostname", "certificate", "addresses", "dot", "doh", "dot_port", "doh_port", "views"}`: staged or applied like any change; 409 while the certificate hasn't been obtained | | 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 | | 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` | | 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` |
+4
View File
@@ -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
+17
View File
@@ -360,6 +360,23 @@ cp /etc/bind/named.conf.pre-jarl-<date> /etc/bind/named.conf
rndc reconfig rndc reconfig
``` ```
## Failed logins in the journal
A failed login to the web UI or the API (a wrong password, or a wrong
two-factor code), and a refused API token, are written to the journal as
well as the audit log, with the address first:
```
level=WARN msg="login failed" ip=203.0.113.9 user=admin reason="wrong username or password"
level=WARN msg="token refused" ip=203.0.113.9 reason="wrong token secret"
```
Guard's `jarl` preset, or a fail2ban filter, can ban such addresses at
the firewall (with Housecarl's ban lists). The address comes before the
user name, which a caller chooses, so a pattern anchored on
`msg="login failed" ip=` can't be fooled by a name. With `log.format:
json` they are JSON objects with the same fields in the same order.
## Monitoring ## Monitoring
* `GET /healthz` (no authentication) reports that jarld is up. * `GET /healthz` (no authentication) reports that jarld is up.
+17 -3
View File
@@ -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
@@ -173,6 +185,7 @@ you can then edit.
| `dns-editor` | `zones:write`, `records:write`, `views:read`, `acls:read`, `options:read`, `blocking:read` | | `dns-editor` | `zones:write`, `records:write`, `views:read`, `acls:read`, `options:read`, `blocking:read` |
| `operator` | `*:write` (everything except user management) | | `operator` | `*:write` (everything except user management) |
| `acme` | `records:write names=_acme-challenge.* types=TXT` | | `acme` | `records:write names=_acme-challenge.* types=TXT` |
| `certificates` | `certificates:write`: certificates for other hosts (`jarl cert get`) for any name in Jarl's zones. Narrow it to a host's names, e.g. `certificates:write names=web01.adbraun.lol` ([certificates.md](certificates.md#certificates-for-other-hosts)) |
```sh ```sh
jarl user create alice --role dns-editor jarl user create alice --role dns-editor
@@ -198,6 +211,7 @@ The audit log shows the user and the token name.
jarl token create laptop # full rights of the user, no expiry jarl token create laptop # full rights of the user, no expiry
jarl token create ci --grant "records:write zones=lab.adbraun.lol" --expires 90d jarl token create ci --grant "records:write zones=lab.adbraun.lol" --expires 90d
jarl token create certbot --user certbot --role acme # admin: a token for another user jarl token create certbot --user certbot --role acme # admin: a token for another user
jarl token create web01 --grant "certificates:write names=web01.adbraun.lol" # jarl cert get on one host
jarl token list --all jarl token list --all
jarl token revoke 12 jarl token revoke 12
``` ```
+50 -9
View File
@@ -97,10 +97,19 @@ jarl zone create 2a02:8013:7f3d:10::/64 --view internal --fill
the dialog's View), the list also has the addresses only other views' 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 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 only the external view has. Fill names those from the other view's
record, when the zone's own view has none for the address, and says so. record only when asked: `--other-views` (with `--fill` or `jarl zone
Keeping PTR records in step (below) stays within a zone's own view and fill-ptr`), or the dialog's **Also for addresses only other views'
its linked views: a later change to that external record doesn't reach records use**, which is ticked for you when you pick a "records in"
the internal reverse zone. 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 ## Creating and filling
@@ -132,8 +141,25 @@ view's A and AAAA records use. An address with several names gets one PTR:
3. the shortest name, then the first alphabetically. 3. the shortest name, then the first alphabetically.
Jarl says which name it chose, and which it didn't, so you can change it. Jarl says which name it chose, and which it didn't, so you can change it.
A reverse zone's page shows how many addresses still lack a PTR, with a A reverse zone's page shows the addresses that still lack a PTR record and
button to add them. `jarl zone show` shows it as `Missing PTRs`. 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 ## Keeping PTR records in step
@@ -147,9 +173,24 @@ jarl zone update adbraun.lol --view internal --auto-ptr # off: --auto-p
(Zone settings → **Reverse DNS** in the web UI.) Then adding, changing, (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 disabling, enabling or deleting an A or AAAA record in the zone does the
same to its PTR record, in the reverse zones of the zone's view and of the same to its PTR record, in the reverse zone of each view that has the
views it is linked with ([views.md](views.md#linked-zones)). A whole set record. A whole set replaced at once (`jarl record set`, a batch) works
replaced at once (`jarl record set`, a batch) works too. 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 * **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 host, it stays: a second A record for the same address doesn't add a
+50 -1
View File
@@ -70,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
@@ -192,6 +193,50 @@ 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 ## Views
With views, the Statistics page has a toggle per view (and **All** and With views, the Statistics page has a toggle per view (and **All** and
@@ -218,7 +263,11 @@ database, roughly in proportion to the number of views.
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
+1 -1
View File
@@ -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 |
+15 -3
View File
@@ -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
@@ -216,7 +222,10 @@ anyway), so scripts don't need to know; `--shared` there is refused.
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
@@ -235,7 +244,10 @@ before it).
## Comparing views ## Comparing views
A zone's **Compare views** tab, or `jarl zone diff`, compares it with the 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: 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 $ jarl zone diff adbraun.lol --view internal
+77 -1
View File
@@ -34,6 +34,9 @@ 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 // OtherViews are the other views with a primary zone of this name
// (linked or not): what GET /zones/{zone}/compare compares. // (linked or not): what GET /zones/{zone}/compare compares.
OtherViews []string `json:"other_views,omitempty"` OtherViews []string `json:"other_views,omitempty"`
@@ -42,6 +45,15 @@ type Zone struct {
// (POST /zones/{zone}/fill-ptr adds them). // (POST /zones/{zone}/fill-ptr adds them).
Prefix string `json:"prefix,omitempty"` Prefix string `json:"prefix,omitempty"`
PTRMissing int `json:"ptr_missing,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"`
} }
@@ -67,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.
@@ -277,6 +304,37 @@ type StatusResponse struct {
// EncryptedDNS is the last check of DNS over TLS and HTTPS, when it // EncryptedDNS is the last check of DNS over TLS and HTTPS, when it
// is set up (for those who may see options). // is set up (for those who may see options).
EncryptedDNS *EncDNSSummary `json:"encrypted_dns,omitempty"` 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.
@@ -368,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.
@@ -434,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"`
@@ -585,6 +658,9 @@ 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. // EncDNSSummary is what the dashboard says about encrypted DNS.
+30
View File
@@ -134,3 +134,33 @@ func TestRoleTemplatesValid(t *testing.T) {
} }
} }
} }
func TestCertificatesRole(t *testing.T) {
p := &Principal{User: &model.User{Grants: RoleTemplates["certificates"]}}
if !p.Can(Action{Resource: ResCerts, Write: true, Name: "web01.adbraun.lol"}) {
t.Error("the certificates role can't get a certificate")
}
if p.Can(Action{Resource: ResRecords, Write: true, Zone: "adbraun.lol."}) || p.Can(Action{Resource: ResZones, Zone: "adbraun.lol."}) {
t.Error("the certificates role reaches beyond certificates")
}
}
func TestCertificateGrants(t *testing.T) {
g, err := ParseGrant("certificates:write names=*.adbraun.lol")
if err != nil {
t.Fatal(err)
}
if !GrantAllows(g, Action{Resource: ResCerts, Write: true, Name: "mail.adbraun.lol"}) || GrantAllows(g, Action{Resource: ResCerts, Write: true, Name: "mail.example.org"}) {
t.Error("names filter")
}
if _, err := ParseGrant("certificates:read types=TXT"); err == nil {
t.Error("types on certificates accepted")
}
if _, err := ParseGrant("certificates:read zones=adbraun.lol"); err == nil {
t.Error("zones on certificates accepted")
}
// "*" never covers certificates: they hand out private keys.
if GrantAllows(model.Grant{Resource: ResAll, Access: Write}, Action{Resource: ResCerts, Name: "mail.adbraun.lol"}) {
t.Error("* covers certificates")
}
}
+16 -4
View File
@@ -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
ResAll = "*" // ResCerts is certificates for other hosts (jarl cert get), by the
// names in them. It is never part of "*": it hands out private keys.
ResCerts = "certificates"
ResAll = "*"
) )
// Resources lists the resources in display order. // 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
} }
@@ -382,4 +391,7 @@ var RoleTemplates = map[string][]model.Grant{
}, },
"operator": {{Resource: ResAll, Access: Write}}, "operator": {{Resource: ResAll, Access: Write}},
"acme": {{Resource: ResRecords, Access: Write, Names: []string{"_acme-challenge.*"}, Types: []string{"TXT"}}}, "acme": {{Resource: ResRecords, Access: Write, Names: []string{"_acme-challenge.*"}, Types: []string{"TXT"}}},
// certificates for other hosts (jarl cert get): narrow it with names=
// to the host's own.
"certificates": {{Resource: ResCerts, Access: Write}},
} }
+130
View File
@@ -52,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.
@@ -132,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}$`)
@@ -210,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 != "" {
@@ -295,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) {
+13 -1
View File
@@ -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).
@@ -395,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
@@ -461,9 +468,10 @@ func Default() *Config {
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},
@@ -604,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")
} }
@@ -754,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
+23
View File
@@ -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.")
+20
View File
@@ -249,3 +249,23 @@ func TestPrefixFromReverse(t *testing.T) {
} }
} }
} }
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)
}
}
}
+183 -25
View File
@@ -9,6 +9,7 @@ import (
"fmt" "fmt"
"net/netip" "net/netip"
"path/filepath" "path/filepath"
"slices"
"strconv" "strconv"
"strings" "strings"
@@ -208,8 +209,10 @@ func GetStatus(st *model.State, certDir string) Status {
// Setup is what to set up. // Setup is what to set up.
type Setup struct { type Setup struct {
// Hostname is the name clients connect to (the certificate's). // Hostnames are the names clients connect to; the certificate must be
Hostname string `json:"hostname"` // 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 is the Jarl certificate (with the dns use) to serve.
Certificate string `json:"certificate"` Certificate string `json:"certificate"`
// Addresses are the addresses of this server to listen on. // Addresses are the addresses of this server to listen on.
@@ -218,9 +221,27 @@ type Setup struct {
DoH bool `json:"doh"` DoH bool `json:"doh"`
DoHPort int `json:"doh_port"` // default 443 DoHPort int `json:"doh_port"` // default 443
DoTPort int `json:"dot_port"` // default 853 (clients expect it) DoTPort int `json:"dot_port"` // default 853 (clients expect it)
// Views get the hostname's A/AAAA records (default: none). In a // AddressViews says, per listen address, the views where the host
// linked zone they are local to the view. // names get it (A or AAAA): a private address shouldn't reach a public
Views []string `json:"views"` // 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, // Plan returns the requests that set up encrypted DNS as s says,
@@ -248,10 +269,15 @@ func Plan(st *model.State, s Setup, certDir string) ([]engine.Request, []string,
if s.Certificate == "" { if s.Certificate == "" {
return nil, nil, fmt.Errorf("choose the certificate to serve") return nil, nil, fmt.Errorf("choose the certificate to serve")
} }
host := strings.ToLower(strings.TrimSuffix(strings.TrimSpace(s.Hostname), ".")) hosts := s.Hosts()
if host == "" || !strings.Contains(host, ".") { if len(hosts) == 0 {
return nil, nil, fmt.Errorf("give the host name clients connect to, e.g. dns.example.com") 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 var v4, v6 []string
for _, a := range s.Addresses { for _, a := range s.Addresses {
ip, err := netip.ParseAddr(strings.TrimSpace(a)) ip, err := netip.ParseAddr(strings.TrimSpace(a))
@@ -325,32 +351,98 @@ func Plan(st *model.State, s Setup, certDir string) ([]engine.Request, []string,
add("listen-on", v4) add("listen-on", v4)
add("listen-on-v6", v6) add("listen-on-v6", v6)
// The host name's addresses, in the views asked for. // The host names' addresses, in the views asked for, per address.
fqdn := host + "." want := map[string][]string{} // view -> addresses
for _, vn := range s.Views { all := append(append([]string{}, v4...), v6...)
v := st.ViewByName(vn) if s.AddressViews != nil {
if v == 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) return nil, nil, fmt.Errorf("view %q does not exist", vn)
} }
z := zoneFor(st, fqdn, v.ID) if s.PublicViews[vn] {
if z == nil { for _, a := range addrs {
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)) if ip, err := netip.ParseAddr(a); err == nil && Private(ip) {
continue 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, ", ")))
}
}
} }
var local *bool }
if z.LinkGroup != 0 { // Ours are the addresses this setup (or the one before) gives the host
local = model.Ptr(true) // 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
}
} }
for _, set := range []struct { }
typ string names := slices.Clone(hosts)
addrs []string for _, h := range st.Settings.EncryptedDNSHosts {
}{{"A", v4}, {"AAAA", v6}} { if !slices.Contains(names, h) {
if len(set.addrs) == 0 { 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 continue
} }
reqs = append(reqs, engine.NewRequest(engine.OpRRSetReplace, 0, engine.RRSetInput{ZoneID: z.ID, Name: fqdn, Type: set.typ, Data: set.addrs, Local: local})) 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 return reqs, notes, nil
} }
@@ -369,6 +461,69 @@ func zoneFor(st *model.State, name string, viewID int64) *model.Zone {
return best 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 // Disable returns the requests that take the setup out: its listeners and
// tls statement. Plain listeners and the host name's records stay. // tls statement. Plain listeners and the host name's records stay.
func Disable(st *model.State) []engine.Request { func Disable(st *model.State) []engine.Request {
@@ -383,6 +538,9 @@ func Disable(st *model.State) []engine.Request {
reqs = append(reqs, engine.NewRequest(engine.OpOptionDelete, b.OptionID, nil)) 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 return reqs
} }
+67
View File
@@ -186,3 +186,70 @@ func TestPorts(t *testing.T) {
t.Errorf("free DoH port = %d, want 4443", p) 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)
}
}
+37
View File
@@ -206,3 +206,40 @@ func describe(err error, e Endpoint) string {
} }
return err.Error() 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
}
+18
View File
@@ -113,3 +113,21 @@ func TestHealth(t *testing.T) {
t.Errorf("nothing listening: %+v", h.Probes[0]) 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])
}
}
+2 -1
View File
@@ -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)), DismissedReverse: model.Ptr(slices.Clone(s.DismissedReverse))}, "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) {
+3
View File
@@ -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)}
+36
View File
@@ -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)
}
}
+293 -211
View File
@@ -5,6 +5,7 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"net/netip" "net/netip"
"reflect"
"slices" "slices"
"strings" "strings"
"time" "time"
@@ -31,6 +32,12 @@ type expander struct {
// exposures classifies the views as public or private (nil in tests: // exposures classifies the views as public or private (nil in tests:
// every view private). // every view private).
exposures func(*model.State) []validate.Exposure 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) {
@@ -69,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)
@@ -90,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))
@@ -135,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, OpZoneFillPTR: 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
@@ -193,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:
@@ -440,8 +471,8 @@ func (x *expander) zoneCreate(req Request) ([]model.Op, error) {
} }
} }
} }
fill := in.Fill fill, fillOther := in.Fill, in.FillOtherViews
in.Fill = false in.Fill, in.FillOtherViews = false, false
if fill { if fill {
if zt != model.ZonePrimary { if zt != model.ZonePrimary {
return nil, errf(KindBadRequest, "fill only applies to primary zones") return nil, errf(KindBadRequest, "fill only applies to primary zones")
@@ -453,10 +484,15 @@ func (x *expander) zoneCreate(req Request) ([]model.Op, error) {
if in.ViewID != nil { if in.ViewID != nil {
vid = *in.ViewID vid = *in.ViewID
} }
fills := PTRFills(x.st, vid, name, moved) fills := PTRFills(x.st, vid, name, moved, fillOther)
if len(fills) == 0 { 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))) 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)...) x.warnings = append(x.warnings, fillWarnings(fills)...)
for _, f := range 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)}) in.Records = append(in.Records, RecordInput{Name: model.Ptr(f.Name), Type: model.Ptr("PTR"), Data: model.Ptr(f.Target)})
@@ -751,65 +787,227 @@ 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
// // request's operations (and those keeping linked zones in step), ptrSync
// auto is set when the zone's auto_ptr asked rather than the request: a // compares each touched A or AAAA set before and after, in every view it
// missing reverse zone isn't worth a warning then (most addresses have // is in, and changes the PTR records of that view's reverse zone to match.
// none), and an address whose PTR record names another host keeps it. // So a record local to one view of a linked zone changes PTR records in
func (x *expander) ptrCreate(z *model.Zone, owner string, p *rr.Parsed, auto bool) ([]model.Op, error) { // that view only, and an address that goes from one view keeps its PTR
addr, ok := addrOf(p) // record in the views that still have it.
if !ok {
return nil, errf(KindBadRequest, "ptr only applies to A and AAAA records") // 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{}
} }
var ops []model.Op x.ptrForced[rrKey{z.ID, name, typ}] = true
found := false for _, o := range x.st.LinkedWith(z) {
for _, vid := range ptrViews(x.st, z) { x.ptrForced[rrKey{o.ID, name, typ}] = true
rz, ptrName := reverseZoneFor(x.st, vid, addr)
if rz == nil {
continue
}
found = true
more, err := x.ptrCreateIn(rz, ptrName, owner, auto)
if err != nil {
return nil, err
}
ops = append(ops, more...)
} }
if !found && !auto {
x.warnings = append(x.warnings, fmt.Sprintf("no reverse zone for %s in this view, so no PTR record was added (create one: jarl zone create %s/%d)", addr, addr, addr.BitLen()))
}
return ops, nil
} }
// ptrViews are the views whose reverse zones get the PTR records of zone // Whether a set has an address: none, only in disabled records, enabled.
// z's address records: z's own, and, for a linked zone, the other views const (
// of the link (the record is in all of them). addrNone = iota
func ptrViews(st *model.State, z *model.Zone) []int64 { addrDisabled
out := []int64{z.ViewID} addrEnabled
for _, o := range st.LinkedWith(z) { )
if !slices.Contains(out, o.ViewID) {
out = append(out, o.ViewID) // 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 return out
} }
// ptrSync returns ops followed by the PTR changes they call for: for the
// zone's auto_ptr, or because the request asked (ptrForce).
func (x *expander) ptrSync(ops []model.Op) ([]model.Op, error) {
forced := x.ptrForced
x.ptrForced = nil
work := x.st.Clone()
c := &applyCtx{p: x.p, now: x.now}
for _, op := range ops {
if err := applyOp(work, op, c); err != nil {
return nil, err
}
}
keys := affectedKeys(x.st, work, ops)
for _, k := range sortedForced(forced) {
if !slices.Contains(keys, k) {
keys = append(keys, k)
}
}
var extra []model.Op
for _, k := range keys {
z := work.Zones[k.zone]
if z == nil || (k.typ != "A" && k.typ != "AAAA") || strings.HasPrefix(k.name, "*.") {
continue
}
force := forced[k]
if !force && !z.AutoPTR {
continue
}
before, after := addrStates(x.st, k), addrStates(work, k)
var addrs []netip.Addr
for _, m := range []map[netip.Addr]int{before, after} {
for a := range m {
if !slices.Contains(addrs, a) {
addrs = append(addrs, a)
}
}
}
slices.SortFunc(addrs, func(a, b netip.Addr) int { return a.Compare(b) })
for _, a := range addrs {
was, is := before[a], after[a]
if !force && (was == is || was == addrNone && is == addrDisabled) {
continue
}
rz, ptrName := reverseZoneFor(work, z.ViewID, a)
if rz == nil {
if force && is == addrEnabled {
x.warnings = append(x.warnings, fmt.Sprintf("no reverse zone for %s in %s, so no PTR record was added there (create one: jarl zone create %s/%d%s)",
a, viewOrServer(work, z.ViewID), a, a.BitLen(), viewFlag(work, z.ViewID)))
}
continue
}
more, err := x.ptrChange(work, rz, ptrName, k.name, is, !force)
if err != nil {
return nil, err
}
for _, op := range more {
if err := applyOp(work, op, c); err != nil {
return nil, err
}
}
extra = append(extra, more...)
}
}
if len(extra) == 0 {
return ops, nil
}
// The PTR records are local (see ptrChange), so this only keeps a
// linked reverse zone consistent.
extra, err := x.linkSync(extra)
if err != nil {
return nil, err
}
return append(ops, extra...), nil
}
func sortedForced(m map[rrKey]bool) []rrKey {
var out []rrKey
for k := range m {
out = append(out, k)
}
slices.SortFunc(out, func(a, b rrKey) int {
if a.zone != b.zone {
return int(a.zone - b.zone)
}
if a.name != b.name {
return strings.Compare(a.name, b.name)
}
return strings.Compare(a.typ, b.typ)
})
return out
}
func viewFlag(st *model.State, viewID int64) string {
if v := st.Views[viewID]; v != nil {
return " --view " + v.Name
}
return ""
}
// ptrChange returns the operations that give reverse zone rz's PTR
// records at ptrName for host owner the state of its address: none
// (deleted), disabled, or enabled (added when missing).
//
// In a linked reverse zone the PTR records are local to each view, since
// each view's follow its own address records: a shared set at ptrName
// is made local in every view of the link first, as each view has it.
func (x *expander) ptrChange(st *model.State, rz *model.Zone, ptrName, owner string, state int, auto bool) ([]model.Op, error) {
nwarn := len(x.warnings)
ops, err := x.ptrChangeIn(st, rz, ptrName, owner, state, auto)
if err != nil || len(ops) == 0 || rz.LinkGroup == 0 {
return ops, err
}
var local []model.Op
for _, m := range st.LinkMembers(rz.LinkGroup) {
for _, r := range st.RRset(m.ID, ptrName, "PTR") {
if r.SharedID != 0 {
local = append(local, newOp(OpRecordUpdate, r.ID, r.Version, RecordInput{SharedID: model.Ptr(int64(0))},
fmt.Sprintf("linked: %s PTR %s becomes local to %s (PTR records follow each view's own address records)", r.Name, r.Data, zoneDesc(st, m))))
}
}
}
if len(local) == 0 {
return ops, nil
}
// Again on the local records (their versions changed).
scratch := st.Clone()
c := &applyCtx{p: x.p, now: x.now}
for _, op := range local {
if err := applyOp(scratch, op, c); err != nil {
return nil, err
}
}
x.warnings = x.warnings[:nwarn]
ops, err = x.ptrChangeIn(scratch, rz, ptrName, owner, state, auto)
if err != nil {
return nil, err
}
return append(local, ops...), nil
}
func (x *expander) ptrChangeIn(st *model.State, rz *model.Zone, ptrName, owner string, state int, auto bool) ([]model.Op, error) {
var ops []model.Op
switch state {
case addrNone:
for _, r := range st.RRset(rz.ID, ptrName, "PTR") {
if strings.EqualFold(r.Data, owner) {
ops = append(ops, newOp(OpRecordDelete, r.ID, r.Version, nil, fmt.Sprintf("delete %s PTR %s", r.Name, r.Data)))
}
}
case addrDisabled:
for _, r := range st.RRset(rz.ID, ptrName, "PTR") {
if strings.EqualFold(r.Data, owner) && !r.Disabled {
ops = append(ops, newOp(OpRecordUpdate, r.ID, r.Version, RecordInput{Disabled: model.Ptr(true)}, fmt.Sprintf("disable %s PTR %s", r.Name, r.Data)))
}
}
default:
return x.ptrCreateIn(st, rz, ptrName, owner, auto)
}
return ops, nil
}
// ptrCreateIn returns operations creating the PTR record ptrName -> owner // ptrCreateIn returns operations creating the PTR record ptrName -> owner
// in reverse zone rz (none when it is there; enabling it when disabled). // in reverse zone rz (none when it is there; enabling it when disabled).
func (x *expander) ptrCreateIn(rz *model.Zone, ptrName, owner string, auto bool) ([]model.Op, error) { // 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 var other string
for _, r := range x.st.ZoneRecords(rz.ID) { for _, r := range st.RRset(rz.ID, ptrName, "PTR") {
if r.Name == ptrName && r.Type == "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)} return []model.Op{newOp(OpRecordUpdate, r.ID, r.Version, in, fmt.Sprintf("enable %s PTR %s", r.Name, r.Data))}, nil
return []model.Op{newOp(OpRecordUpdate, r.ID, r.Version, in, fmt.Sprintf("enable %s PTR %s", r.Name, r.Data))}, nil
}
return nil, nil // already there
}
if !r.Disabled {
other = r.Data
} }
return nil, nil // already there
}
if !r.Disabled {
other = r.Data
} }
} }
if other != "" { if other != "" {
@@ -824,38 +1022,12 @@ func (x *expander) ptrCreateIn(rz *model.Zone, ptrName, owner string, auto bool)
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
}
var ops []model.Op
for _, vid := range ptrViews(x.st, z) {
rz, ptrName := reverseZoneFor(x.st, vid, addr)
if rz == nil {
continue
}
ops = append(ops, x.ptrSetDisabledIn(rz, ptrName, owner, disabled)...)
}
return ops
}
func (x *expander) ptrSetDisabledIn(rz *model.Zone, ptrName, owner string, disabled bool) []model.Op {
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"
@@ -863,23 +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
}
var ops []model.Op
for _, vid := range ptrViews(x.st, z) {
rz, ptrName := reverseZoneFor(x.st, vid, addr)
if rz == nil {
continue
}
in := RecordInput{ZoneID: rz.ID, Name: model.Ptr(ptrName), Type: model.Ptr("PTR"), Data: model.Ptr(owner), MissingOK: true}
ops = append(ops, newOp(OpRecordDelete, 0, 0, in, fmt.Sprintf("delete %s IN PTR %s (if present)", ptrName, owner)))
}
return ops
}
// 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.
@@ -906,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, auto := in.PTR, !in.PTR && autoPTR(z, owner, p) 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)
@@ -934,15 +1094,7 @@ func (x *expander) recordCreate(req Request) ([]model.Op, error) {
} }
// local on a zone that isn't linked: its records are local anyway. // 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 || auto && (in.Disabled == nil || !*in.Disabled) {
more, err := x.ptrCreate(z, owner, p, auto)
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) {
@@ -1063,67 +1215,22 @@ func (x *expander) recordUpdate(req Request) ([]model.Op, error) {
if string(mustJSON(in)) != "{}" || !ptr { if string(mustJSON(in)) != "{}" || !ptr {
ops = append(ops, newOp(OpRecordUpdate, r.ID, base, in, summary)) ops = append(ops, newOp(OpRecordUpdate, r.ID, base, in, summary))
} }
disabled := r.Disabled if ptr {
if in.Disabled != nil { // The PTR records of the old and the new record, whichever is an
disabled = *in.Disabled // address (ptrSync).
} _, isAddr := addrOf(p)
moved := p.Data != r.Data || owner != r.Name || p.Type != r.Type
if !ptr && z.AutoPTR {
// The zone keeps PTR records in step (docs/reverse.md): as if
// asked, for whichever of the old and new record is an address.
old, err := rr.Parse(z.Name, r.Name, r.Type, r.Data)
oldAuto := err == nil && autoPTR(z, r.Name, old)
newAuto := autoPTR(z, owner, p)
switch {
case moved:
if oldAuto {
ops = append(ops, x.ptrDelete(z, r.Name, old)...)
}
if newAuto && !disabled {
more, err := x.ptrCreate(z, owner, p, true)
if err != nil {
return nil, err
}
ops = append(ops, more...)
}
case toggled && newAuto && disabled:
ops = append(ops, x.ptrSetDisabled(z, owner, p, true)...)
case toggled && newAuto:
// Enables the PTR record, or adds it when it is missing.
more, err := x.ptrCreate(z, owner, p, true)
if err != nil {
return nil, err
}
ops = append(ops, more...)
}
return ops, nil
}
if ptr && moved {
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, false)
if err != nil {
return nil, err
} }
ops = append(ops, more...)
} }
} if !isAddr {
if ptr && !moved && toggled {
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)
if ptr && !moved && !toggled && !disabled {
// Nothing else changes: make sure the PTR record is there (the
// reverse zone may be newer than the record).
more, err := x.ptrCreate(z, owner, p, false)
if err != nil {
return nil, err
} }
ops = append(ops, more...)
} }
return ops, nil return ops, nil
} }
@@ -1146,9 +1253,8 @@ func (x *expander) recordDelete(req Request) ([]model.Op, error) {
base = r.Version base = r.Version
} }
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))}
z := x.st.Zones[r.ZoneID] if in.PTR {
if p, err := rr.Parse(z.Name, r.Name, r.Type, r.Data); err == nil && (in.PTR || autoPTR(z, r.Name, p)) { x.ptrForce(x.st.Zones[r.ZoneID], r.Name, r.Type)
ops = append(ops, x.ptrDelete(z, r.Name, p)...)
} }
return ops, nil return ops, nil
} }
@@ -1184,25 +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
switch {
case ptr && parsed != nil:
ops = append(ops, x.ptrDelete(z, owner, parsed)...)
case z.AutoPTR:
// Every matching record's PTR record goes too.
for _, r := range x.st.RRset(z.ID, owner, typ) {
if parsed != nil && r.Data != parsed.Data {
continue
}
if p, err := rr.Parse(z.Name, r.Name, r.Type, r.Data); err == nil && autoPTR(z, r.Name, p) {
ops = append(ops, x.ptrDelete(z, r.Name, p)...)
}
}
}
return ops, nil
} }
// recordSetDisabled expands record.disable and record.enable: the record // recordSetDisabled expands record.disable and record.enable: the record
@@ -1336,32 +1429,7 @@ func (x *expander) rrsetReplace(req Request) ([]model.Op, error) {
if len(canon) == 0 { if len(canon) == 0 {
summary = fmt.Sprintf("delete all %s records of %s%s", typ, owner, where) summary = fmt.Sprintf("delete all %s records of %s%s", typ, owner, where)
} }
ops := append(pre, newOp(OpRRSetReplace, 0, 0, in, summary)) return append(pre, newOp(OpRRSetReplace, 0, 0, in, summary)), nil
if z.AutoPTR && (typ == "A" || typ == "AAAA") && !strings.HasPrefix(owner, "*.") {
// PTR records follow the addresses that go and come.
old := map[string]bool{}
for _, r := range x.st.RRset(z.ID, owner, typ) {
old[r.Data] = true
if !slices.Contains(canon, r.Data) {
if p, err := rr.Parse(z.Name, r.Name, r.Type, r.Data); err == nil {
ops = append(ops, x.ptrDelete(z, r.Name, p)...)
}
}
}
for _, d := range canon {
if old[d] {
continue
}
if _, p, err := parseRecord(z, owner, typ, d); err == nil {
more, err := x.ptrCreate(z, owner, p, true)
if err != nil {
return nil, err
}
ops = append(ops, more...)
}
}
}
return ops, nil
} }
func (x *expander) viewCreate(req Request) ([]model.Op, error) { func (x *expander) viewCreate(req Request) ([]model.Op, error) {
@@ -1555,3 +1623,17 @@ func firstNonEmptyStr(a, b string) string {
func notLinked(st *model.State, z *model.Zone) error { 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)) 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)
}
+14 -2
View File
@@ -163,8 +163,11 @@ type ZoneInput struct {
// step (docs/reverse.md). // step (docs/reverse.md).
AutoPTR *bool `json:"auto_ptr,omitempty"` AutoPTR *bool `json:"auto_ptr,omitempty"`
// Fill (create only, reverse zones) adds a PTR record for every // 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. // address of the view's A and AAAA records in the zone's range;
Fill bool `json:"fill,omitempty"` // 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
@@ -224,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"`
@@ -314,6 +324,8 @@ type SettingsInput struct {
// DismissedReverse are dismissed reverse zone suggestions (without // DismissedReverse are dismissed reverse zone suggestions (without
// views). // views).
DismissedReverse *[]string `json:"dismissed_reverse,omitempty"` 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.
+55 -17
View File
@@ -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,24 +123,40 @@ 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) && x.sharing[rrKey{z.ID, name, typ}] {
// Another view's version was shared (dropOverrides): z gets it.
for _, o := range members {
if src := st.SharedRecords(o.ID, name, typ); len(src) > 0 && !st.HasLocal(o.ID, name, typ) {
for _, r := range src {
op, err := x.copyRecord(st, z, r, "linked: "+name+" "+typ+" is shared again in "+where(z)+": add "+r.Data)
if err != nil {
return nil, err
}
ops = append(ops, op)
}
break
}
}
return ops, nil
}
if len(truth) == 0 && x.st.HasLocal(z.ID, name, typ) { if len(truth) == 0 && x.st.HasLocal(z.ID, name, typ) {
// The override was removed: back to the shared records. // 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 { for _, o := range members {
if st.HasLocal(o.ID, name, typ) { if st.HasLocal(o.ID, name, typ) {
continue continue
} }
src := st.SharedRecords(o.ID, name, typ) var more []model.Op
if len(src) == 0 { for _, r := range st.SharedRecords(o.ID, name, typ) {
continue 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))))
} }
for _, r := range src { if len(more) > 0 && !x.p.Can(recordAction(st, o, name, typ, true)) {
op, err := x.copyRecord(st, z, r, "linked: "+name+" "+typ+" is shared again in "+where(z)+": add "+r.Data) 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)",
if err != nil { dnsname.Display(name), typ, where(z), where(o), where(o))
return nil, err
}
ops = append(ops, op)
} }
break ops = append(ops, more...)
} }
return ops, nil return ops, nil
} }
@@ -440,12 +456,30 @@ 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 *want, nil
return has, 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
@@ -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))))
} }
} }
+64 -3
View File
@@ -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.
@@ -386,3 +391,59 @@ func TestCompareReport(t *testing.T) {
t.Errorf("mail A: %+v", rep) 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)
}
}
+3
View File
@@ -1414,6 +1414,9 @@ func applySettings(st *model.State, op model.Op, c *applyCtx) error {
if in.DismissedReverse != nil { if in.DismissedReverse != nil {
s.DismissedReverse = slices.Clone(*in.DismissedReverse) s.DismissedReverse = slices.Clone(*in.DismissedReverse)
} }
if in.EncryptedDNSHosts != nil {
s.EncryptedDNSHosts = slices.Clone(*in.EncryptedDNSHosts)
}
s.Version++ s.Version++
return nil return nil
} }
+38 -22
View File
@@ -8,7 +8,6 @@ import (
"jarl/internal/dnsname" "jarl/internal/dnsname"
"jarl/internal/model" "jarl/internal/model"
"jarl/internal/rr"
"jarl/internal/validate" "jarl/internal/validate"
) )
@@ -88,11 +87,12 @@ type PTRFill struct {
// PTRFills returns the PTR records a reverse zone named zoneName in a view // 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 // 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 // address, naming its mail or name server host when it is one, otherwise
// a host rather than the domain itself, then the shortest name. An // a host rather than the domain itself, then the shortest name. With
// address no record of the view uses is named from another view's // otherViews, an address no record of the view uses is named from another
// records (fillAddresses), marked From. existing are the zone's records // view's records (fillAddresses), marked From; that is only on request,
// (none for a new zone). // since it publishes one view's host names in another. existing are the
func PTRFills(st *model.State, viewID int64, zoneName string, existing []*model.Record) []PTRFill { // 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) prefix, ok := dnsname.PrefixFromReverse(zoneName)
if !ok { if !ok {
return nil return nil
@@ -106,7 +106,11 @@ func PTRFills(st *model.State, viewID int64, zoneName string, existing []*model.
servers := serverNames(st, viewID) servers := serverNames(st, viewID)
byAddr := map[netip.Addr][]addrUse{} byAddr := map[netip.Addr][]addrUse{}
var order []netip.Addr var order []netip.Addr
for _, u := range fillAddresses(st, viewID) { addrs := forwardAddresses(st, viewID)
if otherViews {
addrs = fillAddresses(st, viewID)
}
for _, u := range addrs {
if !prefix.Contains(u.addr) { if !prefix.Contains(u.addr) {
continue continue
} }
@@ -196,9 +200,23 @@ func displayAll(names []string) []string {
return out 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. // zoneFillPTR expands zone.fill_ptr: the PTR records a reverse zone lacks.
func (x *expander) zoneFillPTR(req Request) ([]model.Op, error) { func (x *expander) zoneFillPTR(req Request) ([]model.Op, error) {
var in LinkInput var in FillPTRInput
if err := decode(req.Data, &in); err != nil { if err := decode(req.Data, &in); err != nil {
return nil, err return nil, err
} }
@@ -216,9 +234,15 @@ func (x *expander) zoneFillPTR(req Request) ([]model.Op, error) {
if _, ok := dnsname.PrefixFromReverse(z.Name); !ok { 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)) 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)) fills := PTRFills(x.st, z.ViewID, z.Name, x.st.ZoneRecords(z.ID), in.OtherViews)
if len(fills) == 0 { if len(fills) == 0 {
return nil, errf(KindBadRequest, "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)) 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)...) x.warnings = append(x.warnings, fillWarnings(fills)...)
ids, err := x.ids(len(fills)) ids, err := x.ids(len(fills))
@@ -227,9 +251,11 @@ func (x *expander) zoneFillPTR(req Request) ([]model.Op, error) {
} }
var ops []model.Op var ops []model.Op
for i, f := range fills { 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)} ri := RecordInput{ZoneID: z.ID, Name: model.Ptr(f.Name), Type: model.Ptr("PTR"), Data: model.Ptr(f.Target)}
if z.LinkGroup != 0 { if z.LinkGroup != 0 {
ri.SharedID = model.Ptr(ids[i]) 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))) ops = append(ops, newOp(OpRecordCreate, ids[i], 0, ri, fmt.Sprintf("add %s IN PTR %s", f.Name, f.Target)))
} }
@@ -414,7 +440,7 @@ func ReverseCandidates(st *model.State, exposures []validate.Exposure, local []n
continue 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{}} 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) fills := PTRFills(st, sc.id, name, nil, opt.All && opt.View != "")
s.Fill = len(fills) s.Fill = len(fills)
for i, f := range fills { for i, f := range fills {
if i == 3 { if i == 3 {
@@ -531,16 +557,6 @@ func (x *expander) reverseDismiss(req Request, dismiss bool) ([]model.Op, error)
var cgnat = netip.MustParsePrefix("100.64.0.0/10") var cgnat = netip.MustParsePrefix("100.64.0.0/10")
// autoPTR reports whether a record's PTR is kept in step by its zone's
// auto_ptr setting.
func autoPTR(z *model.Zone, owner string, p *rr.Parsed) bool {
if z == nil || !z.AutoPTR || strings.HasPrefix(owner, "*.") {
return false
}
_, ok := addrOf(p)
return ok
}
func viewName(st *model.State, id int64) string { func viewName(st *model.State, id int64) string {
if v := st.Views[id]; v != nil { if v := st.Views[id]; v != nil {
return v.Name return v.Name
+131 -9
View File
@@ -69,11 +69,11 @@ func TestReverseFillAndSuggest(t *testing.T) {
env.submit(env.admin, NewRequest(OpZoneCreate, 0, ZoneInput{Name: model.Ptr("fdab:abab:abab::/48")})) env.submit(env.admin, NewRequest(OpZoneCreate, 0, ZoneInput{Name: model.Ptr("fdab:abab:abab::/48")}))
st := env.state() st := env.state()
z6 := st.FindZone("b.a.b.a.b.a.b.a.b.a.d.f.ip6.arpa.", st.ViewByName("internal").ID) 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, LinkInput{})) 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.") { if p := env.ptrs("internal", z6.Name); len(p) != 1 || !strings.HasSuffix(p[0], " nas.example.com.") {
t.Errorf("ip6 fill: %q", p) t.Errorf("ip6 fill: %q", p)
} }
if e := env.submitErr(env.admin, NewRequest(OpZoneFillPTR, z6.ID, LinkInput{})); !strings.Contains(e.Message, "nothing to add") { if e := env.submitErr(env.admin, NewRequest(OpZoneFillPTR, z6.ID, FillPTRInput{})); !strings.Contains(e.Message, "nothing to add") {
t.Errorf("second fill: %v", e) t.Errorf("second fill: %v", e)
} }
@@ -288,18 +288,140 @@ func TestReverseFromOtherView(t *testing.T) {
t.Errorf("suggested unasked: %+v", s) t.Errorf("suggested unasked: %+v", s)
} }
} }
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})) for _, s := range ReverseCandidates(env.state(), env.e.Exposures(env.state()), nil, ReverseOptions{All: true, View: "internal"}) {
st := env.state() if strings.HasPrefix(s.Prefix, "2600:") && (s.Fill != 1 || len(s.Samples) != 1) {
var zone string t.Errorf("candidate counts the other view's address: %+v", s)
for _, z := range st.ZonesInView(st.ViewByName("internal").ID) {
if strings.HasSuffix(z.Name, ".ip6.arpa.") {
zone = z.Name
} }
} }
if p := env.ptrs("internal", zone); len(p) != 1 || !strings.HasSuffix(p[0], " vps.example.com.") { // 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) 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") }) { 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) 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")
}
+1 -1
View File
@@ -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
+18 -1
View File
@@ -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) }
+3
View File
@@ -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.
+45 -11
View File
@@ -275,7 +275,10 @@ type Settings struct {
// DismissedReverse are dismissed reverse zone suggestions (without // DismissedReverse are dismissed reverse zone suggestions (without
// views; see View). // views; see View).
DismissedReverse []string `json:"dismissed_reverse,omitempty"` DismissedReverse []string `json:"dismissed_reverse,omitempty"`
Version int `json:"version"` // 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"`
} }
// BlockingPaused reports whether blocking is paused, for a time or until // BlockingPaused reports whether blocking is paused, for a time or until
@@ -521,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).
@@ -643,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 := ""
@@ -659,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)
@@ -671,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
+42
View File
@@ -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")
}
}
+23
View File
@@ -0,0 +1,23 @@
package server
import (
"bytes"
"log/slog"
"strings"
"testing"
)
// TestFailedLoginLogged: a failed login and a refused token are logged
// with the address first, for Guard's and fail2ban's patterns.
func TestFailedLoginLogged(t *testing.T) {
e := newAPIEnv(t)
var buf bytes.Buffer
e.srv.log = slog.New(slog.NewTextHandler(&buf, nil))
e.req("POST", "/auth/login", "", map[string]string{"username": "x ip=8.8.8.8", "password": "wrong"})
e.req("GET", "/zones", "jarl_aaaaaaaaaaaaaaaa_bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", nil)
for _, want := range []string{`msg="login failed" ip=127.0.0.1 user="x ip=8.8.8.8"`, `msg="token refused" ip=127.0.0.1 reason=`} {
if !strings.Contains(buf.String(), want) {
t.Errorf("no %q in\n%s", want, buf.String())
}
}
}
+4
View File
@@ -136,6 +136,8 @@ func (s *Server) authenticate(r *http.Request) (*auth.Principal, *model.Session,
p, err := s.tokenPrincipal(r.Context(), strings.TrimSpace(tok), ip, r.UserAgent()) p, err := s.tokenPrincipal(r.Context(), strings.TrimSpace(tok), ip, r.UserAgent())
if err != nil { if err != nil {
s.limiter.fail("ip:" + ip) s.limiter.fail("ip:" + ip)
// For Guard and fail2ban: the address first, so a name can't fake it.
s.log.Warn("token refused", "ip", ip, "reason", err.Error())
s.eng.Audit(r.Context(), &auth.Principal{IP: ip, Agent: r.UserAgent(), Method: "token"}, "auth.token_failed", "", 0, "", "failure", err.Error()) s.eng.Audit(r.Context(), &auth.Principal{IP: ip, Agent: r.UserAgent(), Method: "token"}, "auth.token_failed", "", 0, "", "failure", err.Error())
return nil, nil, &httpError{http.StatusUnauthorized, "unauthenticated", "invalid API token: " + err.Error()} return nil, nil, &httpError{http.StatusUnauthorized, "unauthenticated", "invalid API token: " + err.Error()}
} }
@@ -328,6 +330,8 @@ func (s *Server) login(w http.ResponseWriter, r *http.Request) error {
if err == nil && u.Disabled && ok { if err == nil && u.Disabled && ok {
reason = "account disabled" reason = "account disabled"
} }
// For Guard and fail2ban: the address first, so a name can't fake it.
s.log.Warn("login failed", "ip", ip, "user", name, "reason", reason)
s.eng.Audit(r.Context(), &auth.Principal{User: &model.User{Username: name}, IP: ip, Agent: r.UserAgent(), Method: "session"}, "auth.login_failed", "user", 0, name, "failure", reason) s.eng.Audit(r.Context(), &auth.Principal{User: &model.User{Username: name}, IP: ip, Agent: r.UserAgent(), Method: "session"}, "auth.login_failed", "user", 0, name, "failure", reason)
return &httpError{http.StatusUnauthorized, "login_failed", "wrong username or password"} return &httpError{http.StatusUnauthorized, "login_failed", "wrong username or password"}
} }
+173
View File
@@ -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")
}
}
+37 -1
View File
@@ -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)
}
}
+121
View File
@@ -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"])
}
}
+2
View File
@@ -198,6 +198,8 @@ func (s *Server) httpreqPrincipal(w http.ResponseWriter, r *http.Request) (*auth
} }
fail := func(reason string) error { fail := func(reason string) error {
s.limiter.fail("ip:" + ip) s.limiter.fail("ip:" + ip)
// For Guard and fail2ban: the address first, so a name can't fake it.
s.log.Warn("token refused", "ip", ip, "reason", "httpreq: "+reason)
s.eng.Audit(r.Context(), &auth.Principal{User: &model.User{Username: user}, IP: ip, Agent: r.UserAgent(), Method: "token"}, "auth.token_failed", "", 0, "", "failure", "httpreq: "+reason) s.eng.Audit(r.Context(), &auth.Principal{User: &model.User{Username: user}, IP: ip, Agent: r.UserAgent(), Method: "token"}, "auth.token_failed", "", 0, "", "failure", "httpreq: "+reason)
w.Header().Set("WWW-Authenticate", httpreqRealm) w.Header().Set("WWW-Authenticate", httpreqRealm)
return &httpError{http.StatusUnauthorized, "unauthenticated", "invalid credentials: " + reason} return &httpError{http.StatusUnauthorized, "unauthenticated", "invalid credentials: " + reason}
+6
View File
@@ -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
+270 -6
View File
@@ -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,16 @@ 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; an owner who isn't an administrator
// sees only their own downloads).
Owner string `json:"owner,omitempty"`
PulledBy []model.CertificatePull `json:"pulled_by,omitempty"`
} }
type certFiles struct { type certFiles struct {
@@ -57,6 +71,93 @@ 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: all of them for an
// administrator, the principal's own downloads for anyone else (who sees
// only their own certificates).
func (s *Server) withOwners(ctx context.Context, p *auth.Principal, views []certView) error {
pulls, err := s.store.CertificatePulls(ctx)
if err != nil {
return err
}
if !p.IsAdmin() {
for i := range views {
for _, pl := range pulls[views[i].ID] {
if p.User != nil && pl.UserID == p.User.ID {
views[i].PulledBy = append(views[i].PulledBy, pl)
}
}
if p.User != nil && views[i].OwnerID != 0 {
views[i].Owner = p.User.Username
}
}
return nil
}
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 +167,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 err := s.withOwners(r.Context(), p, out); err != nil {
return err
}
if !p.IsAdmin() {
writeJSON(w, http.StatusOK, map[string]any{"certificates": out})
return nil
} }
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 +208,134 @@ 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))
vs := []certView{v}
if err := s.withOwners(r.Context(), p, 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 +382,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 +480,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()
}
+1
View File
@@ -285,6 +285,7 @@ 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 p.Can(auth.Action{Resource: auth.ResOptions}) {
if h := s.encryptedDNSHealth(); h != nil { if h := s.encryptedDNSHealth(); h != nil {
+3 -1
View File
@@ -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
}) })
+49 -10
View File
@@ -36,31 +36,70 @@ 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).
var best *model.Zone func parentInJarl(st *model.State, z *model.Zone, public map[int64]bool) *model.Zone {
for _, o := range st.ZonesInView(z.ViewID) { in := func(viewID int64) *model.Zone {
if o.ID == z.ID || o.Type != model.ZonePrimary || o.Name == z.Name || !dnsname.IsSubdomain(z.Name, o.Name) { var best *model.Zone
for _, o := range st.ZonesInView(viewID) {
if o.Type != model.ZonePrimary || o.Name == z.Name || !dnsname.IsSubdomain(z.Name, o.Name) {
continue
}
if best == nil || len(o.Name) > len(best.Name) {
best = o
}
}
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 continue
} }
if best == nil || len(o.Name) > len(best.Name) { if pz := in(v.ID); pz != nil && (other == nil || public[v.ID] && !public[other.ViewID]) {
best = o other = pz
} }
} }
return best return other
} }
// dsChecker returns the checker for parent DS lookups, with ?resolver=IP. // dsChecker returns the checker for parent DS lookups, with ?resolver=IP.
+41 -10
View File
@@ -39,8 +39,13 @@ type encCert struct {
type encStatus struct { type encStatus struct {
encdns.Status encdns.Status
// Hostname is the name clients connect to (the certificate's first). // Hostnames are the names clients connect to (the setup's, else the
Hostname string `json:"hostname,omitempty"` // 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 and DoHURL are what to give clients.
DoTHost string `json:"dot_host,omitempty"` DoTHost string `json:"dot_host,omitempty"`
DoHURL string `json:"doh_url,omitempty"` DoHURL string `json:"doh_url,omitempty"`
@@ -92,6 +97,15 @@ func (s *Server) encryptedDNS(w http.ResponseWriter, r *http.Request, p *auth.Pr
} }
} }
} }
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 out.Hostname != "" {
if len(out.DoT) > 0 { if len(out.DoT) > 0 {
out.DoTHost = out.Hostname out.DoTHost = out.Hostname
@@ -120,17 +134,25 @@ func (s *Server) encryptedDNS(w http.ResponseWriter, r *http.Request, p *auth.Pr
out.Addresses = append(out.Addresses, encAddress{Address: a.String(), Private: encdns.Private(a)}) 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) }) slices.SortFunc(out.Addresses, func(a, b encAddress) int { return strings.Compare(a.Address, b.Address) })
host := strings.TrimSuffix(r.URL.Query().Get("hostname"), ".") hosts := out.Hostnames
if host == "" { if q := r.URL.Query().Get("hostname"); q != "" {
host = out.Hostname 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) { for _, x := range s.eng.Exposures(st) {
if x.View == "" { if x.View == "" {
continue continue
} }
v := encView{Name: x.View, Public: x.Public} v := encView{Name: x.View, Public: x.Public}
if host != "" { if vv := st.ViewByName(x.View); vv != nil {
if vv := st.ViewByName(x.View); vv != nil { for _, host := range hosts {
for _, z := range st.ZonesInView(vv.ID) { for _, z := range st.ZonesInView(vv.ID) {
if z.Type == model.ZonePrimary && (host+"." == z.Name || strings.HasSuffix(host+".", "."+z.Name)) { if z.Type == model.ZonePrimary && (host+"." == z.Name || strings.HasSuffix(host+".", "."+z.Name)) {
v.HasZone = true v.HasZone = true
@@ -204,13 +226,17 @@ func (s *Server) encryptedDNSSetup(w http.ResponseWriter, r *http.Request, p *au
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) 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: 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) 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 in.Hostname != "" && !certs.Covers(c.Names, []string{in.Hostname}): case len(in.Hosts()) > 0 && !certs.Covers(c.Names, in.Hosts()):
return badRequest("certificate %s is for %s, not %s: clients would reject it", c.Name, strings.Join(c.Names, ", "), in.Hostname) 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()) st, err := s.store.LoadState(r.Context())
if err != nil { if err != nil {
return err 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("")) reqs, notes, err := encdns.Plan(st, in, s.cfg.Certs.FilesDir(""))
if err != nil { if err != nil {
return badRequest("%v", err) return badRequest("%v", err)
@@ -222,7 +248,7 @@ func (s *Server) encryptedDNSSetup(w http.ResponseWriter, r *http.Request, p *au
if in.DoH { if in.DoH {
what = append(what, "DNS over HTTPS") what = append(what, "DNS over HTTPS")
} }
return s.mutateNotes(w, r, p, "set up "+strings.Join(what, " and ")+" for "+in.Hostname, reqs, nil, notes) return s.mutateNotes(w, r, p, "set up "+strings.Join(what, " and ")+" for "+strings.Join(in.Hosts(), ", "), reqs, nil, notes)
} }
// encryptedDNSDisable: DELETE /encrypted-dns. // encryptedDNSDisable: DELETE /encrypted-dns.
@@ -274,7 +300,12 @@ func (s *Server) checkEncryptedDNS(ctx context.Context) *encdns.Health {
var h *encdns.Health var h *encdns.Health
if st.Enabled { if st.Enabled {
if host, serial := s.encTarget(ctx, st); host != "" { 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 = encdns.Check(ctx, st, host, serial, s.encRoots)
h.RequireNames(append([]string{host}, hosts...))
} }
} }
s.encHealth.Lock() s.encHealth.Lock()
+16 -1
View File
@@ -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)")
} }
+3 -2
View File
@@ -8,7 +8,8 @@ import (
) )
// zoneFillPTR: POST /zones/{zone}/fill-ptr adds the PTR records a reverse // zoneFillPTR: POST /zones/{zone}/fill-ptr adds the PTR records a reverse
// zone lacks for its view's A and AAAA records (docs/reverse.md). // 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 { func (s *Server) zoneFillPTR(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
st, err := s.state(r) st, err := s.state(r)
if err != nil { if err != nil {
@@ -18,7 +19,7 @@ func (s *Server) zoneFillPTR(w http.ResponseWriter, r *http.Request, p *auth.Pri
if err != nil { if err != nil {
return err return err
} }
return s.mutate(w, r, p, "", []engine.Request{engine.NewRequest(engine.OpZoneFillPTR, z.ID, engine.LinkInput{ZoneID: z.ID})}, nil) 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 // reverseSuggestions: GET /reverse-zones/suggestions lists the reverse
+67 -1
View File
@@ -215,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
@@ -283,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
} }
+79
View File
@@ -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
}
+2
View File
@@ -206,6 +206,8 @@ func (s *Server) loginTOTP(w http.ResponseWriter, r *http.Request) error {
if err != nil { if err != nil {
s.limiter.fail("ip:"+ip, userKey) s.limiter.fail("ip:"+ip, userKey)
left := s.pendingLogins.fail(in.Challenge) left := s.pendingLogins.fail(in.Challenge)
// For Guard and fail2ban: the address first, so a name can't fake it.
s.log.Warn("login failed", "ip", ip, "user", u.Username, "reason", "wrong code: "+err.Error())
s.eng.Audit(r.Context(), who, "auth.totp_failed", "user", u.ID, u.Username, "failure", err.Error()) s.eng.Audit(r.Context(), who, "auth.totp_failed", "user", u.ID, u.Username, "failure", err.Error())
msg := "wrong code: use the current code from your authenticator app, or a recovery code" msg := "wrong code: use the current code from your authenticator app, or a recovery code"
if left == 0 { if left == 0 {
+1 -1
View File
@@ -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
} }
+40 -1
View File
@@ -29,10 +29,33 @@ func (s *Server) presentZone(st *model.State, z *model.Zone, counts map[int64]re
} }
} }
} }
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 { if pf, ok := dnsname.PrefixFromReverse(z.Name); ok {
out.Prefix = pf.String() out.Prefix = pf.String()
if z.Type == model.ZonePrimary { if z.Type == model.ZonePrimary {
out.PTRMissing = len(engine.PTRFills(st, z.ViewID, z.Name, st.ZoneRecords(z.ID))) 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) {
@@ -73,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 {
+26
View File
@@ -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)
}
+3
View File
@@ -174,6 +174,8 @@ func (s *Server) routes() {
m.HandleFunc("POST "+p+"/certificates", s.authed(s.certCreate)) m.HandleFunc("POST "+p+"/certificates", s.authed(s.certCreate))
m.HandleFunc("POST "+p+"/certificates/check", s.authed(s.certCheck)) m.HandleFunc("POST "+p+"/certificates/check", s.authed(s.certCheck))
m.HandleFunc("GET "+p+"/certificates/{name}", s.authed(s.certGet)) m.HandleFunc("GET "+p+"/certificates/{name}", s.authed(s.certGet))
m.HandleFunc("GET "+p+"/certificates/{name}/pem", s.authed(s.certPEM))
m.HandleFunc("POST "+p+"/certificates/{name}/request", s.authed(s.certRequest))
m.HandleFunc("PATCH "+p+"/certificates/{name}", s.authed(s.certUpdate)) m.HandleFunc("PATCH "+p+"/certificates/{name}", s.authed(s.certUpdate))
m.HandleFunc("DELETE "+p+"/certificates/{name}", s.authed(s.certDelete)) m.HandleFunc("DELETE "+p+"/certificates/{name}", s.authed(s.certDelete))
m.HandleFunc("POST "+p+"/certificates/{name}/issue", s.authed(s.certIssue)) m.HandleFunc("POST "+p+"/certificates/{name}/issue", s.authed(s.certIssue))
@@ -195,6 +197,7 @@ func (s *Server) routes() {
m.HandleFunc("GET "+p+"/stats/heatmap", s.authed(s.statsHeatmap)) m.HandleFunc("GET "+p+"/stats/heatmap", s.authed(s.statsHeatmap))
m.HandleFunc("GET "+p+"/stats/breakdown", s.authed(s.statsBreakdown)) m.HandleFunc("GET "+p+"/stats/breakdown", s.authed(s.statsBreakdown))
m.HandleFunc("GET "+p+"/stats/server", s.authed(s.statsServer)) m.HandleFunc("GET "+p+"/stats/server", s.authed(s.statsServer))
m.HandleFunc("GET "+p+"/stats/explore", s.authed(s.statsExplore))
m.HandleFunc(p+"/", func(w http.ResponseWriter, r *http.Request) { m.HandleFunc(p+"/", func(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusNotFound, "not_found", "no such API endpoint: "+r.Method+" "+r.URL.Path) writeError(w, http.StatusNotFound, "not_found", "no such API endpoint: "+r.Method+" "+r.URL.Path)
}) })
+77
View File
@@ -5,6 +5,7 @@ import (
"encoding/json" "encoding/json"
"io" "io"
"log/slog" "log/slog"
"path/filepath"
"strings" "strings"
"testing" "testing"
"time" "time"
@@ -121,3 +122,79 @@ func TestStatsAPI(t *testing.T) {
vtok := e.do("POST", "/users/"+itoa(viewerID)+"/tokens", e.token, map[string]any{"name": "t"}, 201)["token"].(string) vtok := e.do("POST", "/users/"+itoa(viewerID)+"/tokens", e.token, map[string]any{"name": "t"}, 201)["token"].(string)
e.do("GET", "/stats/summary", vtok, nil, 200) e.do("GET", "/stats/summary", vtok, nil, 200)
} }
func TestStatsExploreAPI(t *testing.T) {
ctx := context.Background()
ss, err := stats.OpenStore(":memory:")
if err != nil {
t.Fatal(err)
}
defer ss.Close()
now := time.Now().Unix()
b := &stats.Batch{}
b.AddQueries([]stats.QueryRow{
{Time: now - 60, Client: "10.0.0.7", View: "internal", Name: "www.example.com", Type: "A", RCode: "NOERROR"},
{Time: now - 50, Client: "10.0.0.7", View: "internal", Name: "ads.tracker.test", Type: "A", RCode: "NXDOMAIN", Blocked: "hagezi"},
{Time: now - 40, Client: "198.51.100.9", View: "external", Name: "nope.example.com", Type: "A", RCode: "NXDOMAIN"},
})
if err := ss.Commit(ctx, b); err != nil {
t.Fatal(err)
}
opts := stats.Options{QueryLog: true, StatisticsChannel: "127.0.0.1:1", TopN: 10, RetainMinute: time.Hour, RetainHour: 24 * time.Hour, RetainDay: 24 * time.Hour, RetainQueries: 7 * 24 * time.Hour}
c := stats.NewCollector(ss, opts, nil, slog.New(slog.NewTextHandler(io.Discard, nil)))
e := newAPIEnv(t, c)
e.do("POST", "/zones", e.token, map[string]any{"name": "example.com", "records": []map[string]any{{"name": "ns1", "type": "A", "data": "192.0.2.1"}, {"name": "laptop", "type": "A", "data": "10.0.0.7"}}}, 201)
ex := e.do("GET", "/stats/explore?rcode=NXDOMAIN&since=1h", e.token, nil, 200)
if ex["total"].(float64) != 2 || ex["blocked"].(float64) != 1 {
t.Errorf("NXDOMAIN: %v", ex)
}
clients := ex["by"].(map[string]any)["client"].([]any)
if len(clients) != 2 {
t.Fatalf("clients %v", clients)
}
named := false
for _, c := range clients {
m := c.(map[string]any)
named = named || m["key"] == "10.0.0.7" && m["name"] == "laptop.example.com"
}
if !named {
t.Errorf("client names %v", clients)
}
ex = e.do("GET", "/stats/explore?client=10.0.0.7", e.token, nil, 200)
recent := ex["recent"].([]any)
if ex["total"].(float64) != 2 || ex["client_name"] != "laptop.example.com" || len(recent) != 2 || recent[0].(map[string]any)["client_name"] != "laptop.example.com" {
t.Errorf("laptop: %v", ex)
}
if ex := e.do("GET", "/stats/explore?client=10.0.0.7&views=external", e.token, nil, 200); ex["total"].(float64) != 0 {
t.Errorf("laptop in external only: %v", ex["total"])
}
// The dashboard reports the databases' size.
st := e.do("GET", "/status", e.token, nil, 200)
sg, ok := st["storage"].(map[string]any)
if !ok || len(sg["databases"].([]any)) == 0 {
t.Errorf("storage %v", st["storage"])
}
}
// /stats/server reports the statistics database's size and the query log.
func TestStatsServerDatabase(t *testing.T) {
ss, err := stats.OpenStore(filepath.Join(t.TempDir(), "stats.db"))
if err != nil {
t.Fatal(err)
}
defer ss.Close()
b := &stats.Batch{}
b.AddQueries([]stats.QueryRow{{Time: time.Now().Unix(), Client: "10.0.0.7", View: "internal", Name: "www.example.com", Type: "A", RCode: "NOERROR"}})
if err := ss.Commit(context.Background(), b); err != nil {
t.Fatal(err)
}
opts := stats.Options{QueryLog: true, StatisticsChannel: "127.0.0.1:1", TopN: 10, RetainMinute: time.Hour, RetainHour: 24 * time.Hour, RetainDay: 24 * time.Hour, RetainQueries: 7 * 24 * time.Hour}
e := newAPIEnv(t, stats.NewCollector(ss, opts, nil, slog.New(slog.NewTextHandler(io.Discard, nil))))
sv := e.do("GET", "/stats/server", e.token, nil, 200)
db, ok := sv["database"].(map[string]any)
if !ok || db["bytes"].(float64) <= 0 || db["query_log_rows"].(float64) != 1 {
t.Errorf("database %v", sv["database"])
}
}
+53 -12
View File
@@ -56,9 +56,12 @@ type Options struct {
RetainMinute time.Duration RetainMinute time.Duration
RetainHour time.Duration RetainHour time.Duration
RetainDay time.Duration RetainDay time.Duration
Anonymize bool // RetainQueries is how long the query log keeps every answer (0: no
Exclude []netip.Prefix // query log; it needs QueryLog).
Location *time.Location // for days and log timestamps (default: local time) RetainQueries time.Duration
Anonymize bool
Exclude []netip.Prefix
Location *time.Location // for days and log timestamps (default: local time)
// MainConfig is named's main configuration file; it tells named's // MainConfig is named's main configuration file; it tells named's
// process apart from other named processes (for its memory and CPU). // process apart from other named processes (for its memory and CPU).
MainConfig string MainConfig string
@@ -87,10 +90,18 @@ type Status struct {
Warnings []string `json:"warnings"` Warnings []string `json:"warnings"`
FirstData *time.Time `json:"first_data,omitempty"` FirstData *time.Time `json:"first_data,omitempty"`
Retention struct { Retention struct {
Minute string `json:"minute"` Minute string `json:"minute"`
Hour string `json:"hour"` Hour string `json:"hour"`
Day string `json:"day"` Day string `json:"day"`
Queries string `json:"queries"` // the query log ("" when off)
} `json:"retention"` } `json:"retention"`
// RetentionSeconds is Retention in seconds, for the web UI's ranges.
RetentionSeconds struct {
Minute int64 `json:"minute"`
Hour int64 `json:"hour"`
Day int64 `json:"day"`
Queries int64 `json:"queries"` // 0 when the query log is off
} `json:"retention_seconds"`
} }
// Collector gathers statistics into a Store. // Collector gathers statistics into a Store.
@@ -119,9 +130,11 @@ type Collector struct {
recentL []int64 // response log lines per minute, last 10 minutes recentL []int64 // response log lines per minute, last 10 minutes
linesMin int64 linesMin int64
ensureAt time.Time ensureAt time.Time
// rewritten holds queries named blocked (client#port|name) until their // rewritten holds queries named blocked (client#port|name), and what
// response line arrives. // blocked them, until their response line arrives.
rewritten map[string]bool rewritten map[string]string
// pending are answers for the query log, written at the next flush.
pending []QueryRow
// viewsSince is set once per-view counters exist (meta views_since). // viewsSince is set once per-view counters exist (meta views_since).
viewsSince bool viewsSince bool
viewsSinceAt int64 viewsSinceAt int64
@@ -137,6 +150,10 @@ type Collector struct {
// maxKeys bounds the per-hour maps; rarely seen keys are dropped first. // maxKeys bounds the per-hour maps; rarely seen keys are dropped first.
const maxKeys = 200_000 const maxKeys = 200_000
// maxPending bounds the answers waiting for the query log between flushes
// (a minute); past it they are counted but not logged.
const maxPending = 500_000
// NewCollector creates a collector. Run starts it. // NewCollector creates a collector. Run starts it.
func NewCollector(store *Store, opts Options, zones ZoneIndex, log *slog.Logger) *Collector { func NewCollector(store *Store, opts Options, zones ZoneIndex, log *slog.Logger) *Collector {
if opts.Location == nil { if opts.Location == nil {
@@ -154,6 +171,13 @@ func NewCollector(store *Store, opts Options, zones ZoneIndex, log *slog.Logger)
c.status.Retention.Minute = humanDuration(opts.RetainMinute) c.status.Retention.Minute = humanDuration(opts.RetainMinute)
c.status.Retention.Hour = humanDuration(opts.RetainHour) c.status.Retention.Hour = humanDuration(opts.RetainHour)
c.status.Retention.Day = humanDuration(opts.RetainDay) c.status.Retention.Day = humanDuration(opts.RetainDay)
c.status.RetentionSeconds.Minute = int64(opts.RetainMinute / time.Second)
c.status.RetentionSeconds.Hour = int64(opts.RetainHour / time.Second)
c.status.RetentionSeconds.Day = int64(opts.RetainDay / time.Second)
if opts.QueryLog && opts.RetainQueries > 0 {
c.status.Retention.Queries = humanDuration(opts.RetainQueries)
c.status.RetentionSeconds.Queries = int64(opts.RetainQueries / time.Second)
}
return c return c
} }
@@ -362,7 +386,8 @@ func (c *Collector) handleLine(line string) {
} }
return return
} }
blocked := c.rewritten[ev.Client.String()+"#"+ev.Port+"|"+ev.Name] blockedBy := c.rewritten[ev.Client.String()+"#"+ev.Port+"|"+ev.Name]
blocked := blockedBy != ""
if blocked { if blocked {
delete(c.rewritten, ev.Client.String()+"#"+ev.Port+"|"+ev.Name) delete(c.rewritten, ev.Client.String()+"#"+ev.Port+"|"+ev.Name)
} }
@@ -405,6 +430,9 @@ func (c *Collector) handleLine(line string) {
} }
add(KindName, ev.Name) add(KindName, ev.Name)
add(KindClient, client) add(KindClient, client)
if c.opts.RetainQueries > 0 && len(c.pending) < maxPending {
c.pending = append(c.pending, QueryRow{Time: ev.Time.Unix(), Client: client, View: view, Name: ev.Name, Type: ev.Type, RCode: ev.RCode, Blocked: blockedBy})
}
switch { switch {
case blocked: case blocked:
// Counted by handleRewrite; not a name that doesn't exist. // Counted by handleRewrite; not a name that doesn't exist.
@@ -445,9 +473,9 @@ func (c *Collector) handleRewrite(rw Rewrite) {
if c.rewritten == nil || len(c.rewritten) > 10_000 { if c.rewritten == nil || len(c.rewritten) > 10_000 {
// Rewrites whose response line never came (named only logs // Rewrites whose response line never came (named only logs
// responses while response logging is on) don't pile up. // responses while response logging is on) don't pile up.
c.rewritten = map[string]bool{} c.rewritten = map[string]string{}
} }
c.rewritten[rw.Client.String()+"#"+rw.Port+"|"+rw.Name] = true c.rewritten[rw.Client.String()+"#"+rw.Port+"|"+rw.Name] = policySource(rw.Via)
minute, hour := c.minuteOf(rw.Time), c.hourOf(rw.Time) minute, hour := c.minuteOf(rw.Time), c.hourOf(rw.Time)
view := rw.View view := rw.View
if view == "" { if view == "" {
@@ -639,6 +667,8 @@ func (c *Collector) flush(ctx context.Context, final bool) {
pos, _ := json.Marshal(c.tail.Position()) pos, _ := json.Marshal(c.tail.Position())
b.SetMeta("tail", string(pos)) b.SetMeta("tail", string(pos))
} }
b.AddQueries(c.pending)
c.pending = nil
c.updateWarnings() c.updateWarnings()
c.mu.Unlock() c.mu.Unlock()
@@ -737,6 +767,17 @@ func (c *Collector) prune(ctx context.Context) {
c.log.Debug("statistics: pruned", "resolution", res, "rows", n) c.log.Debug("statistics: pruned", "resolution", res, "rows", n)
} }
} }
// The query log keeps answers for RetainQueries; with no query log,
// none at all (it may have been turned off).
keep := c.opts.RetainQueries
if !c.opts.QueryLog {
keep = 0
}
if n, err := c.store.PruneQueries(ctx, now-int64(keep.Seconds())); err != nil {
c.log.Warn("statistics: pruning the query log failed", "err", err)
} else if n > 0 {
c.log.Debug("statistics: pruned the query log", "rows", n)
}
} }
func humanDuration(d time.Duration) string { func humanDuration(d time.Duration) string {
+272
View File
@@ -0,0 +1,272 @@
package stats
import (
"context"
"fmt"
"strings"
"time"
)
// Exploring the query log (docs/stats.md#exploring): every answer of the
// last days (stats.retention.queries), filtered by any mix of client, name,
// view, type, result and blocking, broken down by each of them.
// ExploreFilter narrows the query log. Empty fields don't filter. Each
// field may hold several values, comma separated: any of them; a value
// written !VALUE excludes it. A name *.example.com is it and the names
// below it.
type ExploreFilter struct {
Client string // a client key, as the top lists show it
Name string // exactly this name
Domain string // this name or any below it
View string
Type string
RCode string
// Blocked is "yes" (blocked by anything), "no", or a blocklist's name
// ("rules" for your own rules).
Blocked string
Since time.Duration // how far back (default 24h)
}
// Dimensions are what the query log breaks down by, in the order shown.
var Dimensions = []string{"client", "name", "view", "type", "rcode", "blocked"}
// Exploration is what the query log says for a filter.
type Exploration struct {
From time.Time `json:"from"`
To time.Time `json:"to"`
Total int64 `json:"total"`
// Blocked counts the blocked answers among them.
Blocked int64 `json:"blocked"`
// Series are the answers (and blocked ones) per Step seconds.
Step int64 `json:"step"`
Series []ExplorePoint `json:"series"`
// By are the top values of each dimension the filter leaves open.
By map[string][]Entry `json:"by"`
// Recent are the latest answers.
Recent []QueryRow `json:"recent"`
// Oldest is the first answer the query log still has.
Oldest *time.Time `json:"oldest,omitempty"`
// Retention is how long it keeps them, also in seconds.
Retention string `json:"retention"`
RetentionSeconds int64 `json:"retention_seconds"`
}
// ExplorePoint is one step of the series.
type ExplorePoint struct {
Time int64 `json:"t"`
Count int64 `json:"count"`
Blocked int64 `json:"blocked"`
}
// ErrNoQueryLog is returned when the query log is off.
var ErrNoQueryLog = fmt.Errorf("the query log is off: it needs stats.query_log and a stats.retention.queries above 0 in /etc/jarl/jarl.yaml (docs/stats.md#exploring)")
// values splits a filter field into the values it must be one of and the
// ones (written !VALUE) it must not be: "10.0.0.5, !10.0.0.9".
func values(field string, norm func(string) string) (in, out []string) {
for _, v := range strings.Split(field, ",") {
v = strings.TrimSpace(v)
not := strings.HasPrefix(v, "!")
v = norm(strings.TrimSpace(strings.TrimPrefix(v, "!")))
switch {
case v == "":
case not:
out = append(out, v)
default:
in = append(in, v)
}
}
return in, out
}
// nameValues are the name filter's values: Name's, and Domain's as
// "*.DOMAIN" (the name and the names below it).
func (f ExploreFilter) nameValues() (in, out []string) {
norm := func(v string) string { return strings.ToLower(strings.TrimSuffix(v, ".")) }
in, out = values(f.Name, norm)
din, dout := values(f.Domain, norm)
for _, d := range din {
in = append(in, "*."+strings.TrimPrefix(d, "*."))
}
for _, d := range dout {
out = append(out, "*."+strings.TrimPrefix(d, "*."))
}
return in, out
}
// fieldValues returns a dimension's filter values.
func (f ExploreFilter) fieldValues(dim string) (in, out []string) {
same := func(v string) string { return v }
upper := strings.ToUpper
switch dim {
case "client":
return values(f.Client, same)
case "name":
return f.nameValues()
case "view":
return values(f.View, same)
case "type":
return values(f.Type, upper)
case "rcode":
return values(f.RCode, upper)
case "blocked":
return values(f.Blocked, same)
}
return nil, nil
}
// cond is the SQL condition for one value of a dimension.
func cond(dim, v string) (string, []any) {
switch {
case dim == "name" && strings.HasPrefix(v, "*."):
d := strings.TrimPrefix(v, "*.")
return "(name = ? OR (length(name) > length(?) AND substr(name, length(name) - length(?)) = ?))", []any{d, d, d, "." + d}
case dim == "blocked" && v == "yes":
return "blocked != ''", nil
case dim == "blocked" && v == "no":
return "blocked = ''", nil
}
return dim + " = ?", []any{v}
}
// where builds the SQL condition and arguments for a filter: within a
// dimension any of its values, and none of its !values; the dimensions
// together.
func (q *Querier) where(f ExploreFilter, from int64) (string, []any) {
conds := []string{"ts >= ?"}
args := []any{from}
for _, dim := range Dimensions {
in, out := f.fieldValues(dim)
if len(in) > 0 {
var or []string
for _, v := range in {
c, a := cond(dim, v)
or = append(or, c)
args = append(args, a...)
}
conds = append(conds, "("+strings.Join(or, " OR ")+")")
}
for _, v := range out {
c, a := cond(dim, v)
conds = append(conds, "NOT "+c)
args = append(args, a...)
}
}
if len(q.Views) > 0 {
marks := strings.TrimSuffix(strings.Repeat("?,", len(q.Views)), ",")
conds = append(conds, "view IN ("+marks+")")
for _, v := range q.Views {
args = append(args, v)
}
}
return strings.Join(conds, " AND "), args
}
// Explore answers a filter: totals, a series, the top values of each open
// dimension (topN each) and the latest answers (recent of them).
func (q *Querier) Explore(ctx context.Context, f ExploreFilter, topN, recent int) (*Exploration, error) {
if !q.Opts.QueryLog || q.Opts.RetainQueries <= 0 {
return nil, ErrNoQueryLog
}
now := q.Now()
since := f.Since
if since <= 0 {
since = 24 * time.Hour
}
if since > q.Opts.RetainQueries {
since = q.Opts.RetainQueries
}
from := now.Add(-since).Unix()
out := &Exploration{From: time.Unix(from, 0), To: now, By: map[string][]Entry{}, Series: []ExplorePoint{}, Recent: []QueryRow{},
Retention: humanDuration(q.Opts.RetainQueries), RetentionSeconds: int64(q.Opts.RetainQueries / time.Second)}
cond, args := q.where(f, from)
db := q.Store.db
if err := db.QueryRowContext(ctx, `SELECT count(*), coalesce(sum(blocked != ''), 0) FROM queries WHERE `+cond, args...).Scan(&out.Total, &out.Blocked); err != nil {
return nil, err
}
// The series: about 100 steps over the range, in whole minutes.
step := int64(since.Seconds()) / 100
step = max(60, step-step%60)
out.Step = step
rows, err := db.QueryContext(ctx, `SELECT ts - ts % ? AS t, count(*), sum(blocked != '') FROM queries WHERE `+cond+` GROUP BY t ORDER BY t`, append([]any{step}, args...)...)
if err != nil {
return nil, err
}
got := map[int64]ExplorePoint{}
for rows.Next() {
var p ExplorePoint
if err := rows.Scan(&p.Time, &p.Count, &p.Blocked); err != nil {
rows.Close()
return nil, err
}
got[p.Time] = p
}
rows.Close()
for t := from - from%step; t <= now.Unix(); t += step {
p := got[t]
p.Time = t
out.Series = append(out.Series, p)
}
// A dimension is broken down unless the filter pins it to one value
// (several: how they compare; !values leave the rest to see).
open := map[string]bool{}
for _, dim := range Dimensions {
in, _ := f.fieldValues(dim)
open[dim] = len(in) != 1 || (dim == "blocked" && in[0] == "yes") || (dim == "name" && strings.HasPrefix(in[0], "*."))
}
for _, dim := range Dimensions {
if !open[dim] {
continue
}
c := cond
if dim == "blocked" {
c += " AND blocked != ''"
}
rows, err := db.QueryContext(ctx, `SELECT `+dim+`, count(*) AS n FROM queries WHERE `+c+` GROUP BY `+dim+` ORDER BY n DESC, `+dim+` LIMIT ?`, append(args, topN)...)
if err != nil {
return nil, err
}
es := []Entry{}
for rows.Next() {
var e Entry
if err := rows.Scan(&e.Key, &e.Count); err != nil {
rows.Close()
return nil, err
}
es = append(es, e)
}
rows.Close()
out.By[dim] = es
}
rows, err = db.QueryContext(ctx, `SELECT ts, client, view, name, type, rcode, blocked FROM queries WHERE `+cond+` ORDER BY ts DESC, rowid DESC LIMIT ?`, append(args, recent)...)
if err != nil {
return nil, err
}
for rows.Next() {
var r QueryRow
if err := rows.Scan(&r.Time, &r.Client, &r.View, &r.Name, &r.Type, &r.RCode, &r.Blocked); err != nil {
rows.Close()
return nil, err
}
out.Recent = append(out.Recent, r)
}
rows.Close()
var oldest *int64
if err := db.QueryRowContext(ctx, `SELECT min(ts) FROM queries`).Scan(&oldest); err == nil && oldest != nil {
t := time.Unix(*oldest, 0)
out.Oldest = &t
}
return out, nil
}
// QueryLogSize returns how many answers the query log has.
func (s *Store) QueryLogSize(ctx context.Context) (int64, error) {
var n int64
err := s.db.QueryRowContext(ctx, `SELECT count(*) FROM queries`).Scan(&n)
return n, err
}
+131
View File
@@ -0,0 +1,131 @@
package stats
import (
"context"
"fmt"
"io"
"log/slog"
"path/filepath"
"testing"
"time"
)
func TestExplore(t *testing.T) {
ctx := context.Background()
loc := time.UTC
store, err := OpenStore(filepath.Join(t.TempDir(), "stats.db"))
if err != nil {
t.Fatal(err)
}
defer store.Close()
clk := &clock{time.Date(2026, 10, 13, 12, 0, 30, 0, loc)}
opts := Options{QueryLog: true, LogFile: "unused", StatisticsChannel: "unused", TopN: 10, RetainMinute: time.Hour, RetainHour: time.Hour, RetainDay: time.Hour,
RetainQueries: 7 * 24 * time.Hour, Location: loc}
c := NewCollector(store, opts, func() map[string][]string { return nil }, slog.New(slog.NewTextHandler(io.Discard, nil)))
c.now = clk.now
at := clk.t.Add(-10 * time.Minute)
// The phone: lookups, an NXDOMAIN, a blocked tracker.
c.HandleLine(logLine(at, "10.0.0.5", "internal", "www.adbraun.lol", "A", "NOERROR"))
c.HandleLine(logLine(at, "10.0.0.5", "internal", "nas.adbraun.lol", "AAAA", "NOERROR"))
c.HandleLine(logLine(at, "10.0.0.5", "internal", "typo.adbraun.lol", "A", "NXDOMAIN"))
c.HandleLine(fmt.Sprintf("%s client @0x7f0000000000 10.0.0.5#5353 (t.tracker.example): view internal: rpz QNAME NXDOMAIN rewrite t.tracker.example/A/IN via t.tracker.example.hagezi.blocklist.jarl.invalid\n", at.Format(logTimeLayout)))
c.HandleLine(logLine(at, "10.0.0.5", "internal", "t.tracker.example", "A", "NXDOMAIN"))
// A scanner in the external view, NXDOMAIN twice.
for i := 0; i < 2; i++ {
c.HandleLine(logLine(at, "198.51.100.7", "external", "nope.adbraun.lol", "A", "NXDOMAIN"))
}
c.flush(ctx, true)
q := &Querier{Store: store, Opts: opts, Now: clk.now}
ex, err := q.Explore(ctx, ExploreFilter{View: "external", RCode: "NXDOMAIN"}, 10, 50)
if err != nil {
t.Fatal(err)
}
if ex.Total != 2 || len(ex.By["client"]) != 1 || ex.By["client"][0].Key != "198.51.100.7" || ex.By["view"] != nil {
t.Errorf("external NXDOMAIN: %+v", ex)
}
// What else does the phone do?
ex, err = q.Explore(ctx, ExploreFilter{Client: "10.0.0.5"}, 10, 50)
if err != nil {
t.Fatal(err)
}
if ex.Total != 4 || ex.Blocked != 1 || len(ex.Recent) != 4 {
t.Errorf("phone: total %d blocked %d recent %d", ex.Total, ex.Blocked, len(ex.Recent))
}
if b := ex.By["blocked"]; len(b) != 1 || b[0].Key != "hagezi" || b[0].Count != 1 {
t.Errorf("blocked by %+v", b)
}
if r := ex.By["rcode"]; len(r) != 2 || r[0].Key != "NOERROR" || r[0].Count != 2 {
t.Errorf("rcode %+v", r)
}
var sum int64
for _, p := range ex.Series {
sum += p.Count
}
if sum != 4 || ex.Step != 60*int64(24*60/100) {
t.Errorf("series sum %d step %d", sum, ex.Step)
}
// A domain and what's below it.
ex, _ = q.Explore(ctx, ExploreFilter{Domain: "adbraun.lol"}, 10, 50)
if ex.Total != 5 {
t.Errorf("domain adbraun.lol: %d", ex.Total)
}
ex, _ = q.Explore(ctx, ExploreFilter{Name: "nas.adbraun.lol", Type: "aaaa"}, 10, 50)
if ex.Total != 1 || ex.Recent[0].Client != "10.0.0.5" {
t.Errorf("nas AAAA: %+v", ex)
}
ex, _ = q.Explore(ctx, ExploreFilter{Blocked: "no", Client: "10.0.0.5"}, 10, 50)
if ex.Total != 3 {
t.Errorf("not blocked: %d", ex.Total)
}
// Limited to a view (the Statistics page's view switches).
ex, _ = q.ForViews([]string{"internal"}).Explore(ctx, ExploreFilter{RCode: "NXDOMAIN"}, 10, 50)
if ex.Total != 2 {
t.Errorf("internal NXDOMAIN: %d", ex.Total)
}
// Several values: any of them; !values: none of them.
total := func(f ExploreFilter) int64 {
t.Helper()
ex, err := q.Explore(ctx, f, 10, 50)
if err != nil {
t.Fatal(err)
}
return ex.Total
}
for _, c := range []struct {
f ExploreFilter
want int64
}{
{ExploreFilter{Client: "10.0.0.5, 198.51.100.7"}, 6},
{ExploreFilter{Client: "!10.0.0.5"}, 2},
{ExploreFilter{RCode: "NXDOMAIN,noerror"}, 6},
{ExploreFilter{Name: "*.adbraun.lol"}, 5},
{ExploreFilter{Name: "!*.adbraun.lol"}, 1},
{ExploreFilter{Name: "www.adbraun.lol,nas.adbraun.lol"}, 2},
{ExploreFilter{Name: "*.adbraun.lol, !typo.adbraun.lol", Client: "!198.51.100.7"}, 2},
{ExploreFilter{Blocked: "!hagezi"}, 5},
{ExploreFilter{Domain: "adbraun.lol", Name: "!nope.adbraun.lol"}, 3},
} {
if got := total(c.f); got != c.want {
t.Errorf("%+v: %d answers, want %d", c.f, got, c.want)
}
}
// Two clients: their breakdown shows them side by side.
ex, _ = q.Explore(ctx, ExploreFilter{Client: "10.0.0.5,198.51.100.7"}, 10, 50)
if len(ex.By["client"]) != 2 {
t.Errorf("two clients' breakdown %+v", ex.By["client"])
}
// Pruning: past the retention they go.
clk.t = clk.t.Add(8 * 24 * time.Hour)
c.prune(ctx)
if n, _ := store.QueryLogSize(ctx); n != 0 {
t.Errorf("after pruning: %d rows", n)
}
// Off: an error that says how to turn it on.
q.Opts.RetainQueries = 0
if _, err := q.Explore(ctx, ExploreFilter{}, 10, 50); err != ErrNoQueryLog {
t.Errorf("off: %v", err)
}
}
+62
View File
@@ -45,6 +45,21 @@ var schema = []string{
PRIMARY KEY (res, ts, kind, key) PRIMARY KEY (res, ts, kind, key)
) WITHOUT ROWID; ) WITHOUT ROWID;
CREATE TABLE meta (key TEXT PRIMARY KEY, value TEXT NOT NULL);`, CREATE TABLE meta (key TEXT PRIMARY KEY, value TEXT NOT NULL);`,
// 2: the query log, every answer for a while (stats.retention.queries),
// for drilling down: what a client asked, who asked for a name
// (docs/stats.md#exploring).
`CREATE TABLE queries (
ts INTEGER NOT NULL,
client TEXT NOT NULL,
view TEXT NOT NULL,
name TEXT NOT NULL,
type TEXT NOT NULL,
rcode TEXT NOT NULL,
blocked TEXT NOT NULL DEFAULT ''
);
CREATE INDEX queries_ts ON queries (ts);
CREATE INDEX queries_client ON queries (client, ts);
CREATE INDEX queries_name ON queries (name, ts);`,
} }
// OpenStore opens (creating if needed) the statistics database. Use // OpenStore opens (creating if needed) the statistics database. Use
@@ -123,8 +138,25 @@ type Batch struct {
counters []counterRow counters []counterRow
tops []topsWrite tops []topsWrite
meta map[string]string meta map[string]string
queries []QueryRow
} }
// QueryRow is one answer in the query log.
type QueryRow struct {
Time int64 `json:"ts"` // Unix seconds
Client string `json:"client"`
View string `json:"view"` // "_default" without views
Name string `json:"name"`
Type string `json:"type"`
RCode string `json:"rcode"`
Blocked string `json:"blocked,omitempty"` // the blocklist (or "rules") that blocked it
// ClientName is the client's host name (set by the API).
ClientName string `json:"client_name,omitempty"`
}
// AddQueries adds answers to the query log.
func (b *Batch) AddQueries(rows []QueryRow) { b.queries = append(b.queries, rows...) }
type counterRow struct { type counterRow struct {
res string res string
ts int64 ts int64
@@ -220,9 +252,39 @@ func (s *Store) Commit(ctx context.Context, b *Batch) error {
return err return err
} }
} }
if len(b.queries) > 0 {
st, err := tx.PrepareContext(ctx, `INSERT INTO queries (ts, client, view, name, type, rcode, blocked) VALUES (?, ?, ?, ?, ?, ?, ?)`)
if err != nil {
return err
}
for _, q := range b.queries {
if _, err := st.ExecContext(ctx, q.Time, q.Client, q.View, q.Name, q.Type, q.RCode, q.Blocked); err != nil {
st.Close()
return err
}
}
st.Close()
}
return tx.Commit() return tx.Commit()
} }
// PruneQueries deletes the query log's answers older than before (Unix
// time), a slice at a time so the database isn't locked for long.
func (s *Store) PruneQueries(ctx context.Context, before int64) (int64, error) {
var n int64
for {
r, err := s.db.ExecContext(ctx, `DELETE FROM queries WHERE rowid IN (SELECT rowid FROM queries WHERE ts < ? LIMIT 50000)`, before)
if err != nil {
return n, err
}
c, _ := r.RowsAffected()
n += c
if c < 50000 {
return n, nil
}
}
}
// Meta returns a stored value ("" if unset). // Meta returns a stored value ("" if unset).
func (s *Store) Meta(ctx context.Context, key string) (string, error) { func (s *Store) Meta(ctx context.Context, key string) (string, error) {
var v string var v string
+35 -5
View File
@@ -12,7 +12,7 @@ import (
const certCols = `id, name, source, domains, cert_file, key_file, uses, challenge, key_type, comment, cert_pem, key_pem, names, const certCols = `id, name, source, domains, cert_file, key_file, uses, challenge, key_type, comment, cert_pem, key_pem, names,
not_before, not_after, issuer, serial, renew_at, ari_checked_at, last_attempt, last_success, last_error, failures, last_routes, not_before, not_after, issuer, serial, renew_at, ari_checked_at, last_attempt, last_success, last_error, failures, last_routes,
created_at, updated_at, created_by, updated_by` created_at, updated_at, created_by, updated_by, owner_id`
func scanCert(sc interface{ Scan(...any) error }) (*model.Certificate, error) { func scanCert(sc interface{ Scan(...any) error }) (*model.Certificate, error) {
c := &model.Certificate{} c := &model.Certificate{}
@@ -20,7 +20,7 @@ func scanCert(sc interface{ Scan(...any) error }) (*model.Certificate, error) {
var nb, na, ra, ac, la, ls sql.NullString var nb, na, ra, ac, la, ls sql.NullString
if err := sc.Scan(&c.ID, &c.Name, &c.Source, &domains, &c.CertFile, &c.KeyFile, &uses, &c.Challenge, &c.KeyType, &c.Comment, if err := sc.Scan(&c.ID, &c.Name, &c.Source, &domains, &c.CertFile, &c.KeyFile, &uses, &c.Challenge, &c.KeyType, &c.Comment,
&c.CertPEM, &c.KeyPEM, &names, &nb, &na, &c.Issuer, &c.Serial, &ra, &ac, &la, &ls, &c.LastError, &c.Failures, &routes, &c.CertPEM, &c.KeyPEM, &names, &nb, &na, &c.Issuer, &c.Serial, &ra, &ac, &la, &ls, &c.LastError, &c.Failures, &routes,
&ca, &ua, &c.CreatedBy, &c.UpdatedBy); err != nil { &ca, &ua, &c.CreatedBy, &c.UpdatedBy, &c.OwnerID); err != nil {
return nil, err return nil, err
} }
for _, f := range []struct { for _, f := range []struct {
@@ -71,7 +71,7 @@ func (s *Store) CertificateByName(ctx context.Context, name string) (*model.Cert
func (s *Store) CreateCertificate(ctx context.Context, c *model.Certificate) error { func (s *Store) CreateCertificate(ctx context.Context, c *model.Certificate) error {
now := time.Now().UTC() now := time.Now().UTC()
c.CreatedAt, c.UpdatedAt = now, now c.CreatedAt, c.UpdatedAt = now, now
res, err := s.db.ExecContext(ctx, `INSERT INTO certificates (`+certCols[len("id, "):]+`) VALUES (`+placeholders(27)+`)`, certArgs(c)...) res, err := s.db.ExecContext(ctx, `INSERT INTO certificates (`+certCols[len("id, "):]+`) VALUES (`+placeholders(28)+`)`, certArgs(c)...)
if isUnique(err) { if isUnique(err) {
return ErrConflict return ErrConflict
} }
@@ -87,7 +87,7 @@ func (s *Store) UpdateCertificate(ctx context.Context, c *model.Certificate) err
c.UpdatedAt = time.Now().UTC() c.UpdatedAt = time.Now().UTC()
res, err := s.db.ExecContext(ctx, `UPDATE certificates SET name=?, source=?, domains=?, cert_file=?, key_file=?, uses=?, challenge=?, key_type=?, res, err := s.db.ExecContext(ctx, `UPDATE certificates SET name=?, source=?, domains=?, cert_file=?, key_file=?, uses=?, challenge=?, key_type=?,
comment=?, cert_pem=?, key_pem=?, names=?, not_before=?, not_after=?, issuer=?, serial=?, renew_at=?, ari_checked_at=?, last_attempt=?, comment=?, cert_pem=?, key_pem=?, names=?, not_before=?, not_after=?, issuer=?, serial=?, renew_at=?, ari_checked_at=?, last_attempt=?,
last_success=?, last_error=?, failures=?, last_routes=?, created_at=?, updated_at=?, created_by=?, updated_by=? WHERE id=?`, last_success=?, last_error=?, failures=?, last_routes=?, created_at=?, updated_at=?, created_by=?, updated_by=?, owner_id=? WHERE id=?`,
append(certArgs(c), c.ID)...) append(certArgs(c), c.ID)...)
if isUnique(err) { if isUnique(err) {
return ErrConflict return ErrConflict
@@ -123,5 +123,35 @@ func certArgs(c *model.Certificate) []any {
return []any{c.Name, c.Source, list(c.Domains), c.CertFile, c.KeyFile, list(c.Uses), c.Challenge, c.KeyType, c.Comment, return []any{c.Name, c.Source, list(c.Domains), c.CertFile, c.KeyFile, list(c.Uses), c.Challenge, c.KeyType, c.Comment,
c.CertPEM, c.KeyPEM, list(c.Names), tsPtr(c.NotBefore), tsPtr(c.NotAfter), c.Issuer, c.Serial, tsPtr(c.RenewAt), c.CertPEM, c.KeyPEM, list(c.Names), tsPtr(c.NotBefore), tsPtr(c.NotAfter), c.Issuer, c.Serial, tsPtr(c.RenewAt),
tsPtr(c.ARICheckedAt), tsPtr(c.LastAttempt), tsPtr(c.LastSuccess), c.LastError, c.Failures, list(c.LastRoutes), tsPtr(c.ARICheckedAt), tsPtr(c.LastAttempt), tsPtr(c.LastSuccess), c.LastError, c.Failures, list(c.LastRoutes),
ts(c.CreatedAt), ts(c.UpdatedAt), c.CreatedBy, c.UpdatedBy} ts(c.CreatedAt), ts(c.UpdatedAt), c.CreatedBy, c.UpdatedBy, c.OwnerID}
}
// RecordCertificatePull notes a download of a certificate, replacing the
// same downloader's earlier one.
func (s *Store) RecordCertificatePull(ctx context.Context, p *model.CertificatePull) error {
_, err := s.db.ExecContext(ctx, `INSERT INTO certificate_pulls (cert_id, who, user_id, token_id, serial, ip, at) VALUES (?,?,?,?,?,?,?)
ON CONFLICT (cert_id, who) DO UPDATE SET user_id = excluded.user_id, token_id = excluded.token_id, serial = excluded.serial, ip = excluded.ip, at = excluded.at`,
p.CertID, p.Who, p.UserID, p.TokenID, p.Serial, p.IP, ts(p.At))
return err
}
// CertificatePulls returns who downloaded each certificate, newest first,
// by certificate ID.
func (s *Store) CertificatePulls(ctx context.Context) (map[int64][]model.CertificatePull, error) {
rows, err := s.db.QueryContext(ctx, `SELECT cert_id, who, user_id, token_id, serial, ip, at FROM certificate_pulls ORDER BY at DESC`)
if err != nil {
return nil, err
}
out := map[int64][]model.CertificatePull{}
for rows.Next() {
var p model.CertificatePull
var at string
if err := rows.Scan(&p.CertID, &p.Who, &p.UserID, &p.TokenID, &p.Serial, &p.IP, &at); err != nil {
rows.Close()
return nil, err
}
p.At = parseTS(at)
out[p.CertID] = append(out[p.CertID], p)
}
return out, closeRows(rows)
} }
+25
View File
@@ -29,6 +29,15 @@ type Store struct {
// DB exposes the underlying database (for backups and tests). // DB exposes the underlying database (for backups and tests).
func (s *Store) DB() *sql.DB { return s.db } func (s *Store) DB() *sql.DB { return s.db }
// Path returns the database file ("" in memory).
func (s *Store) Path() string {
var file string
if err := s.db.QueryRow(`SELECT file FROM pragma_database_list WHERE name = 'main'`).Scan(&file); err != nil {
return ""
}
return file
}
// Open opens (creating if needed) the database at path and applies // Open opens (creating if needed) the database at path and applies
// migrations. Use ":memory:" for a throwaway in-memory database. // migrations. Use ":memory:" for a throwaway in-memory database.
func Open(path string) (*Store, error) { func Open(path string) (*Store, error) {
@@ -375,6 +384,22 @@ ALTER TABLE zones ADD COLUMN auto_ptr INTEGER NOT NULL DEFAULT 0;
` `
ALTER TABLE views ADD COLUMN reverse_suggestions_off INTEGER NOT NULL DEFAULT 0; ALTER TABLE views ADD COLUMN reverse_suggestions_off INTEGER NOT NULL DEFAULT 0;
ALTER TABLE views ADD COLUMN dismissed_reverse TEXT NOT NULL DEFAULT '[]'; ALTER TABLE views ADD COLUMN dismissed_reverse TEXT NOT NULL DEFAULT '[]';
`,
// 14: certificates for other hosts (docs/certificates.md#certificates-for-other-hosts):
// an owner, a per-user limit, and who downloaded them.
`
ALTER TABLE certificates ADD COLUMN owner_id INTEGER NOT NULL DEFAULT 0;
ALTER TABLE users ADD COLUMN cert_limit INTEGER;
CREATE TABLE certificate_pulls (
cert_id INTEGER NOT NULL REFERENCES certificates(id) ON DELETE CASCADE,
who TEXT NOT NULL,
user_id INTEGER NOT NULL DEFAULT 0,
token_id INTEGER NOT NULL DEFAULT 0,
serial TEXT NOT NULL DEFAULT '',
ip TEXT NOT NULL DEFAULT '',
at TEXT NOT NULL,
PRIMARY KEY (cert_id, who)
);
`, `,
} }
+11 -6
View File
@@ -18,16 +18,21 @@ func isUnique(err error) bool {
return err != nil && strings.Contains(err.Error(), "UNIQUE constraint failed") return err != nil && strings.Contains(err.Error(), "UNIQUE constraint failed")
} }
const userCols = `id, username, password_hash, admin, grants, disabled, comment, created_at, updated_at, last_login_at, totp_secret, totp_pending, totp_enabled_at, totp_last_step` const userCols = `id, username, password_hash, admin, grants, disabled, comment, created_at, updated_at, last_login_at, totp_secret, totp_pending, totp_enabled_at, totp_last_step, cert_limit`
func scanUser(sc interface{ Scan(...any) error }) (*model.User, error) { func scanUser(sc interface{ Scan(...any) error }) (*model.User, error) {
u := &model.User{} u := &model.User{}
var grants, ca, ua string var grants, ca, ua string
var admin, disabled int var admin, disabled int
var ll, te sql.NullString var ll, te sql.NullString
if err := sc.Scan(&u.ID, &u.Username, &u.PasswordHash, &admin, &grants, &disabled, &u.Comment, &ca, &ua, &ll, &u.TOTPSecret, &u.TOTPPending, &te, &u.TOTPLastStep); err != nil { var limit sql.NullInt64
if err := sc.Scan(&u.ID, &u.Username, &u.PasswordHash, &admin, &grants, &disabled, &u.Comment, &ca, &ua, &ll, &u.TOTPSecret, &u.TOTPPending, &te, &u.TOTPLastStep, &limit); err != nil {
return nil, err return nil, err
} }
if limit.Valid {
n := int(limit.Int64)
u.CertLimit = &n
}
u.Admin, u.Disabled = admin != 0, disabled != 0 u.Admin, u.Disabled = admin != 0, disabled != 0
u.HasPassword = u.PasswordHash != "" u.HasPassword = u.PasswordHash != ""
if err := json.Unmarshal([]byte(grants), &u.Grants); err != nil { if err := json.Unmarshal([]byte(grants), &u.Grants); err != nil {
@@ -48,8 +53,8 @@ func (s *Store) CreateUser(ctx context.Context, u *model.User) error {
if u.Grants == nil { if u.Grants == nil {
u.Grants = []model.Grant{} u.Grants = []model.Grant{}
} }
res, err := s.db.ExecContext(ctx, `INSERT INTO users (username, password_hash, admin, grants, disabled, comment, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?)`, res, err := s.db.ExecContext(ctx, `INSERT INTO users (username, password_hash, admin, grants, disabled, comment, created_at, updated_at, cert_limit) VALUES (?,?,?,?,?,?,?,?,?)`,
u.Username, u.PasswordHash, boolInt(u.Admin), jsonText(u.Grants), boolInt(u.Disabled), u.Comment, ts(now), ts(now)) u.Username, u.PasswordHash, boolInt(u.Admin), jsonText(u.Grants), boolInt(u.Disabled), u.Comment, ts(now), ts(now), u.CertLimit)
if isUnique(err) { if isUnique(err) {
return ErrConflict return ErrConflict
} }
@@ -64,8 +69,8 @@ func (s *Store) CreateUser(ctx context.Context, u *model.User) error {
// UpdateUser saves a user. // UpdateUser saves a user.
func (s *Store) UpdateUser(ctx context.Context, u *model.User) error { func (s *Store) UpdateUser(ctx context.Context, u *model.User) error {
u.UpdatedAt = time.Now() u.UpdatedAt = time.Now()
res, err := s.db.ExecContext(ctx, `UPDATE users SET username = ?, password_hash = ?, admin = ?, grants = ?, disabled = ?, comment = ?, updated_at = ? WHERE id = ?`, res, err := s.db.ExecContext(ctx, `UPDATE users SET username = ?, password_hash = ?, admin = ?, grants = ?, disabled = ?, comment = ?, updated_at = ?, cert_limit = ? WHERE id = ?`,
u.Username, u.PasswordHash, boolInt(u.Admin), jsonText(u.Grants), boolInt(u.Disabled), u.Comment, ts(u.UpdatedAt), u.ID) u.Username, u.PasswordHash, boolInt(u.Admin), jsonText(u.Grants), boolInt(u.Disabled), u.Comment, ts(u.UpdatedAt), u.CertLimit, u.ID)
if isUnique(err) { if isUnique(err) {
return ErrConflict return ErrConflict
} }
+13 -5
View File
@@ -186,7 +186,7 @@ func (c *checker) checkZoneRecords(z *model.Zone) {
for n := range ix.types { for n := range ix.types {
names = append(names, n) names = append(names, n)
} }
sort.Slice(names, func(i, j int) bool { return model.CanonicalLess(names[i], names[j]) }) sort.Slice(names, func(i, j int) bool { return model.NameLess(names[i], names[j]) })
for _, name := range names { for _, name := range names {
types := ix.types[name] types := ix.types[name]
@@ -385,8 +385,16 @@ func (c *checker) checkCNAMETarget(z *model.Zone, ix *zoneIndex, r *model.Record
if !ix.inZoneTarget(target) { if !ix.inZoneTarget(target) {
return return
} }
// Follow the chain inside the zone to detect loops. // Follow the chain inside the zone to detect loops. One CNAME to
// another is common and costs nothing (named answers the chain in
// one response); a longer chain gets a note.
seen := map[string]bool{r.Name: true} seen := map[string]bool{r.Name: true}
chain := []string{disp(r.Name), disp(target)}
note := func() {
if len(chain) > 3 {
c.recProblem(z, r, Info, "cname_chain", "%s reaches its address through %d CNAMEs (%s); fine, but each one is another lookup for resolvers that don't get the chain at once", disp(r.Name), len(chain)-1, strings.Join(chain, " → "))
}
}
cur := target cur := target
for hops := 0; hops < 20; hops++ { for hops := 0; hops < 20; hops++ {
if seen[cur] { if seen[cur] {
@@ -398,18 +406,18 @@ func (c *checker) checkCNAMETarget(z *model.Zone, ix *zoneIndex, r *model.Record
if len(next) == 0 { if len(next) == 0 {
break break
} }
if hops == 0 {
c.recProblem(z, r, Warning, "cname_chain", "%s is a CNAME to %s, which is itself a CNAME; chains slow down resolution", disp(r.Name), disp(target))
}
p := ix.parsed[next[0].ID] p := ix.parsed[next[0].ID]
if p == nil { if p == nil {
break break
} }
cur = p.RR.(*dns.CNAME).Target cur = p.RR.(*dns.CNAME).Target
chain = append(chain, disp(cur))
if !ix.inZoneTarget(cur) { if !ix.inZoneTarget(cur) {
note()
return return
} }
} }
note()
if len(ix.byName[target]) == 0 && !ix.hasAddress(target) && !hasDescendant(ix, target) { if len(ix.byName[target]) == 0 && !ix.hasAddress(target) && !hasDescendant(ix, target) {
c.recProblem(z, r, Warning, "cname_dangling", "%s is a CNAME to %s, which does not exist in the zone", disp(r.Name), disp(target)) c.recProblem(z, r, Warning, "cname_dangling", "%s is a CNAME to %s, which does not exist in the zone", disp(r.Name), disp(target))
} }
+1 -1
View File
@@ -189,7 +189,7 @@ func sortProblems(ps []Problem) {
return a.Zone < b.Zone return a.Zone < b.Zone
} }
if a.Name != b.Name { if a.Name != b.Name {
return model.CanonicalLess(a.Name, b.Name) return model.NameLess(a.Name, b.Name)
} }
return a.Message < b.Message return a.Message < b.Message
}) })
+16 -2
View File
@@ -161,8 +161,9 @@ func TestRecordConflicts(t *testing.T) {
}, "cname_loop", Error}, }, "cname_loop", Error},
{"cname chain", func(b *builder, z *model.Zone) { {"cname chain", func(b *builder, z *model.Zone) {
b.rec(z, "a", "CNAME", "b") b.rec(z, "a", "CNAME", "b")
b.rec(z, "b", "CNAME", "ns1") b.rec(z, "b", "CNAME", "c")
}, "cname_chain", Warning}, b.rec(z, "c", "CNAME", "ns1")
}, "cname_chain", Info},
{"dangling cname", func(b *builder, z *model.Zone) { b.rec(z, "a", "CNAME", "nothing") }, "cname_dangling", Warning}, {"dangling cname", func(b *builder, z *model.Zone) { b.rec(z, "a", "CNAME", "nothing") }, "cname_dangling", Warning},
{"null mx not alone", func(b *builder, z *model.Zone) { {"null mx not alone", func(b *builder, z *model.Zone) {
b.rec(z, "@", "MX", "0 .") b.rec(z, "@", "MX", "0 .")
@@ -380,3 +381,16 @@ func TestProblemStringDisplaysZone(t *testing.T) {
t.Errorf("String() = %q, want %q", got, want) t.Errorf("String() = %q, want %q", got, want)
} }
} }
// One CNAME to another is common and says nothing.
func TestShortCNAMEChain(t *testing.T) {
b := newBuilder()
z := b.primary("example.com", nil)
b.rec(z, "songshare", "CNAME", "navidrome")
b.rec(z, "navidrome", "CNAME", "ns1")
for _, p := range State(b.s, Options{}) {
if p.Code == "cname_chain" {
t.Errorf("one extra CNAME: %s %s", p.Severity, p.Message)
}
}
}
+2
View File
@@ -17,6 +17,7 @@ import * as users from "./pages/users.js";
import * as account from "./pages/account.js"; import * as account from "./pages/account.js";
import * as search from "./pages/search.js"; import * as search from "./pages/search.js";
import * as stats from "./pages/stats.js"; import * as stats from "./pages/stats.js";
import * as explore from "./pages/explore.js";
import * as blocking from "./pages/blocking.js"; import * as blocking from "./pages/blocking.js";
import * as exposure from "./pages/exposure.js"; import * as exposure from "./pages/exposure.js";
import * as certificates from "./pages/certificates.js"; import * as certificates from "./pages/certificates.js";
@@ -39,6 +40,7 @@ const routes = [
{ pattern: /^account$/, page: account, nav: "account" }, { pattern: /^account$/, page: account, nav: "account" },
{ pattern: /^search$/, page: search, nav: "" }, { pattern: /^search$/, page: search, nav: "" },
{ pattern: /^stats$/, page: stats, nav: "stats" }, { pattern: /^stats$/, page: stats, nav: "stats" },
{ pattern: /^stats\/explore$/, page: explore, nav: "stats" },
{ pattern: /^blocking$/, page: blocking, nav: "blocking" }, { pattern: /^blocking$/, page: blocking, nav: "blocking" },
{ pattern: /^exposure$/, page: exposure, nav: "exposure" }, { pattern: /^exposure$/, page: exposure, nav: "exposure" },
{ pattern: /^certificates$/, page: certificates, nav: "certificates" }, { pattern: /^certificates$/, page: certificates, nav: "certificates" },
+6 -4
View File
@@ -283,7 +283,7 @@ export function heatmap(hm, unit) {
const n = hm.samples[d][hr]; const n = hm.samples[d][hr];
const text = v < 0 ? day + " " + pad(hr) + ":00 – no data" : const text = v < 0 ? day + " " + pad(hr) + ":00 – no data" :
day + " " + pad(hr) + ":00 – " + Math.round(v).toLocaleString() + " " + unit + " per hour (average of " + n + (n === 1 ? " hour)" : " hours)"); day + " " + pad(hr) + ":00 – " + Math.round(v).toLocaleString() + " " + unit + " per hour (average of " + n + (n === 1 ? " hour)" : " hours)");
const cell = h("div", { class: "hm-cell " + (v < 0 ? "empty" : "heat-" + level(v)), tabindex: "-1", "aria-label": text }); const cell = h("div", { class: "hm-cell " + (v < 0 ? "hm-empty" : "heat-" + level(v)), tabindex: "-1", "aria-label": text });
const showTip = () => { const showTip = () => {
tip.textContent = text; tip.textContent = text;
tip.hidden = false; tip.hidden = false;
@@ -313,7 +313,7 @@ export function heatmap(hm, unit) {
h("span", { class: "muted small" }, "0"), h("span", { class: "muted small" }, "0"),
Array.from({ length: steps }, (_, i) => h("span", { class: "hm-swatch heat-" + i })), Array.from({ length: steps }, (_, i) => h("span", { class: "hm-swatch heat-" + i })),
h("span", { class: "muted small" }, compact(peak) + " " + unit + "/hour"), h("span", { class: "muted small" }, compact(peak) + " " + unit + "/hour"),
h("span", { class: "hm-swatch empty" }), h("span", { class: "muted small" }, "no data")); h("span", { class: "hm-swatch hm-empty" }), h("span", { class: "muted small" }, "no data"));
return h("div", { class: "heatmap-wrap" }, grid, tip, scale); return h("div", { class: "heatmap-wrap" }, grid, tip, scale);
} }
@@ -329,7 +329,9 @@ export function heatmapTable(hm, unit) {
// barList shows ranked entries as labelled horizontal bars in one hue // barList shows ranked entries as labelled horizontal bars in one hue
// (one series: no legend, the title names it). Values are always printed, // (one series: no legend, the title names it). Values are always printed,
// so the list is its own table view. // so the list is its own table view.
export function barList(entries, { total, label = (k) => k, empty = "No data yet.", href } = {}) { // extra(key), when given, adds something after an entry's label (e.g. a
// link that excludes it).
export function barList(entries, { total, label = (k) => k, empty = "No data yet.", href, extra } = {}) {
if (!entries.length) return h("div", { class: "empty" }, empty); if (!entries.length) return h("div", { class: "empty" }, empty);
// Entries may be in a natural order (e.g. latency buckets), not by size. // Entries may be in a natural order (e.g. latency buckets), not by size.
const peak = Math.max(1, ...entries.map((e) => e.count)); const peak = Math.max(1, ...entries.map((e) => e.count));
@@ -343,7 +345,7 @@ export function barList(entries, { total, label = (k) => k, empty = "No data yet
return h("li", { title: (e.name ? e.name + " (" + name + ")" : name) + ": " + e.count.toLocaleString() + " (" + share.toFixed(1) + "%)" }, return h("li", { title: (e.name ? e.name + " (" + name + ")" : name) + ": " + e.count.toLocaleString() + " (" + share.toFixed(1) + "%)" },
h("div", { class: "bar-text" }, h("div", { class: "bar-text" },
link ? h("a", { href: link, class: "bar-label" }, shown) : h("span", { class: "bar-label" }, shown), link ? h("a", { href: link, class: "bar-label" }, shown) : h("span", { class: "bar-label" }, shown),
h("span", { class: "bar-value" }, compact(e.count), h("span", { class: "muted" }, " · " + share.toFixed(share < 10 ? 1 : 0) + "%"))), h("span", { class: "bar-value" }, extra ? [extra(e.key), " "] : null, compact(e.count), h("span", { class: "muted" }, " · " + share.toFixed(share < 10 ? 1 : 0) + "%"))),
h("div", { class: "bar-track" }, h("div", { class: "bar-fill", style: { width: Math.max(0.5, (e.count / peak) * 100) + "%" } }))); h("div", { class: "bar-track" }, h("div", { class: "bar-fill", style: { width: Math.max(0.5, (e.count / peak) * 100) + "%" } })));
})); }));
} }
+1 -1
View File
@@ -9,7 +9,7 @@ export async function render(el) {
const tokensEl = h("div"); const tokensEl = h("div");
const drawTokens = async () => { const drawTokens = async () => {
const tokens = await get("/me/tokens"); const tokens = await get("/me/tokens");
mount(tokensEl, tokenTable(tokens, drawTokens), newTokenForm("/me/tokens", grants, drawTokens)); mount(tokensEl, tokenTable(tokens, drawTokens), newTokenForm("/me/tokens", grants, drawTokens, { admin: me.admin, grants: me.user.grants }));
}; };
const cur = input({ type: "password", autocomplete: "current-password" }); const cur = input({ type: "password", autocomplete: "current-password" });
const pw1 = input({ type: "password", autocomplete: "new-password" }); const pw1 = input({ type: "password", autocomplete: "new-password" });
@@ -32,9 +32,10 @@ export async function render(el) {
{ label: "Name", render: (c) => h("a", { href: "#", onclick: (e) => { e.preventDefault(); details(c); } }, h("strong", null, c.name)) }, { label: "Name", render: (c) => h("a", { href: "#", onclick: (e) => { e.preventDefault(); details(c); } }, h("strong", null, c.name)) },
{ label: "Names", render: (c) => h("div", { class: "mono small" }, (c.names && c.names.length ? c.names : c.domains).map((n) => h("div", null, n))) }, { label: "Names", render: (c) => h("div", { class: "mono small" }, (c.names && c.names.length ? c.names : c.domains).map((n) => h("div", null, n))) },
{ label: "Used for", render: (c) => [ { label: "Used for", render: (c) => [
c.uses.includes("web") ? badge(c.serving ? "web UI (serving)" : "web UI", c.serving ? "ok" : "") : null, " ", c.uses.includes("web") ? [badge(c.serving ? "web UI (serving)" : "web UI", c.serving ? "ok" : ""), " "] : null,
c.uses.includes("dns") ? badge("named", "") : null, c.uses.includes("dns") ? namedUse(c) : null,
!c.uses.length ? h("span", { class: "muted" }, "nothing yet") : null] }, !c.uses.length ? h("span", { class: "muted", title: "Export it to use it in another program" }, "not used by Jarl") : null] },
{ label: "Owner", render: (c) => c.owner ? h("span", null, c.owner, pulledBy(c)) : h("span", { class: "muted" }, "Jarl") },
{ label: "Source", render: (c) => c.source === "acme" ? h("span", null, "Jarl", h("div", { class: "small muted" }, "challenge " + c.challenge)) : h("span", null, "your files", h("div", { class: "small muted mono" }, c.cert_file)) }, { label: "Source", render: (c) => c.source === "acme" ? h("span", null, "Jarl", h("div", { class: "small muted" }, "challenge " + c.challenge)) : h("span", null, "your files", h("div", { class: "small muted mono" }, c.cert_file)) },
{ label: "Expires", render: (c) => c.not_after ? h("span", null, time(c.not_after), c.source === "acme" && c.next_attempt ? h("div", { class: "small muted" }, "renews " + fmtTime(c.next_attempt)) : null) : h("span", { class: "muted" }, "–") }, { label: "Expires", render: (c) => c.not_after ? h("span", null, time(c.not_after), c.source === "acme" && c.next_attempt ? h("div", { class: "small muted" }, "renews " + fmtTime(c.next_attempt)) : null) : h("span", { class: "muted" }, "–") },
{ label: "Status", render: (c) => { { label: "Status", render: (c) => {
@@ -44,6 +45,7 @@ export async function render(el) {
{ label: "", render: (c) => h("span", null, { label: "", render: (c) => h("span", null,
h("button", { class: "small", disabled: !!c.issuing_since, title: c.source === "acme" ? "Obtain a new certificate now" : "Reread the files now", onclick: () => issue(c) }, c.source === "acme" ? "Renew" : "Reread"), " ", h("button", { class: "small", disabled: !!c.issuing_since, title: c.source === "acme" ? "Obtain a new certificate now" : "Reread the files now", onclick: () => issue(c) }, c.source === "acme" ? "Renew" : "Reread"), " ",
h("button", { class: "small", onclick: () => editDialog(c, a) }, "Edit"), " ", h("button", { class: "small", onclick: () => editDialog(c, a) }, "Edit"), " ",
c.not_after ? [h("button", { class: "small", title: "Download it to use in another program", onclick: () => exportDialog(c) }, "Export…"), " "] : null,
h("button", { class: "small danger", onclick: () => del(c) }, "Delete")), tdClass: "actions-cell" }, h("button", { class: "small danger", onclick: () => del(c) }, "Delete")), tdClass: "actions-cell" },
], r.certificates, { empty: "No certificates yet." }))); ], r.certificates, { empty: "No certificates yet." })));
@@ -63,6 +65,24 @@ export async function render(el) {
} }
} }
// pulledBy says who downloaded a certificate for another host, and
// whether they have the current one.
function pulledBy(c) {
const ps = c.pulled_by || [];
if (!ps.length) return h("div", { class: "small muted" }, "not downloaded yet");
return ps.map((p) => h("div", { class: "small " + (p.serial === c.serial ? "muted" : "warn-text"), title: p.who + " from " + (p.ip || "?") + ", " + fmtTime(p.at) },
p.who.replace(/^token /, "") + ", " + fmtTime(p.at) + (p.serial === c.serial ? "" : " (old: not fetched since the renewal)")));
}
// namedUse is what named uses a certificate for: DoT and DoH, with the
// addresses in the tooltip (the details list them).
function namedUse(c) {
const uses = c.named_use || [];
if (!uses.length) return h("span", { class: "muted", title: "Encrypted DNS sets up named's DNS over TLS and HTTPS" }, "named: not set up");
const kinds = [["DNS over TLS", "DoT"], ["DNS over HTTPS", "DoH"]].filter(([long]) => uses.some((u) => u.startsWith(long)));
return h("span", { title: uses.join("\n") }, kinds.map(([, short]) => [badge(short, ""), " "]));
}
function details(c) { function details(c) {
const rows = [ const rows = [
["Source", c.source === "acme" ? "Jarl obtains and renews it (" + c.key_type + " key)" : "your files: " + c.cert_file + ", " + c.key_file], ["Source", c.source === "acme" ? "Jarl obtains and renews it (" + c.key_type + " key)" : "your files: " + c.cert_file + ", " + c.key_file],
@@ -79,10 +99,67 @@ function details(c) {
modal("Certificate " + c.name, h("div", null, modal("Certificate " + c.name, h("div", null,
h("dl", { class: "kv" }, rows.map(([k, v]) => [h("dt", null, k), h("dd", null, v)])), h("dl", { class: "kv" }, rows.map(([k, v]) => [h("dt", null, k), h("dd", null, v)])),
c.files ? h("div", null, h("h3", null, "For named"), c.files ? h("div", null, h("h3", null, "For named"),
(c.named_use || []).length ? h("ul", { class: "small" }, c.named_use.map((u) => h("li", null, u))) : h("p", { class: "small muted" }, "named doesn't use it yet (Encrypted DNS sets that up)."),
h("p", { class: "small muted" }, "Point a tls block in named's configuration at these files; Jarl rewrites them on renewal and has named reread them."), h("p", { class: "small muted" }, "Point a tls block in named's configuration at these files; Jarl rewrites them on renewal and has named reread them."),
copyBox(c.files.chain), copyBox(c.files.key)) : null), [{ label: "Close", value: null }], { wide: true }); copyBox(c.files.chain), copyBox(c.files.key)) : null), [{ label: "Close", value: null }], { wide: true });
} }
// exportDialog downloads a certificate's files to use elsewhere.
function exportDialog(c) {
const parts = [
["fullchain", "fullchain.pem", "the certificate and its chain: what most programs want"],
["cert", "cert.pem", "the certificate alone"],
["key", "privkey.pem", "the private key"],
["bundle", "bundle.pem", "the key, then the chain, in one file (HAProxy)"],
];
modal("Export certificate " + c.name, h("div", null,
c.source === "acme"
? h("p", null, "For a program outside Jarl (a mail server, a reverse proxy). Jarl renews the certificate, but not your copy: export it again after each renewal",
c.next_attempt ? " (next: " + fmtTime(c.next_attempt) + ")" : "", ", or have the program read the files Jarl writes for named (use it for named).")
: h("p", null, "For a program outside Jarl (a mail server, a reverse proxy). You renew this one yourself, so a program on this server can read your files directly: ", h("code", null, c.cert_file), "."),
h("table", { class: "small" }, parts.map(([part, file, what]) => h("tr", null,
h("td", null, h("button", { class: "small" + (part === "fullchain" ? " primary" : ""), title: "Download " + file, onclick: () => download(c, part, file) }, file), " ",
h("button", { class: "small", title: "Copy " + file + " to the clipboard", onclick: () => copyPEM(c, part, file) }, "Copy")),
h("td", { class: part === "key" || part === "bundle" ? "" : "muted" }, what, part === "key" || part === "bundle" ? h("span", { class: "muted" }, " (audited)") : null)))),
h("p", { class: "small muted" }, "CLI: ", h("code", null, "jarl cert export " + c.name + " --dir DIR"))), [{ label: "Close", value: null }]);
}
// fetchPEM gets one part of a certificate, after asking when it holds the
// private key (verb: "Download" or "Copy"). null when not confirmed.
async function fetchPEM(c, part, verb) {
if ((part === "key" || part === "bundle") && !await confirmDialog(verb + " the private key",
"Whoever has the key of " + c.name + " can pose as " + (c.names || c.domains || []).join(", ") + " until the certificate expires. Keep it as safe as the server keeps it" +
(verb === "Copy" ? ", and clear it from the clipboard when you're done (clipboard managers keep a history)" : "") + ". This is recorded in the audit log.", verb)) return null;
const resp = await fetch("/api/v1/certificates/" + encodeURIComponent(c.name) + "/pem?part=" + part, { credentials: "same-origin" });
if (!resp.ok) {
let msg = "HTTP " + resp.status;
try { msg = (await resp.json()).error.message; } catch { /* not JSON */ }
throw new Error(msg);
}
return resp.text();
}
async function copyPEM(c, part, file) {
try {
const text = await fetchPEM(c, part, "Copy");
if (text === null) return;
if (!navigator.clipboard) throw new Error("the browser allows copying only over HTTPS (or from localhost); download it instead");
await navigator.clipboard.writeText(text);
toast("Copied " + file);
} catch (e) { toast("Copy failed: " + e.message, "err", 8000); }
}
async function download(c, part, file) {
try {
const text = await fetchPEM(c, part, "Download");
if (text === null) return;
const a = h("a", { href: URL.createObjectURL(new Blob([text], { type: "application/x-pem-file" })), download: c.name + "-" + file });
document.body.append(a);
a.click();
a.remove();
} catch (e) { toast(e.message, "err", 8000); }
}
async function issue(c) { async function issue(c) {
try { try {
const r = await api("POST", "/certificates/" + encodeURIComponent(c.name) + "/issue", { body: {} }); const r = await api("POST", "/certificates/" + encodeURIComponent(c.name) + "/issue", { body: {} });
@@ -18,6 +18,7 @@ export async function render(el) {
st.backup ? backupCard(st.backup) : null, st.backup ? backupCard(st.backup) : null,
exposure ? exposureCard(exposure) : null, exposure ? exposureCard(exposure) : null,
st.certificates ? certCard(st.certificates) : null, st.certificates ? certCard(st.certificates) : null,
st.storage ? storageCard(st.storage) : null,
st.encrypted_dns ? h("a", { class: "card link", href: "#/encrypted-dns" }, h("div", { class: "label" }, "Encrypted DNS"), st.encrypted_dns ? h("a", { class: "card link", href: "#/encrypted-dns" }, h("div", { class: "label" }, "Encrypted DNS"),
h("div", { class: "value" }, st.encrypted_dns.ok ? badge("working", "ok") : badge("not working", "err")), h("div", { class: "value" }, st.encrypted_dns.ok ? badge("working", "ok") : badge("not working", "err")),
h("div", { class: "sub" }, "checked ", time(st.encrypted_dns.checked))) : null, h("div", { class: "sub" }, "checked ", time(st.encrypted_dns.checked))) : null,
@@ -100,6 +101,7 @@ export async function render(el) {
h("h2", null, "Configuration checks"), h("h2", null, "Configuration checks"),
real.length ? problemsList(real) : h("div", { class: "msg ok" }, "No problems found in the current configuration."), real.length ? problemsList(real) : h("div", { class: "msg ok" }, "No problems found in the current configuration."),
problems.length > real.length ? h("details", null, h("summary", { class: "muted" }, plural(problems.length - real.length, "note")), problemsList(problems.filter((p) => p.severity === "info"))) : null)); problems.length > real.length ? h("details", null, h("summary", { class: "muted" }, plural(problems.length - real.length, "note")), problemsList(problems.filter((p) => p.severity === "info"))) : null));
if (st.storage) parts.push(storagePanel(st.storage));
if (s.paths) { if (s.paths) {
parts.push(h("div", { class: "panel" }, h("h2", null, "Files"), h("dl", { class: "kv" }, parts.push(h("div", { class: "panel" }, h("h2", null, "Files"), h("dl", { class: "kv" },
h("dt", null, "named's main config"), h("dd", { class: "mono" }, s.main_config || "–"), h("dt", null, "named's main config"), h("dd", { class: "mono" }, s.main_config || "–"),
@@ -140,3 +142,52 @@ function certCard(c) {
function card(label, value, sub) { function card(label, value, sub) {
return h("div", { class: "card" }, h("div", { class: "label" }, label), h("div", { class: "value" }, value), sub ? h("div", { class: "sub" }, sub) : null); return h("div", { class: "card" }, h("div", { class: "label" }, label), h("div", { class: "value" }, value), sub ? h("div", { class: "sub" }, sub) : null);
} }
// storageCard: the databases' size, and the fullest disk Jarl uses.
function storageCard(sg) {
const fullest = sg.disks.reduce((a, d) => (!a || used(d) > used(a) ? d : a), null);
const value = h("span", null, bytes(sg.total));
const sub = fullest ? [h("span", { class: used(fullest) > 0.9 ? "warn-text" : "" }, Math.round(used(fullest) * 100) + "% full"), " · " + fullest.path] : "databases";
return h("a", { class: "card link", href: "#storage" , onclick: (e) => { e.preventDefault(); document.getElementById("storage")?.scrollIntoView({ behavior: "smooth" }); } },
h("div", { class: "label" }, "Databases"), h("div", { class: "value" }, value), h("div", { class: "sub" }, sub));
}
// storagePanel shows each database's size and, per disk Jarl uses, a bar:
// the databases, everything else, and what is free.
function storagePanel(sg) {
const dbRows = sg.databases.map((d) => [h("dt", null, d.label),
h("dd", null, h("strong", null, bytes(d.bytes)), " ", h("span", { class: "mono small muted" }, d.path),
d.query_log_rows != null ? h("div", { class: "small muted" }, "query log: " + d.query_log_rows.toLocaleString() + (d.query_log_rows === 1 ? " answer" : " answers") + " so far, each kept for " + retentionWords(d.query_log_retention) + " (stats.retention.queries)") : null)]);
return h("div", { class: "panel", id: "storage" },
h("h2", null, "Storage"),
h("dl", { class: "kv" }, dbRows, h("dt", null, "Together"), h("dd", null, h("strong", null, bytes(sg.total)))),
sg.disks.map((d) => {
const other = Math.max(0, d.total - d.free - d.databases);
const pctOf = (n) => (d.total ? (n / d.total) * 100 : 0) + "%";
const u = used(d);
return h("div", { class: "disk" },
h("div", { class: "disk-head" }, h("span", { class: "mono" }, d.path), h("span", { class: "muted small" }, " · " + d.holds.join(", ")),
h("span", { class: "grow" }), h("span", { class: u > 0.9 ? "warn-text" : "muted" }, bytes(d.free) + " free of " + bytes(d.total) + " (" + Math.round(u * 100) + "% used)")),
h("div", { class: "disk-bar", role: "img", "aria-label": Math.round(u * 100) + "% used, Jarl's databases " + bytes(d.databases) },
d.databases ? h("div", { class: "seg db", title: "Jarl's databases: " + bytes(d.databases), style: { width: pctOf(d.databases) } }) : null,
h("div", { class: "seg other" + (u > 0.9 ? " full" : ""), title: "Everything else: " + bytes(other), style: { width: pctOf(other) } })),
d.databases ? h("div", { class: "small muted" }, h("span", { class: "swatch db" }), " Jarl's databases " + bytes(d.databases), " ", h("span", { class: "swatch other" }), " everything else") : null);
}));
}
function used(d) { return d.total ? (d.total - d.free) / d.total : 0; }
function bytes(n) {
const units = ["B", "KB", "MB", "GB", "TB"];
let i = 0;
while (n >= 1024 && i < units.length - 1) { n /= 1024; i++; }
return (n >= 100 || i === 0 ? Math.round(n) : n.toFixed(1)) + " " + units[i];
}
// retentionWords: "7d" -> "7 days".
function retentionWords(r) {
const m = /^(\d+)([dhm])$/.exec(r || "");
if (!m) return r || "a while";
const word = { d: "day", h: "hour", m: "minute" }[m[2]];
return m[1] + " " + word + (m[1] === "1" ? "" : "s");
}
Loaded 100 of 109 files, more files were not shown because too many files have changed in this diff. Show more