Compare 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
94 changed files with 4602 additions and 464 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.
+235
View File
@@ -8,6 +8,238 @@ All notable changes to Jarl are documented here. The format follows
### Added
- **Failed logins in the journal.** A failed login (a wrong password, or
a wrong two-factor code) and a refused API token are logged as well as
audited, with the address first (`msg="login failed" ip=… user=…`,
`msg="token refused" ip=…`), for Guard's `jarl` preset or a fail2ban
filter to ban such addresses (docs/operations.md).
- **`jarl cert get --combined PATH`** also writes the key and the full
chain in one file (mode 600, written all at once), for HAProxy. It
belongs outside `--dir`, so a directory of combined files can be
HAProxy's `crt` directory. The hook gets it as `JARL_CERT_COMBINED`.
Adding it to a certificate this host already has writes it from the
files on disk and runs the hook; `--combined ''` removes it.
- **`jarl cert get --local`** doesn't ask jarld: it writes the combined
file and runs the hook from this host's files, to apply a changed
setting or test a hook.
- **`jarl cert saved NAME`** shows all of one certificate's saved
settings (names, key type, dir, combined file, hook, server) and this
host's copy. The token is never shown.
- `jarl cert list` has a PULLED column: when the certificate was last
downloaded, with `(old)` while a host hasn't fetched the renewed one.
The owner of a certificate obtained with `jarl cert get` now sees their
own downloads (`pulled_by`) in `jarl cert list` and `jarl cert show`, not
only administrators.
### Changed
- `jarl cert list` shows `get` as USES for a certificate obtained with
`jarl cert get`, not `-`.
- `jarl cert get` without a name refuses `--dir`, `--hook`, `--key-type`,
`--combined`, `--replace` and `--no-save`, which change one
certificate; they used to be ignored.
## [0.6.3] - 2026-10-21
### Added
- A docs/acme.md section: lego on a host inside a split DNS fails its own
propagation check when the challenge is written into the external view
only (`JARL_VIEW`, a one-view token, `.../httpreq/view/...`): why, and
the ways round it (no view limit, `--dns.propagation.disable-ans` or
`--dns.propagation.wait`, Traefik's `propagation.disableANSChecks`).
- docs/acme.md and `jarl-lego` show lego 5's commands (`lego run ...`,
which also renews; `lego migrate` when upgrading), with lego 4's
beside them. Jarl's side is the same for both.
## [0.6.2] - 2026-10-21
### Added
- A `certificates` role (template): `certificates:write`, for tokens and
users that get certificates for other hosts with `jarl cert get`. In
the web UI's user and token forms it is in "Insert a template…", to
narrow with `names=`; in the CLI, `--role certificates`.
## [0.6.1] - 2026-10-20
### Added
- Certificates for other hosts: `jarl cert get NAME -d DOMAIN --dir DIR
--hook CMD` on a mail server or proxy has jarld obtain the certificate
(and renew it), waits, downloads it when it is newer than the host's
copy, and runs the hook with the file paths in `JARL_CERT_*`
variables. Its settings are saved, so `jarl cert get` alone refreshes
them all; `jarl cert schedule` writes a daily systemd timer for that;
`jarl cert saved` lists them with their expiry; `jarl cert forget`
stops one. A certificate belongs to the user that asked for it (other
users can't take the name), is obtained again only when a name is
added or removed, and the Certificates page shows its owner and who
downloaded it, flagging hosts that haven't fetched a renewal. A new
`certificates` grant (`certificates:write names=*.adbraun.lol`) says
which names a user may have; it is never part of `*`. Users may own
`certificates.max_per_user` certificates (10), or their own
`--cert-limit`. API: `POST /certificates/{name}/request`.
- Certificates can be exported for use outside Jarl: **Export…** on the
Certificates page (download, or copy to the clipboard),
`jarl cert export NAME [--dir DIR | --part PART]`
and `GET /certificates/{name}/pem?part=fullchain|cert|key|bundle`.
Administrators only; every export of the private key is audited
(`certificate.export_key`).
- A reverse zone's "addresses have no PTR record" banner lists them, with
the name each would get, instead of only counting them
(`ptr_missing_list` in the zone API).
- A zone's page links to the same zone in the other views ("also in
external"), in each view's colour, keeping the tab you are on. It
works for zones that aren't linked too, and the zone API's new
`in_views` lists them.
- `jarl.service` has the alias `jarld.service`, so `systemctl restart
jarld` works too. The alias is linked when the unit is enabled; the
package relinks a unit enabled by an older version on upgrade.
### Changed
- The Statistics page's range buttons follow `stats.retention`: ranges
longer than any data kept are left out, and **1y** and **2y** appear
when `retention.day` (or `retention.hour`) covers them (with the
default 730d, both do). The busiest-times heatmap says how far back it
really goes (at most `retention.hour`), and links to the Explore page
keep within `retention.queries` instead of assuming 7 days.
`GET /stats/status` and `GET /stats/explore` add `retention_seconds`.
- A reverse zone's missing PTR records count only its own view's A and
AAAA records. An address only another view's records use (an internal
host, seen from the vps view's copy of `10.0.10.in-addr.arpa`) is no
longer reported as missing; it shows apart, as `Other views` in `jarl
zone show`, `ptr_other_views` in the API and a note on the zone's page.
Filling (`--fill`, `jarl zone fill-ptr`, the page's button) names those
from the other view only when asked, with `--other-views`
(`fill_other_views`, `?other_views=1`, a checkbox in the New reverse
zone dialog), so one view's host names don't leak into another. When
there is nothing else to fill, Jarl says how many there are and how to
add them. The page and `jarl zone show` list those addresses with the
name each would get, and the view it comes from
(`ptr_other_views_list`); `jarl zone show` lists the missing ones too.
- Records, zones and validation findings sort numeric labels by value, so
`2.10.0.10.in-addr.arpa` comes before `101.10.0.10.in-addr.arpa` in the
web UI, the CLI and the rendered zone files. Names still group by label
from the right (subdomains stay under their parent); other labels keep
the old byte order (`host10` before `host2`). Zone files are rewritten
in the new order the next time they change; named doesn't care about
record order.
- `cname_chain` no longer warns about one CNAME pointing at another (a
service name at a host alias is common, and named answers the chain at
once). A chain of three or more CNAMEs gets an info note that shows
it.
- A certificate's private key is only exported over HTTPS, the local
socket, or to the server itself (plain HTTP from another host is
refused with `https_required`).
- The Certificates page's "Used for" column says DoT and DoH for named,
with the addresses in the tooltip, instead of listing every address. A
certificate with no use says "not used by Jarl" rather than "nothing
yet": it may well be used elsewhere.
- Deleting a linked zone's local records (an override) removes that name
and type from that view only. It used to bring the shared records back
into the view, so a delete looked undone. The other views keep their
records, local to each now; **Share** one view's version (`jarl record
update ID --shared`) to have it everywhere again.
- A linked zone's Compare views tab starts with a view it is linked to,
not the first other view.
- The New token form only offers templates within what the token's user
may do. A token never does more than its user, so offering more only
misled.
- A zone update that changes nothing is no change: saving a zone's
settings without changing them says "Nothing changed" instead of
staging an empty change. Staging operations that change nothing
(`POST /changesets/{id}/ops`, or `?stage=`) is a 400 "nothing to
change".
### Fixed
- `jarl cert get NAME` no longer takes over a name this host has saved
for another Jarl server, which overwrote that certificate's settings
(and, with the default `--dir`, its files), so it stopped being
renewed. It is refused, as is a `--dir` holding another certificate's
files; `--replace` takes the name or directory over.
- The "query log is off" error named a setting that doesn't exist,
`stats.query_retention`; it's `stats.retention.queries`.
- `jarl zone show` for a reverse zone printed only "and 1 more" under
"Other views:" when the server was older than the CLI (0.6.1-rc.2
sends the count but not the addresses). It now prints just the count
line then.
- `jarl changeset show` printed its Created and Applied times in UTC,
while `jarl history` and `jarl status` print local time. It uses local
time now too.
- The statistics' "Busiest times" grid drew hours without data taller
than the others (they picked up the page's padding for empty
messages).
- A token for one view of a linked zone (such as an ACME token with
`views=external`) couldn't add records: a new record is shared, and
that reached the other view. A new name and type is now local to the
view when the user may not change it in the other views.
- A zone name in several views had to be given with `--view` even when
the user could see it in only one (`jarl record list adbraun.lol` with
a one-view token), and the error named views the user can't see. Now
the one they can see is used.
- `jarl view list` was empty for a user without `views:read`, though
they can see zones in views. It now lists those views by name, colour
and visible zone count (marked `limited` in the API), without their
settings.
- The DNSSEC tab told you to give the DS record to your registrar for
every zone whose parent isn't in Jarl in the same view. Now it (and
`jarl zone dnssec status`, and the API's new `ds_at`, `ds_place` and
`ds_note`) says where it really goes: the parent zone in Jarl (in any
view now, the zone's own first), the registrar for a registered
domain, the parent zone wherever it is hosted for a subdomain, your
ISP or RIR for a reverse zone, and nowhere for a private name such as
`home.lan` (signing it has no effect).
- The Registrar tab (and `jarl zone registrar`, and the DNSSEC page's
registrar button) was offered for every zone when `porkbun.domains` is
empty, and always failed for subdomain zones (`lab.adbraun.lol`),
reverse zones and private names (`home.lan`): Porkbun only knows
registered domains. Only zones that are a registered domain (by the
public suffix list) get it now; the others say what delegates them
instead.
- Auto-PTR in linked zones changed PTR records in every linked view, not
just where the address record is. A record local to one view got PTR
records in the other views too; deleting or changing the address in
one view deleted or moved the PTR records of views that still had it.
PTR records now follow each view's own address records, are local to
their view in a linked reverse zone, and each zone's Reverse DNS
setting counts for its own view. **Fill** also adds local PTR records
now, not shared ones. The same applies to **Update PTR** (`--ptr`).
- Saving the settings of a zone whose primary name server is outside it
(`ns1.adbraun.lol.` for `wg.adbraun.lol`) changed the name server to
`ns1.adbraun.lol.wg.adbraun.lol.`. The form showed the name without its
final dot, which the server reads as relative to the zone. Names outside
the zone now keep the dot, and the name server is only sent when it was
changed.
### Upgrading
- Tested: a 0.6.0 install with views, linked and reverse zones and
statistics, opened by 0.6.1-rc.1: every zone, record and view kept,
named answered as before, and the new grant and limits worked.
- The database gains a migration (certificate owners, per-user
certificate limits, certificate downloads). Going back to an older
version afterwards means restoring a backup made before the upgrade.
## [0.6.0] - 2026-10-13
### Added
- Reverse zones made easy (docs/reverse.md):
- **Missing reverse zones**: the Zones page and `jarl zone
suggest-reverse` list the reverse zones the private views lack for
@@ -95,6 +327,9 @@ All notable changes to Jarl are documented here. The format follows
### Fixed
- Encrypted DNS: the client hints tested with kdig (Knot's, often not
installed) and with curl's JSON API, which named's DoH doesn't speak;
they use BIND's `dig +tls` / `dig +https` now.
- Server options → Added by Jarl: long statements didn't wrap, and the
browser console reported a Content Security Policy violation (an inline
style the page's policy blocks).
+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:
rm -f $(DESTDIR)$(BINDIR)/jarld $(DESTDIR)$(BINDIR)/jarl $(DESTDIR)$(UNITDIR)/jarl.service \
$(DESTDIR)$(UNITDIR)/jarld.service \
$(DESTDIR)$(UNITDIR)/jarl-backup.service $(DESTDIR)$(UNITDIR)/jarl-backup.timer \
$(DESTDIR)$(BASHCOMPDIR)/jarl $(DESTDIR)$(BASHCOMPDIR)/jarld \
$(DESTDIR)$(SYSUSERSDIR)/jarl.conf $(DESTDIR)$(TMPFILESDIR)/jarl.conf
+13 -2
View File
@@ -202,8 +202,19 @@ review step.
## Status
Version 0.5.2 is the current release, and 0.6.0 is in testing
(0.6.0-rc.2): it makes reverse zones easy (suggested from your records,
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
+1 -1
View File
@@ -1 +1 @@
0.6.0-rc.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 != "" {
fmt.Fprintf(w, "Comment:\t%s\n", u.Comment)
}
if u.CertLimit != nil {
fmt.Fprintf(w, "Certificates:\tat most %d (jarl cert get)\n", *u.CertLimit)
}
fmt.Fprintf(w, "Last login:\t%s\n", fmtTime(u.LastLoginAt))
}
@@ -389,7 +392,8 @@ ACCESS is read or write, and filters take comma separated glob patterns:
records:write zones=adbraun.lol names=_acme-challenge.* types=TXT
*:read
Roles are ready-made grant sets: readonly, dns-editor, operator, acme.`
Roles are ready-made grant sets: readonly, dns-editor, operator, acme,
certificates.`
func userCmd() *cobra.Command {
cmd := &cobra.Command{Use: "user", Aliases: []string{"users"}, Short: "Manage users (admin)", Long: "Manage users (admin).\n\n" + grantHelp}
@@ -444,6 +448,7 @@ func userCmd() *cobra.Command {
}
var admin, noPassword, disabled bool
var role, comment, rename string
var certLimit int
var grants []string
userBody := func(cmd *cobra.Command) (api.UserInput, error) {
var in api.UserInput
@@ -466,13 +471,17 @@ func userCmd() *cobra.Command {
if f.Changed("rename") {
in.Username = &rename
}
if f.Changed("cert-limit") {
in.CertLimit = &certLimit
}
return in, nil
}
addUserFlags := func(c *cobra.Command) {
c.Flags().BoolVar(&admin, "admin", false, "make the user an administrator (--admin=false to remove)")
c.Flags().StringVar(&role, "role", "", "replace the grants with a role: readonly, dns-editor, operator or acme")
c.Flags().StringVar(&role, "role", "", "replace the grants with a role: readonly, dns-editor, operator, acme or certificates")
c.Flags().StringArrayVar(&grants, "grant", nil, `grant (repeatable), e.g. "records:write zones=example.com"`)
c.Flags().StringVar(&comment, "comment", "", "comment")
c.Flags().IntVar(&certLimit, "cert-limit", -1, "how many certificates the user may own through jarl cert get (-1: certificates.max_per_user in jarl.yaml)")
}
create := &cobra.Command{
Use: "create NAME",
@@ -750,7 +759,7 @@ func tokenCmd() *cobra.Command {
},
}
create.Flags().StringVar(&user, "user", "", "create the token for this user (admin)")
create.Flags().StringVar(&role, "role", "", "restrict the token to a role: readonly, dns-editor, operator or acme")
create.Flags().StringVar(&role, "role", "", "restrict the token to a role: readonly, dns-editor, operator, acme or certificates")
create.Flags().StringArrayVar(&grants, "grant", nil, "restrict the token to these grants (repeatable)")
create.Flags().StringVar(&expires, "expires", "", "lifetime, e.g. 90d or 720h (default: never)")
create.Flags().BoolVar(&save, "save", false, "store the new token in the CLI configuration file")
+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})
}
+131 -4
View File
@@ -50,6 +50,14 @@ type certInfo struct {
Chain string `json:"chain"`
Key string `json:"key"`
} `json:"files,omitempty"`
OwnerID int64 `json:"owner_id,omitempty"`
Owner string `json:"owner,omitempty"`
PulledBy []struct {
Who string `json:"who"`
Serial string `json:"serial"`
IP string `json:"ip"`
At time.Time `json:"at"`
} `json:"pulled_by,omitempty"`
}
type certAccount struct {
@@ -99,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 list
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
}
@@ -123,7 +132,7 @@ func certListCmd() *cobra.Command {
return err
}
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 {
names := ci.Names
if len(names) == 0 {
@@ -133,13 +142,32 @@ func certListCmd() *cobra.Command {
if ci.IssuingSince != nil {
state = "obtaining"
}
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\n", ci.Name, ci.Source, dash(strings.Join(ci.Uses, ",")), strings.Join(names, " "), fmtDate(ci.NotAfter), state)
uses := strings.Join(ci.Uses, ",")
if uses == "" && ci.OwnerID != 0 {
uses = "get" // for another host (jarl cert get)
}
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\t%s\n", ci.Name, ci.Source, dash(uses), strings.Join(names, " "), fmtDate(ci.NotAfter), lastPull(ci), state)
}
})
},
}
}
// lastPull is when the certificate was last downloaded, marked "(old)"
// while someone who downloaded it still has the one before its renewal.
func lastPull(ci certInfo) string {
if len(ci.PulledBy) == 0 {
return "-"
}
s := fmtDate(&ci.PulledBy[0].At) // newest first
for _, p := range ci.PulledBy {
if p.Serial != ci.Serial {
return s + " (old)"
}
}
return s
}
func fmtDate(t *time.Time) string {
if t == nil {
return "-"
@@ -180,6 +208,20 @@ func printCert(w io.Writer, ci certInfo) {
if ci.Comment != "" {
fmt.Fprintf(w, "Comment:\t%s\n", ci.Comment)
}
if ci.Owner != "" {
fmt.Fprintf(w, "Owner:\t%s (jarl cert get)\n", ci.Owner)
}
for i, p := range ci.PulledBy {
label := ""
if i == 0 {
label = "Pulled by:"
}
cur := "current"
if p.Serial != ci.Serial {
cur = "an older one: not fetched since the renewal"
}
fmt.Fprintf(w, "%s\t%s from %s, %s (%s)\n", label, p.Who, dash(p.IP), fmtTime(&p.At), cur)
}
state := ci.Status.State
if ci.Status.Message != "" {
state += ": " + ci.Status.Message
@@ -493,6 +535,91 @@ is published and the CA isn't asked.`,
return cmd
}
func certExportCmd() *cobra.Command {
var dir, part string
var noKey, force bool
cmd := &cobra.Command{
Use: "export NAME",
Short: "Save a certificate (and its key) to use in another program",
Long: `Saves a certificate's current files, to use it outside Jarl (a mail
server, a reverse proxy): fullchain.pem and privkey.pem in --dir (the
key readable only by you), or one part on standard output with --part:
fullchain the certificate and its chain
cert the certificate alone
key the private key
bundle the key, then the chain (one file, as HAProxy wants)
Administrators only; every export of the key is audited. Jarl renews the
certificate, but not your copy: export it again after a renewal (the
'renews' date in 'jarl cert show'), or point the program at the files Jarl
writes for named (use dns) instead.`,
Example: ` jarl cert export ns1 --dir /etc/ssl/ns1
jarl cert export ns1 --part bundle > /etc/haproxy/ns1.pem
jarl cert export ns1 --no-key --dir .`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
get := func(p string) (string, error) {
var out string
err := c.Get(ctx(), certPath(args[0])+"/pem", url.Values{"part": {p}}, &out)
return out, err
}
if part != "" {
out, err := get(part)
if err != nil {
return err
}
_, err = os.Stdout.WriteString(out)
return err
}
files := []struct{ part, name string }{{"fullchain", "fullchain.pem"}, {"key", "privkey.pem"}}
if noKey {
files = files[:1]
}
if err := os.MkdirAll(dir, 0o755); err != nil {
return err
}
for _, f := range files {
path := dir + string(os.PathSeparator) + f.name
if _, err := os.Stat(path); err == nil && !force {
return fmt.Errorf("%s exists (--force replaces it)", path)
}
}
for _, f := range files {
out, err := get(f.part)
if err != nil {
return err
}
path := dir + string(os.PathSeparator) + f.name
mode := os.FileMode(0o644)
if f.part == "key" {
mode = 0o600
}
if err := os.WriteFile(path, []byte(out), mode); err != nil {
return err
}
if err := os.Chmod(path, mode); err != nil {
return err
}
if !flagQuiet {
fmt.Println("wrote", path)
}
}
return nil
},
}
cmd.Flags().StringVar(&dir, "dir", ".", "directory for fullchain.pem and privkey.pem")
cmd.Flags().StringVar(&part, "part", "", "write one part to standard output: fullchain, cert, key or bundle")
cmd.Flags().BoolVar(&noKey, "no-key", false, "only fullchain.pem")
cmd.Flags().BoolVar(&force, "force", false, "replace existing files")
_ = cmd.RegisterFlagCompletionFunc("part", cobra.FixedCompletions([]string{"fullchain", "cert", "key", "bundle"}, cobra.ShellCompDirectiveNoFileComp))
return cmd
}
func certDeleteCmd() *cobra.Command {
var yes bool
cmd := &cobra.Command{
+2 -2
View File
@@ -272,9 +272,9 @@ func changesetCmd() *cobra.Command {
return err
}
return output(cs, func(w io.Writer) {
fmt.Fprintf(w, "Changeset:\t%d (%s)\nDescription:\t%s\nCreated:\t%s by %s via %s\n", cs.ID, cs.Status, cs.Description, cs.CreatedAt.Format("2006-01-02 15:04"), cs.Username, cs.Via)
fmt.Fprintf(w, "Changeset:\t%d (%s)\nDescription:\t%s\nCreated:\t%s by %s via %s\n", cs.ID, cs.Status, cs.Description, cs.CreatedAt.Local().Format("2006-01-02 15:04"), cs.Username, cs.Via)
if cs.AppliedAt != nil {
fmt.Fprintf(w, "Applied:\t%s by %s\n", cs.AppliedAt.Format("2006-01-02 15:04"), cs.AppliedBy)
fmt.Fprintf(w, "Applied:\t%s by %s\n", cs.AppliedAt.Local().Format("2006-01-02 15:04"), cs.AppliedBy)
}
fmt.Fprintln(w, "\n#\tOPERATION")
for _, op := range cs.Ops {
+6 -1
View File
@@ -30,7 +30,12 @@ func viewCmd() *cobra.Command {
return output(views, func(w io.Writer) {
fmt.Fprintln(w, "#\tID\tVIEW\tMATCH-CLIENTS\tMATCH-DESTINATIONS\tZONES\tCOLOR")
for i, v := range views {
fmt.Fprintf(w, "%d\t%d\t%s\t%s\t%s\t%d\t%s\n", i+1, v.ID, v.Name, orAny(v.MatchClients), orAny(v.MatchDestinations), v.Zones, orDefault(v.Color))
clients, dests := orAny(v.MatchClients), orAny(v.MatchDestinations)
if v.Limited {
// No views:read: only the zones they can see.
clients, dests = "(not shown)", "(not shown)"
}
fmt.Fprintf(w, "%d\t%d\t%s\t%s\t%s\t%d\t%s\n", i+1, v.ID, v.Name, clients, dests, v.Zones, orDefault(v.Color))
}
})
},
+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.
Turning signing on is safe at any time; validation only starts once you add
the zone's DS record at the parent zone (your registrar), and 'status' says
when that is safe. Turning it off goes through BIND's insecure policy, which
the zone's DS record at the parent zone (your registrar for a domain, the
parent zone for a subdomain), and 'status' says when that is safe and where. Turning it off goes through BIND's insecure policy, which
keeps the zone signed until the DS is gone from the parent.`,
}
var view string
@@ -71,7 +71,7 @@ DS after that.`,
return err
}
if !flagQuiet {
fmt.Printf("\nnamed is signing %s now. Add the DS record at your registrar only when\n'jarl zone dnssec status %s' says the zone is ready.\n", strings.TrimSuffix(args[0], "."), args[0]+viewFlag(view))
fmt.Printf("\nnamed is signing %s now. Add its DS record to the parent zone only when\n'jarl zone dnssec status %s' says the zone is ready; it also says where it goes.\n", strings.TrimSuffix(args[0], "."), args[0]+viewFlag(view))
}
return nil
},
@@ -87,8 +87,8 @@ DS after that.`,
parent zone still has its DS record:
1. The first run switches the zone to BIND's insecure policy. named keeps it
signed and publishes a CDS "delete" record. Remove the DS record at your
registrar, then run 'jarl zone dnssec check-ds' so named knows. named
signed and publishes a CDS "delete" record. Remove the DS record at the
parent ('status' says where), then run 'jarl zone dnssec check-ds' so named knows. named
waits until resolvers have forgotten the DS (the parent's DS TTL, often a
day) and then removes the keys and signatures.
2. Run it again when 'status' says turning off has finished: DNSSEC is then
@@ -137,7 +137,7 @@ A zone whose DS never reached the parent can be wiped at once instead
if next == "insecure" && st.ParentZone != "" {
fmt.Printf("\nNext: remove the DS record for %s from %s:\n %s\nthen run\n jarl zone dnssec check-ds %s\nRun 'jarl zone dnssec disable %s' again when 'status' says it has finished.\n", displayName(st.Zone), displayName(st.ParentZone), parentDSCommand(st, "delete", ""), args[0]+viewFlag(view), args[0]+viewFlag(view))
} else if next == "insecure" {
fmt.Printf("\nNext: remove the DS record for %s at your registrar, then run\n jarl zone dnssec check-ds %s\nRun 'jarl zone dnssec disable %s' again when 'status' says it has finished.\n", displayName(st.Zone), args[0]+viewFlag(view), args[0]+viewFlag(view))
fmt.Printf("\nNext: remove the DS record for %s %s, then run\n jarl zone dnssec check-ds %s\nRun 'jarl zone dnssec disable %s' again when 'status' says it has finished.\n", displayName(st.Zone), dsPlace(st), args[0]+viewFlag(view), args[0]+viewFlag(view))
} else {
fmt.Println("\nDNSSEC is off. The old keys stay in the key directory until you wipe them ('jarl zone dnssec wipe').")
}
@@ -153,7 +153,7 @@ A zone whose DS never reached the parent can be wiped at once instead
Short: "Check the parent zone's DS records and tell named",
Long: `Asks every server of the parent zone for the zone's DS records, and tells
named (rndc dnssec -checkds) when its key's DS has appeared there (after you
added it at the registrar) or is gone (while turning DNSSEC off). named
added it there) or is gone (while turning DNSSEC off). named
moves to the next step only once it knows.
The parent is found through jarld's resolver, or --resolver. Exits with 2
@@ -345,9 +345,12 @@ func printDNSSEC(w io.Writer, r *api.DNSSECResponse) {
}
}
if len(ds) > 0 && r.Policy != "insecure" {
where := "at your registrar"
if r.DSAt == "none" {
fmt.Fprintf(w, "\n%s\n", r.DSNote)
}
where := dsPlace(r)
if r.ParentZone != "" {
where = "in the parent zone " + displayName(r.ParentZone) + ", which is in Jarl: add it as a DS record there"
where += ", which is in Jarl: add it as a DS record there"
}
fmt.Fprintf(w, "\nDS record for the parent zone (%s):\n", where)
if !r.Phase.CanPublishDS && r.Phase.DSReadyAt != nil {
@@ -368,6 +371,14 @@ func printDNSSEC(w io.Writer, r *api.DNSSECResponse) {
}
}
// dsPlace says where the zone's DS record goes ("at your registrar").
func dsPlace(r *api.DNSSECResponse) string {
if r.DSPlace != "" {
return r.DSPlace
}
return "at the parent zone"
}
// parentDSCommand is the jarl command that adds (or deletes) the zone's DS
// record in its parent zone in Jarl.
func parentDSCommand(r *api.DNSSECResponse, verb, data string) string {
+36 -9
View File
@@ -103,17 +103,30 @@ func saveCLIConfig(c *cliConfig) error {
return os.WriteFile(flagConfig, b, 0o600)
}
func newClient() (*client.Client, error) {
// connection is the server a command talks to and how: Token may be
// "@FILE" (read by open).
type connection struct {
Server string `json:"server"`
Token string `json:"token,omitempty"`
Insecure bool `json:"insecure,omitempty"`
CACert string `json:"ca_cert,omitempty"`
// Name is the named server's name ("" for one given by address).
Name string `json:"-"`
}
// resolveConnection works out the connection from the flags, the
// environment and the CLI configuration.
func resolveConnection() (connection, error) {
cfg, err := loadCLIConfig()
if err != nil {
return nil, fmt.Errorf("reading %s: %w", flagConfig, err)
return connection{}, fmt.Errorf("reading %s: %w", flagConfig, err)
}
ts, err := resolveTargets(cfg, targetSpec(cfg))
if err != nil {
return nil, err
return connection{}, err
}
if len(ts) > 1 {
return nil, fmt.Errorf("--server names %d servers; this command works on one", len(ts))
return connection{}, fmt.Errorf("--server names %d servers; this command works on one", len(ts))
}
t := ts[0]
// A named server's own token wins: a token from the environment
@@ -125,15 +138,21 @@ func newClient() (*client.Client, error) {
sock, isSocket := client.SocketPath(t.URL)
if isSocket {
if err := checkSocket(sock, t.Name == localName && flagServer == "" && os.Getenv("JARL_SERVER") == ""); err != nil {
return nil, err
return connection{}, err
}
}
if token == "" && !isSocket {
if t.Name != "" {
return nil, fmt.Errorf("no API token for server %s: use 'jarl --server %s config set token'", t.Name, t.Name)
return connection{}, fmt.Errorf("no API token for server %s: use 'jarl --server %s config set token'", t.Name, t.Name)
}
return nil, fmt.Errorf("no API token: use --token, JARL_TOKEN or 'jarl config set token'")
return connection{}, fmt.Errorf("no API token: use --token, JARL_TOKEN or 'jarl config set token'")
}
return connection{Server: t.URL, Token: token, Insecure: flagInsecure || t.Insecure, CACert: firstNonEmpty(flagCACert, t.CACert), Name: t.Name}, nil
}
// open returns a client for the connection.
func (cn connection) open() (*client.Client, error) {
token := cn.Token
if strings.HasPrefix(token, "@") {
b, err := os.ReadFile(token[1:])
if err != nil {
@@ -141,7 +160,15 @@ func newClient() (*client.Client, error) {
}
token = strings.TrimSpace(string(b))
}
c, err := client.New(client.Options{Server: t.URL, Token: token, Insecure: flagInsecure || t.Insecure, CACert: firstNonEmpty(flagCACert, t.CACert)})
return client.New(client.Options{Server: cn.Server, Token: token, Insecure: cn.Insecure, CACert: cn.CACert})
}
func newClient() (*client.Client, error) {
cn, err := resolveConnection()
if err != nil {
return nil, err
}
c, err := cn.open()
if err != nil {
return nil, err
}
@@ -167,7 +194,7 @@ func newClient() (*client.Client, error) {
}
c.DryRun, c.Force, c.AcceptRisk = flagDryRun || c.Preflight, flagForce, flagRisk
if !c.Preflight {
c.OnTokenExpiry = tokenExpiryWarning(t.Name)
c.OnTokenExpiry = tokenExpiryWarning(cn.Name)
}
return c, nil
}
+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/url"
"os"
"slices"
"strconv"
"strings"
"unicode/utf8"
@@ -100,6 +101,16 @@ func zoneCmd() *cobra.Command {
fmt.Fprintf(w, "Network:\t%s\n", z.Prefix)
if z.PTRMissing > 0 {
fmt.Fprintf(w, "Missing PTRs:\t%d (addresses the view's records use here; add them: jarl zone fill-ptr %s)\n", z.PTRMissing, args[0])
printPTRFills(w, z.PTRMissingList, z.PTRMissing)
}
if z.PTROtherViews > 0 {
what := "addresses"
if z.PTROtherViews == 1 {
what = "address"
}
fmt.Fprintf(w, "Other views:\t%d %s with A or AAAA records only in %s, so not missing here (add PTRs for them in this view too: jarl zone fill-ptr %s --other-views)\n",
z.PTROtherViews, what, fromViews(z.PTROtherViewsList), args[0])
printPTRFills(w, z.PTROtherViewsList, z.PTROtherViews)
}
}
if len(z.LinkedWith) > 0 {
@@ -119,7 +130,7 @@ func zoneCmd() *cobra.Command {
var ns, records, primaries, forwarders, opts []string
var contact, comment, cloneFrom, file, inView, forward, linkWith string
var ttl uint32
var fill bool
var fill, fillOther bool
create := &cobra.Command{
Use: "create NAME",
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
(created together). Without --view, a reverse zone goes to the private
view whose records use its addresses; --fill adds a PTR record for each
of them (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 192.168.1.0/24
@@ -169,9 +181,12 @@ of them (docs/reverse.md).
if linkWith != "" {
body["link_with"] = linkWith
}
if fill {
if fill || fillOther {
body["fill"] = true
}
if fillOther {
body["fill_other_views"] = true
}
var recs []map[string]any
for _, r := range records {
f := strings.Fields(r)
@@ -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(&comment, "comment", "", "comment")
create.Flags().BoolVar(&fill, "fill", false, "reverse zones: add PTR records for the view's A and AAAA records in the network")
create.Flags().BoolVar(&fillOther, "other-views", false, "reverse zones: --fill, also naming the addresses only other views' records use")
var refresh, retry, expire, minimum, serial uint32
var mname, policy, moveView string
@@ -1166,17 +1182,61 @@ func terminalWidth() int {
return width
}
// printPTRFills lists PTR records a reverse zone lacks under zone show's
// line about them: address -> name, and the view the name is from. A server
// older than the CLI may send the count without the list; then nothing is
// listed, rather than "and N more" under no addresses.
func printPTRFills(w io.Writer, fills []api.PTRFill, total int) {
if len(fills) == 0 {
return
}
for _, f := range fills {
from := ""
if f.From != "" {
from = " (view " + f.From + ")"
}
fmt.Fprintf(w, "\t %s -> %s%s\n", f.Address, dnsname.Display(f.Target), from)
}
if total > len(fills) {
fmt.Fprintf(w, "\t and %d more\n", total-len(fills))
}
}
// fromViews names the views PTR fills take their names from: "view
// internal", "views internal and lab".
func fromViews(fills []api.PTRFill) string {
var vs []string
for _, f := range fills {
if f.From != "" && !slices.Contains(vs, f.From) {
vs = append(vs, f.From)
}
}
switch len(vs) {
case 0:
return "other views"
case 1:
return "view " + vs[0]
}
return "views " + strings.Join(vs[:len(vs)-1], ", ") + " and " + vs[len(vs)-1]
}
func zoneFillPTRCmd() *cobra.Command {
var view string
var otherViews bool
cmd := &cobra.Command{
Use: "fill-ptr ZONE [--view VIEW]",
Use: "fill-ptr ZONE [--view VIEW] [--other-views]",
Short: "Add the PTR records a reverse zone lacks for its view's A and AAAA records",
Long: `Adds a PTR record to a reverse zone for every address in its network that
the view's A and AAAA records use and that has none yet. An address with
several names gets the mail or name server's name, else a host's rather
than the domain's, else the shortest (docs/reverse.md).`,
than the domain's, else the shortest (docs/reverse.md).
--other-views also adds the addresses in the network that only other
views' records use, naming them from there. That publishes those views'
host names in this one, so it is never done unasked.`,
Example: ` jarl zone fill-ptr 10.0.10.0/24
jarl zone fill-ptr 10.0.10.in-addr.arpa --view internal`,
jarl zone fill-ptr 10.0.10.in-addr.arpa --view internal
jarl zone fill-ptr 10.0.10.0/24 --view vps --other-views`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
@@ -1184,6 +1244,9 @@ than the domain's, else the shortest (docs/reverse.md).`,
return err
}
path, qv := zonePath(args[0], view)
if otherViews {
qv.Set("other_views", "1")
}
resp, err := c.Mutate(ctx(), "POST", path+"/fill-ptr", qv, nil)
if err != nil {
return err
@@ -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().BoolVar(&otherViews, "other-views", false, "also name the addresses only other views' records use")
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,
also where nothing is suggested unasked (a view with suggestions off, a
dismissed network, a public view), to create one by hand; --view narrows
the list to one view. 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.`,
Example: ` jarl zone suggest-reverse
jarl zone suggest-reverse --create
+1
View File
@@ -318,6 +318,7 @@ func serveCmd() *cobra.Command {
EABKeyID: a.cfg.ACME.Account.EABKeyID, EABHMAC: a.cfg.ACME.Account.EABHMAC,
Resolvers: a.cfg.ACME.Resolvers, CABundle: a.cfg.ACME.CABundle, CheckPropagation: a.cfg.ACME.CheckPropagation,
Dir: a.cfg.CertDir(), Version: version.String(), Porkbun: pb, PorkbunAllows: a.cfg.Porkbun.Allows,
MaxPerUser: a.cfg.Certificates.MaxPerUser,
}, a.store, a.eng, a.log)
cm.Start(ctx)
if a.cfg.ACME.Account.EABHMAC != "" {
+6 -1
View File
@@ -7,12 +7,17 @@
#
# Install it, then:
# EXEC_PATH=/usr/local/bin/jarl-lego JARL_TOKEN=@/etc/jarl/lego.token \
# lego --email you@example.com --dns exec -d example.com -d '*.example.com' run
# lego run --accept-tos --email you@example.com --dns exec -d example.com -d '*.example.com'
# (lego 4: lego --accept-tos --email ... --dns exec -d ... run, and "renew" to renew)
#
# Options (environment):
# JARL_SERVER Jarl server URL, e.g. https://ns1.example.com:8053 (optional if the jarl CLI is configured)
# JARL_TOKEN API token, or @FILE (optional if the jarl CLI is configured)
# JARL_VIEW Only write into this view (optional; default every view that has the zone)
# On a host inside a split DNS, lego's own propagation check then can't
# see the record: leave it unset, or run lego with
# --dns.propagation.disable-ans (lego 5; --dns.propagation-disable-ans in lego 4)
# (docs/acme.md, "Split DNS and lego's propagation check").
# JARL_BIN Path of the jarl binary (optional; default "jarl")
JARL_BIN="${JARL_BIN:-jarl}"
+7
View File
@@ -38,6 +38,13 @@ if [ "$1" = configure ]; then
if systemctl is-enabled --quiet jarl-backup.timer 2>/dev/null; then
systemctl start jarl-backup.timer || true
fi
# The jarld.service alias is linked when jarl.service is enabled;
# relink a unit enabled before the alias existed (this neither
# starts nor stops jarld).
if [ -n "$2" ] && systemctl is-enabled --quiet jarl.service 2>/dev/null \
&& [ ! -e /etc/systemd/system/jarld.service ]; then
systemctl reenable jarl.service >/dev/null 2>&1 || true
fi
# An upgrade restarts a running jarld (its database is upgraded
# when it starts). A first install starts nothing: Jarl needs
# 'jarld init' and 'jarld adopt' first.
+2
View File
@@ -65,3 +65,5 @@ SystemCallErrorNumber=EPERM
[Install]
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.
* If the zone exists in several **views** (split DNS), the record is written
into every view, so the CA (on the internet, in your external view) and
your internal resolvers see the same thing. `--view NAME` limits it to one.
your internal resolvers see the same thing. `--view NAME` limits it to one,
and so does a token for one view (`views=external`): in a linked zone its
records are then local to that view.
* All values and views go into one changeset. Setting a value that is
already there succeeds, so a retried hook doesn't fail, and removing a
value that isn't there succeeds as well.
@@ -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 JARL_SERVER=https://ns1.adbraun.lol:8053
export JARL_TOKEN=@/etc/jarl/certbot.token
lego --email you@adbraun.lol --dns exec -d adbraun.lol -d '*.adbraun.lol' run
lego run --accept-tos --email you@adbraun.lol --dns exec -d adbraun.lol -d '*.adbraun.lol'
```
Renew with the same variables and `lego ... renew` in place of `run`.
That is lego 5. `lego run` also renews: run the same command daily (a
cron job or systemd timer), and it renews once a third of the
certificate's lifetime is left. **lego 4** takes its options before the
command, and renews with `renew`:
```sh
lego --accept-tos --email you@adbraun.lol --dns exec -d adbraun.lol -d '*.adbraun.lol' run # first time
lego --email you@adbraun.lol --dns exec -d adbraun.lol -d '*.adbraun.lol' renew # then daily
```
Upgrading from lego 4 to 5, run `lego migrate` once (with `--path` if you
keep lego's data somewhere other than `./.lego`) before anything else:
lego 5 stores accounts and certificates differently. The exec and httpreq
providers, and so Jarl's side, are the same in both.
* lego runs `jarl-lego present FQDN VALUE` and `jarl-lego cleanup FQDN VALUE`,
and the script passes them to `jarl acme set|unset --exact`. FQDN is the
@@ -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`.
* lego waits until the record is visible on the zone's name servers
(`EXEC_PROPAGATION_TIMEOUT`, 60 seconds by default). Jarl answers only
once named serves the record, so this check passes on its first try.
once named serves the record, so this check passes on its first try,
as long as the view that answers lego's host has the record: with
`JARL_VIEW` on a host inside your network it doesn't. See
[Split DNS and lego's propagation check](#split-dns-and-legos-propagation-check).
### Split DNS and lego's propagation check
Before lego asks the CA to look, it checks the record itself, asking the
zone's name servers (`ns1.adbraun.lol`) directly. With views, they answer
lego's host from the view that matches *it*. On a host inside your
network that is the internal view. If the challenge was written into
another view only (`JARL_VIEW=external`, `--view external`, a token
limited to `views=external`, or the httpreq endpoint
`.../httpreq/view/external`), lego never sees it, and gives up after
its propagation timeout without the CA having looked:
```
propagation: time limit exceeded: last error: authoritative nameservers:
NS ns1.adbraun.lol.:53 returned NXDOMAIN for _acme-challenge.stats.adbraun.lol.
```
A name whose authorization is still valid from an earlier run ("authorization
already valid; skipping challenge") isn't checked at all, so one name can
work and the next fail with the same settings.
The ways round it, best first:
1. **Don't limit the view.** Leave `JARL_VIEW` unset (and give the token
no `views=` filter, the httpreq endpoint no `/view/...`): the record
goes into every view that has the zone, the internal one too, and
lego's check passes at once. The challenge is a random value that is
useless once the CA has checked it, and it is removed straight after.
2. **Skip lego's check of the name servers.** The record is already
served when Jarl answers, so lego's check adds nothing:
```sh
lego run --dns exec --dns.propagation.disable-ans ... # lego 5
lego --dns exec --dns.propagation-disable-ans ... run # lego 4.20 to 4.x
lego --dns exec --dns.disable-cp ... run # older lego 4
```
or replace the checks with a fixed wait, which also gives secondary
name servers time to catch up: `--dns.propagation.wait 30s` (lego 5;
`--dns.propagation-wait 30s` in lego 4.20 and later). In lego 5 these
can also be set in the environment: `LEGO_DNS_PROPAGATION_DISABLE_ANS=true`,
`LEGO_DNS_PROPAGATION_WAIT=30s`.
3. **In Traefik**, the same under `dnsChallenge` (Traefik 3.2 and later;
older versions have `disablePropagationCheck: true`):
```yaml
dnsChallenge:
provider: httpreq
propagation:
disableANSChecks: true # or delayBeforeChecks: 30s
```
4. **Run lego where the external view answers it**, such as a host outside
your network. Rarely the easy option.
certbot (section 3) doesn't check by itself, and acme.sh (section 4)
checks through public DNS-over-HTTPS resolvers, so neither runs into this.
## 6. lego and Traefik over HTTP (httpreq), without the jarl CLI
@@ -297,14 +371,20 @@ of the `acme` role.
export HTTPREQ_ENDPOINT=https://ns1.adbraun.lol:8053/api/v1/acme/httpreq
export HTTPREQ_USERNAME=lego
export HTTPREQ_PASSWORD_FILE=/etc/jarl/lego.token # or HTTPREQ_PASSWORD=jarl_...
lego --email you@adbraun.lol --dns httpreq -d adbraun.lol -d '*.adbraun.lol' run
lego run --accept-tos --email you@adbraun.lol --dns httpreq -d adbraun.lol -d '*.adbraun.lol'
```
That is lego 5; in lego 4 the options go before the command (`lego
--email ... --dns httpreq -d ... run`, and `renew` to renew). See
[section 5](#5-lego).
Leave `HTTPREQ_MODE` unset (its `RAW` mode sends a different body, which
jarld refuses with a message saying so). If jarld's certificate doesn't come from a public
CA, lego has to trust yours: `SSL_CERT_FILE=/path/to/your-ca.pem` (lego's
`LEGO_CA_CERTIFICATES` covers only the ACME server). To write into one view only, put
it in the endpoint: `.../api/v1/acme/httpreq/view/external`.
it in the endpoint: `.../api/v1/acme/httpreq/view/external`; on a host inside
your network, read [Split DNS and lego's propagation check](#split-dns-and-legos-propagation-check)
first.
### 4. Traefik
@@ -410,4 +490,8 @@ the main zone is never modified by automation.
`dig @ns1.adbraun.lol _acme-challenge.adbraun.lol TXT`. With views, make
sure the record is in the view that matches the CA (it is by default)
and that `allow-query` lets the internet query the zone.
* lego says `propagation: time limit exceeded ... returned NXDOMAIN for
_acme-challenge...`: the record is in a view that doesn't answer lego's
host, usually `JARL_VIEW=external` on a host inside your network. See
[Split DNS and lego's propagation check](#split-dns-and-legos-propagation-check).
* `jarl audit --user certbot` shows every attempt.
+32 -11
View File
@@ -191,6 +191,7 @@ ZoneInput:
{
"name": "adbraun.lol", // or a CIDR like "192.168.1.0/24" for a reverse zone
"fill": true, // reverse zones: PTR records for the view's A/AAAA records there
"fill_other_views": true, // with fill: also for addresses only other views' records use
"view": "internal", // or "view_id"; moving a zone = PATCH with another view
"type": "primary", // secondary, stub, static-stub, forward, mirror, hint, redirect, in-view
"in_view": "internal", // type in-view only
@@ -212,8 +213,12 @@ ZoneInput:
A reverse zone created without a view goes to the private view whose
records use its addresses. Zones come back with `prefix` (reverse zones:
the network) and `ptr_missing` (addresses in it that the view's A and AAAA
records use, without a PTR record).
the network), `ptr_missing` (addresses in it that the view's A and AAAA
records use, without a PTR record) and `ptr_other_views` (addresses in it
without a PTR record that only other views' records use; not missing),
with the first 25 of each in `ptr_missing_list` and
`ptr_other_views_list` (`address`, `name`, `target`, and `from`, the view
the name comes from).
`/serials` and `/delegation` send DNS queries from the server, and only
when called ([operations.md](operations.md#checking-name-servers-and-the-delegation)).
@@ -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": "..."},
"policies": ["default"],
"parent_zone": "", // set when the parent zone is a primary zone in Jarl
"ds_at": "registrar", // parent_zone, registrar, parent, reverse or none
"ds_place": "at your registrar", // completes "add the DS record ..."
"ds_note": "", // for ds_at none: why it can't have one
"parent_zone": "", // set when the parent zone is a primary zone in Jarl (any view, its own first)
"registrar": "porkbun" // when the DS can be published through Jarl
}
```
`ds_at` says where the DS record goes ([dnssec.md](dnssec.md#should-you-sign)):
`parent_zone` (a zone in Jarl: `parent_zone`, `parent_zone_id`,
`parent_view`), `registrar` (the zone is a registered domain), `parent` (a
subdomain whose parent zone is hosted elsewhere), `reverse` (whoever
delegates the addresses) or `none` (nothing on the internet delegates it).
`phase.code` is one of `off`, `leftover_keys`, `starting`, `signing`,
`ready`, `ds_pending`, `secure`, `removing_ds`, `ds_expiring`,
`removing_keys`, `unsigned` ([dnssec.md](dnssec.md#phases)).
@@ -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":
true` (this view only, an override) or `false` (shared; on an update or a
set it makes this view's version every view's). Zones carry `link_group`
and `linked_with` (the other views).
and `linked_with` (the other views). Every zone in a view carries
`in_views`: the zones of the same name in the other views, linked or not
and of any type, in view order (`[{"id": 289, "view": "external", "type":
"primary", "linked": true}]`).
| Method | Path | Body |
|--------|------|------|
| POST | `/zones/{zone}/link` | `{"with": ["external"]}` (default: every view with that zone). Names and types that are the same everywhere become shared, the rest stay local |
| POST | `/zones/{zone}/unlink` | Takes the zone out of its link; its records stay, all local |
| POST | `/zones/{zone}/fill-ptr` | Adds the PTR records a reverse zone lacks for its view's A and AAAA records ([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` |
| 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 |
@@ -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
with one of the values is enabled, other disabled records are left alone.
Disabling a record that is already disabled is a 409; with `missing_ok=1`
(by match) it's "nothing to do".
(by match) it's "nothing to do". A zone update that leaves the zone as it
is (the same SOA, TTL, comment...) changes nothing, so applying it is
"nothing to do" and staging it is "nothing to change".
### Views
@@ -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` |
| POST | `/changesets` | `{"description": "...", "ops": [...], "apply": false}`: create (and optionally stage or apply) |
| GET / PATCH | `/changesets/{id}` | PATCH `{"description": "..."}` |
| POST | `/changesets/{id}/ops` | `{"ops": [Operation...]}`: stage more operations |
| POST | `/changesets/{id}/ops` | `{"ops": [Operation...]}`: stage more operations; 400 "nothing to change" if they change nothing |
| DELETE | `/changesets/{id}/ops/{seq}` | remove one staged operation |
| GET | `/changesets/{id}/preview` | dry run of an open changeset: problems, check messages, diff |
| POST | `/changesets/{id}/apply` | `{"dry_run": false, "force": false}` |
@@ -560,12 +579,12 @@ Needs `stats:read`. When statistics are disabled, every endpoint but
| Method | Path | Query | Returns |
|--------|------|-------|---------|
| GET | `/stats/status` | | `{enabled, query_log, statistics_channel, log_file, last_poll, last_log_line, log_lines_read, first_data, retention, warnings}` |
| GET | `/stats/status` | | `{enabled, query_log, statistics_channel, log_file, last_poll, last_log_line, log_lines_read, first_data, retention, retention_seconds, warnings}`: `retention` has `minute`, `hour`, `day`, `queries` as `"7d"`, `retention_seconds` the same in seconds (`queries` 0 without a query log) |
| GET | `/stats/summary` | `since` (90m, 24h, 7d, 2w; default 24h), `top` (default 10) | totals, `avg_qps`, `peak`, `rcodes`, `qtypes`, `views`, `zones`, `answers`, `transport`, `cache_hit_ratio`, `nxdomain_ratio` (without blocked answers), `blocked`, `blocked_ratio`, `blocklists` (blocked per list; `rules` for your rules), `tops` |
| GET | `/stats/timeline` | `since`, `step` (auto, 1m, 5m, 15m, 1h, 6h, 1d), `key` (repeatable or comma separated; `rcode:*` = every counter of a group; default `queries`) | `{step, times: [unix...], series: [{key, values}]}` |
| GET | `/stats/top` | `kind` (name, client, nxdomain, servfail, refused, blocked, blocked_client), `since`, `limit` (default 20) | `[{key, count}]`; client, refused and blocked_client entries add `name`, the host name of Jarl's record for the address (the summary's client list too) |
| GET | `/stats/heatmap` | `since` (default 28d), `key` (default queries) | `{key, from, avg[7][24], samples[7][24]}`, Monday first, local time, `-1` = no data |
| GET | `/stats/explore` | `?client=&name=&domain=&view=&type=&rcode=&blocked=yes\|no\|LIST&since=24h&views=`; each takes several values, comma separated (any of them), `!value` to leave one out, and names `*.example.com` for it and below | The query log for a filter ([stats.md](stats.md#exploring)): `total`, `blocked`, `series` (per `step` seconds: `t`, `count`, `blocked`), `by` (top 15 of each dimension the filter leaves open: client, name, view, type, rcode, blocked; clients with `name`), `recent` (latest 100: ts, client, client_name, view, name, type, rcode, blocked), `oldest`, `retention`, `client_name`. 404 `query_log_off` without a query log |
| 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}]` |
@@ -584,12 +603,14 @@ Administrators only. See [certificates.md](certificates.md#api).
| 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/check` | `{"domains", "challenge"}`: the route of each challenge |
| GET, PATCH, DELETE | `/certificates/{name}` | |
| POST | `/certificates/{name}/issue` | 202: being obtained (poll until `issuing_since` is gone); a file certificate is reread (200) |
| POST | `/certificates/{name}/check` | the routes of a certificate's names |
| POST | `/certificates/{name}/request` | `{"domains": [...], "key_type": "ec256"}`: a certificate for another host, owned by the caller's user (needs `certificates:write` for every name). New: created and obtained, 202 (poll `GET /certificates/{name}` until `issuing_since` is gone). Existing: must be the caller's (else 409); obtained again only when its names change. Answers `certificate`, `created`, `changed` ([certificates.md](certificates.md#certificates-for-other-hosts)) |
| GET | `/certificates/{name}/pem?part=PART` | the certificate as PEM: `fullchain` (default), `cert`, `key`, `bundle`; key exports audited ([certificates.md](certificates.md#using-a-certificate-outside-jarl)) |
`GET /status` includes `certificates` for administrators: `total`,
`problems` (failing, expiring, expired), `next` and `next_after`,
@@ -616,7 +637,7 @@ Success is `200 {"ok": true, "fqdn": ..., "changeset_id": ..., "zones": [...]}`.
|--------|------|------|
| GET | `/users` | admin |
| POST | `/users` | `{"username": "alice", "password": "...", "admin": false, "role": "dns-editor", "grant_strings": ["records:write zones=lab.adbraun.lol"], "comment": ""}` (`password: ""` = service account) |
| GET / PATCH / DELETE | `/users/{id}` | PATCH also takes `disabled`, and `username` to rename. Users have `totp_enabled_at` (two-factor login on since) and `totp_required` |
| GET / PATCH / DELETE | `/users/{id}` | PATCH also takes `disabled`, `username` to rename, and `cert_limit` (certificates the user may own through `jarl cert get`; negative: the `certificates.max_per_user` default). Users have `totp_enabled_at` (two-factor login on since) and `totp_required` |
| GET / POST | `/users/{id}/tokens` | admin (or yourself) |
| GET / POST | `/me/tokens` | `{"name": "laptop", "role": "", "grant_strings": [...], "expires_in": "90d"}`. The response's `token` field is the secret, shown once |
| GET | `/tokens` | every token (admin); `?all=1` includes revoked tokens |
+157 -1
View File
@@ -216,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
your renewal hook copy them somewhere it can read).
## Using a certificate outside Jarl
A certificate needn't be used by Jarl at all ("not used by Jarl" on the
Certificates page): Jarl obtains and renews it, and you export it for
another program, such as a mail server or a reverse proxy. **Export…** on
the Certificates page downloads each file or copies it to the clipboard
(copying needs the web UI over HTTPS, or from localhost). Or:
```sh
jarl cert export mail --dir /etc/ssl/mail # fullchain.pem, and privkey.pem (mode 600)
jarl cert export mail --part bundle > /etc/haproxy/mail.pem
```
The parts are `fullchain` (the certificate and its chain), `cert` (the
certificate alone), `key` and `bundle` (the key, then the chain, in one
file, as HAProxy wants). Administrators can export any certificate, and
the owner of a certificate for another host (below) their own. Every
export of the key is in the audit log (`certificate.export_key`), and a key
only goes over HTTPS, the local socket, or to this server itself.
Jarl renews the certificate, not your copy: export it again after each
renewal (`jarl cert show` says when that is). For another host, `jarl
cert get` (below) does that for you. A program on this server can instead
read the files Jarl writes for named (use `dns`), which Jarl keeps current.
## Certificates for other hosts
`jarl cert get` gets a certificate onto another host (a mail server, a
reverse proxy) with one command: jarld obtains it from the CA and renews
it, answering the challenges in its own zones as for its own certificates;
the host asks for it, waits until it is there, downloads it and runs a
hook. The host needs only a token, not an ACME client or access to DNS.
**On Jarl**, as an administrator, give the hosts a user and a token each:
```sh
jarl user create hosts --no-password --grant "certificates:write names=*.adbraun.lol"
jarl token create mx --user hosts # one token per host
```
The `certificates` grant says which names the user may have certificates
for. It is never part of `*`, so `operator` and `readonly` users can't
get certificates (or download keys) unless you add one. A certificate
belongs to the user that asked for it: its tokens can download it, other
users can't take its name or see it, and the certificates Jarl uses
itself are the administrators'. Hosts that should share certificates use
the same user (each with its own token, so you can revoke one host
alone); hosts that shouldn't, use different users. A user may own
`certificates.max_per_user` certificates (10 by default), or what `jarl
user update NAME --cert-limit N` (or the user's form) sets.
**On the host:**
```sh
export JARL_SERVER=https://jarl.adbraun.lol:8053 JARL_TOKEN=@/root/jarl.token
jarl cert get mail -d mail.adbraun.lol -d smtp.adbraun.lol --hook 'systemctl reload postfix dovecot'
sudo jarl cert schedule # a daily systemd timer runs 'jarl cert get'
jarl cert saved # what this host has, and when it expires
jarl cert saved mail # all of mail's settings
```
* **Asking again** with the same names returns the certificate jarld has:
nothing is obtained, nothing is downloaded until jarld renews it.
**Adding or removing a name** gets a new certificate. A different
`--key-type` alone doesn't; delete the certificate for that.
* **Files**, in `--dir` (default `/etc/jarl/certs/NAME` as root, `./NAME`
otherwise): `cert.pem`, `chain.pem` (the intermediates),
`fullchain.pem`, `privkey.pem` (mode 600) and `jarl-cert.json` (serial,
expiry, when it was fetched). Each file is replaced at once, never
half written. The key is checked against the certificate before
anything is written.
* **A combined file** (`--combined PATH`): the key, then the full chain,
in one file (mode 600), as HAProxy wants. See [HAProxy](#haproxy).
* **Saved settings**: the names, `--dir`, `--combined`, `--hook`, the
server and the token are saved in `/etc/jarl/cert-get/NAME.json` (as
root; `~/.config/jarl/cert-get` otherwise), readable only by their
owner. `jarl cert get` without a name does every saved certificate. A
token given as `@FILE` is saved as that reference, not copied. `jarl
cert saved NAME` shows them all (the token never). `jarl cert forget
NAME` stops getting one (`--delete` deletes it on jarld too).
* **Changing a setting**: run `jarl cert get NAME` with just that option
(`--hook '...'`, `--combined PATH`, `--dir DIR`); the others stay.
`--hook ''` removes the hook, `--combined ''` stops writing the
combined file and deletes it. The run still asks jarld for the
certificate, but downloads nothing while this host has the current one.
A new or moved combined file is written from the files this host
already has, and the hook runs.
* **`--local`** doesn't ask jarld at all: it writes the combined file and
runs the hook from the files this host has. Use it to apply a changed
setting while jarld is unreachable, or to test a hook. New names still
need jarld.
* **Several Jarl servers on one host**: each server has its own
certificate names, so two servers may both have a `proxy`. The host
keeps one `proxy` per settings directory, though: asking another server
for a name saved here for the first one is refused, as is a `--dir`
holding another certificate's files. Choose another name or `--dir`, or
pass `--replace` to take the name (or directory) over. A `--dir` that
another saved certificate uses is always refused.
* **The hook** runs with `sh` after new files are written or the
combined file changes (and with `--local`), with `JARL_CERT_NAME`,
`JARL_CERT_DIR`, `JARL_CERT_CERT`, `JARL_CERT_CHAIN`,
`JARL_CERT_FULLCHAIN`, `JARL_CERT_KEY`, `JARL_CERT_COMBINED` (empty
without `--combined`), `JARL_CERT_DOMAINS`, `JARL_CERT_SERIAL`,
`JARL_CERT_NOT_AFTER` and `JARL_CERT_RENEWED` (1 when it replaced an
older certificate). If it fails, `jarl cert get` exits non-zero and runs
it again next time. It has 5 minutes.
* **`jarl cert schedule`** (Linux with systemd, as root) writes
`/etc/systemd/system/jarl-cert-get.service` and `.timer`, which run
`jarl cert get` daily at a random time within the hour after midnight,
and catch up after the host was off. `--remove` takes them away. On
other systems, run `jarl cert get` daily with their scheduler.
**On the Certificates page**, the administrators see each certificate's
owner and who downloaded it: the token, from where, when, and whether
that host has the current certificate or hasn't fetched the renewed one
yet. `jarl cert show NAME` says the same. The owner's own tokens see
their own downloads there too. In `jarl cert list`, such a certificate's
USES is `get`, and PULLED is the last download, with `(old)` while a host
hasn't fetched the renewed one.
### HAProxy
HAProxy wants the key and the chain in one file. Give `jarl cert get` a
`--combined` path outside `--dir`:
```sh
jarl cert get proxy -d adbraun.lol -d '*.adbraun.lol' \
--combined /etc/haproxy/certs/adbraun.lol.pem --hook 'systemctl reload haproxy'
jarl cert get webmail -d webmail.adbraun.lol \
--combined /etc/haproxy/certs/webmail.pem --hook 'systemctl reload haproxy'
```
```
frontend https
bind :443 ssl crt /etc/haproxy/certs/
```
HAProxy loads every file in a `crt` directory and picks one by SNI, so
`/etc/haproxy/certs` should hold only combined files. Don't point `crt`
at `--dir` (`/etc/jarl/certs/NAME`): HAProxy would try to load
`cert.pem`, `privkey.pem` and `jarl-cert.json` there too, and refuse
to start. A `--combined` path that would overwrite a file in a saved
certificate's directory, or another certificate's combined file, is
refused.
The combined file is mode 600, owned by the user running `jarl cert get`
(root under the timer). HAProxy reads its certificates as root before it
drops privileges, so that's fine.
To add a combined file to a certificate this host already has, run
`jarl cert get proxy --combined /etc/haproxy/certs/adbraun.lol.pem`: it's
written from the files on disk and the hook runs, with nothing
downloaded.
## Security notes
- Certificates Jarl obtains, their keys and the ACME account key are kept
in Jarl's database, so they are in its backups (like TSIG secrets). The
API 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
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
@@ -240,6 +395,7 @@ your renewal hook copy them somewhere it can read).
| GET, PATCH, DELETE | `/certificates/{name}` | `domains` and `uses` replace the whole list |
| POST | `/certificates/{name}/issue` | obtain now: 202, then poll `GET /certificates/{name}` until `issuing_since` is gone; a file certificate is reread (200) |
| POST | `/certificates/{name}/check` | the routes for an existing certificate |
| GET | `/certificates/{name}/pem?part=fullchain` | the certificate as PEM, to save: `fullchain` (default), `cert`, `key` or `bundle` (key and chain). Exports of the key are audited |
## Testing with Pebble
+15 -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.0/24 --fill # in the view whose records use it, with their PTRs
jarl zone fill-ptr 10.0.10.0/24 # add the PTRs it lacks (zone commands take a network too)
jarl zone fill-ptr 10.0.10.0/24 --view vps --other-views # also name addresses only other views' records use
jarl zone suggest-reverse [--create] # reverse zones your private views lack (--create: yours)
jarl zone suggest-reverse --dismiss 185.199.108.153/32,185.199.109.153/32 --view internal # not yours: don't suggest them again
jarl zone suggest-reverse --all [--view V] # every network without a reverse zone, also the hidden ones
@@ -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
by Jarl); `jarl record update ID --ptr` with nothing else adds the PTR record
if it is missing, e.g. after creating the reverse zone. In linked zones, the
reverse 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
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 update ns1 --challenge porkbun # auto, jarl or porkbun
jarl cert update ns1 --use dns # --use and --domain replace the list (--use none)
jarl cert export mail --dir /etc/ssl/mail # fullchain.pem and privkey.pem, for another program
jarl cert export mail --part bundle # one part to stdout: fullchain, cert, key, bundle
jarl cert get mail -d mail.adbraun.lol --hook 'systemctl reload postfix' # on another host: get, download, hook
jarl cert get # every saved certificate (what the timer runs)
jarl cert get proxy -s other --replace # take over a name saved here for another server
jarl cert get proxy --combined /etc/haproxy/certs/proxy.pem # also key + chain in one file (HAProxy); '' stops it
jarl cert get proxy --hook 'systemctl reload haproxy' --local # change a setting; apply it from this host's files, without jarld
jarl cert saved # this host's certificates and their expiry
jarl cert saved proxy # all of one certificate's settings (never the token)
jarl cert forget mail [--delete] # stop getting it here
sudo jarl cert schedule [--remove] # daily systemd timer for jarl cert get
jarl cert delete ns1
jarl cert account # the ACME account (set in jarl.yaml)
```
@@ -527,6 +540,7 @@ jarl token list --all --expiring [60d] # expiring within 14 days
jarl token create laptop --expires 365d
jarl token create backup-script --grant "*:read"
jarl token create acme --user certbot --role acme
jarl token create web01 --grant "certificates:write names=web01.adbraun.lol" # jarl cert get on one host (or --role certificates)
jarl token rotate laptop --save # new secret, same token and grants
jarl token rotate --current --save --grace 10m # the token this CLI uses
jarl token revoke 7
+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.check_propagation` | `true` | Wait until every name server of the zone serves a challenge before the CA is told to look (they are asked directly, on port 53). |
| `certificates.dir` | `<bind.config_dir>/tls` | Where certificates with the `dns` use are written for named: `<dir>/<name>/fullchain.pem` and `privkey.pem`. |
| `certificates.max_per_user` | `10` | How many certificates a user may own through `jarl cert get` ([certificates.md](certificates.md#certificates-for-other-hosts)). A user's own limit (`jarl user update NAME --cert-limit N`) replaces it. |
### ACME (lego httpreq endpoint)
@@ -163,7 +164,7 @@ check` says so).
|-----|---------|---------|
| `porkbun.api_key` | `""` | The API key (`pk1_...`). Set both or neither. |
| `porkbun.secret_api_key` | `""` | Its secret (`sk1_...`). |
| `porkbun.domains` | `[]` | Only these domains, for both uses (default: any domain the key can reach). |
| `porkbun.domains` | `[]` | Only these domains, for both uses (default: any domain the key can reach). Subdomain and reverse zones never get the registrar features ([dnssec.md](dnssec.md#porkbun)). |
The key can move your domain anywhere, so it is only ever read from this
file: it isn't stored in the database or backups, and there is no API to
+6
View File
@@ -32,6 +32,12 @@ internal/
blocking/ blocklist parser, response policy zone writers, and matching a name the way named does
(the downloads are in engine/blocklists.go)
config/ jarld's YAML configuration
certs/ certificates: Jarl as an ACME client (lego), renewal, files for named
encdns/ encrypted DNS (DoT/DoH) setup, status and health checks
mailcheck/ reverse DNS check of the addresses mail is sent from
totp/ two-factor login codes and recovery codes
version/ what a build reports (release tag, commit, commits since a tag)
testutil/ test helpers, chiefly finding BIND's tools (JARL_BIND_ROOT)
deploy/ systemd units (jarld, the daily backup service and timer), sysusers.d and tmpfiles.d files
deploy/debian/ the Debian package's control template and postinst/prerm/postrm
contrib/acme.sh/ acme.sh DNS API hook
+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
publish a DS, and BIND doesn't validate the zones it serves itself.
**Where the DS record goes** depends on the zone. The DNSSEC tab and
`jarl zone dnssec status` say which:
| Zone | The DS record goes |
|---|---|
| a registered domain (`adbraun.lol`) | at your registrar ([Porkbun](#porkbun) can do it for you) |
| a subdomain whose parent zone is in Jarl (`lab.adbraun.lol`) | in the parent zone, as a DS record named `lab`: one button, or the `jarl record add` command shown. The parent in the zone's own view counts first, else one in another view (a public one first). |
| a subdomain whose parent zone is hosted elsewhere | in that parent zone, wherever it is hosted (at Porkbun: the domain's DNS records) |
| a reverse zone | with whoever delegates it to you: your ISP, or your RIR for an address block of your own. Many ISPs don't take DS records. |
| a private name (`home.lan`) | nowhere: nothing on the internet delegates it, so signing it has no effect |
**Split DNS.** In Jarl a zone belongs to a view, so you sign the external
view's `adbraun.lol` and leave the internal view's copy unsigned. That is
the usual setup, with one catch. Once the DS is at the parent, the world
@@ -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
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`:
| | |
+11 -4
View File
@@ -111,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 |
| 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
kdig +tls-ca +tls-host=dns.adbraun.lol @10.0.10.5 example.com
dig +https @10.0.10.5 example.com
curl -s -H 'accept: application/dns-json' 'https://dns.adbraun.lol/dns-query?name=example.com'
dig +tls +tls-ca +tls-hostname=dns.adbraun.lol @10.0.10.5 example.com # DoT, port 853
dig +https +tls-ca +tls-hostname=dns.adbraun.lol @10.0.10.5 example.com # DoH, port 443, /dns-query
```
`+tls-ca` checks the certificate against the system's CAs and
`+tls-hostname` against the host name, as clients do; add `-p PORT` for
another port. Without `+tls-ca` the query is still encrypted, but the
certificate isn't checked. named's DoH speaks the standard wire format
(RFC 8484), not the JSON API some public resolvers also offer, so
`curl ... accept: application/dns-json` doesn't work against it.
**Android away from home.** Private DNS in hostname mode applies on every
network. Away from home, your server can't be reached, and Android then
resolves nothing at all ("Private DNS server cannot be accessed"). Use a
+4
View File
@@ -317,6 +317,10 @@ systemctl status jarl
journalctl -u jarl -f
```
The unit also answers to `jarld` (`Alias=jarld.service`), so
`systemctl restart jarld` and `journalctl -u jarl` work alike once it
is enabled.
Open `https://<management-address>:8053/` and log in as `admin`.
## 9. Set up the CLI
+17
View File
@@ -360,6 +360,23 @@ cp /etc/bind/named.conf.pre-jarl-<date> /etc/bind/named.conf
rndc reconfig
```
## Failed logins in the journal
A failed login to the web UI or the API (a wrong password, or a wrong
two-factor code), and a refused API token, are written to the journal as
well as the audit log, with the address first:
```
level=WARN msg="login failed" ip=203.0.113.9 user=admin reason="wrong username or password"
level=WARN msg="token refused" ip=203.0.113.9 reason="wrong token secret"
```
Guard's `jarl` preset, or a fail2ban filter, can ban such addresses at
the firewall (with Housecarl's ban lists). The address comes before the
user name, which a caller chooses, so a pattern anchored on
`msg="login failed" ip=` can't be fooled by a name. With `log.format:
json` they are JSON objects with the same fields in the same order.
## Monitoring
* `GET /healthz` (no authentication) reports that jarld is up.
+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,
run `resync`, wipe a zone's DNSSEC keys, use the registrar (Porkbun,
whose key can move the domain), and manage TLS certificates (whose keys
can impersonate the server; [certificates.md](certificates.md)).
can impersonate the server; [certificates.md](certificates.md)). The one
exception is certificates for other hosts, below.
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) |
| `stats` | query statistics, including client addresses and the names they look up (read only) |
| `blocking` | blocklists and block/allow rules ([blocking.md](blocking.md)); `write` also downloads lists now |
| `*` | all of the above |
| `certificates` | certificates for other hosts (`jarl cert get`), by the names in them: `write` gets one obtained, `read` only downloads it. Never part of `*` |
| `*` | all of the above, except `certificates` |
`ACCESS` is `read` or `write` (write includes read). Filters narrow a grant.
Each takes comma separated patterns where `*` and `?` are wildcards, and a
@@ -146,6 +148,8 @@ missing filter matches everything:
* `zones=adbraun.lol,*.adbraun.lol`: zone names, without the final dot
* `views=internal`: view names (`_default` means zones outside any view)
* `names=_acme-challenge.*`: record owner names, as FQDNs without the final dot
(for `certificates`: the names in the certificate; `*.adbraun.lol` also
allows the wildcard certificate `*.adbraun.lol`)
* `types=TXT,CNAME`: record types
Examples:
@@ -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 names=_acme-challenge.* types=TXT ACME challenges anywhere
records:write zones=acme.adbraun.lol types=TXT only TXT records in a dedicated ACME zone
certificates:write names=mail.adbraun.lol,smtp.adbraun.lol certificates for a mail server (jarl cert get)
```
A user who may see some records of a zone may also see that the zone exists
(its name and type), but not its other records.
(its name and type), but not its other records. They also see the zone's
view in `jarl view list` (`GET /views`): its name, colour and how many
zones they can see there, marked `limited`, but not its settings
(`match-clients` and the rest need `views:read`).
A zone name that is in several views needs a view (`--view`), unless the
user can see the zone in only one of them: a token for
`views=external` finds the external copy without being told.
### Roles
@@ -173,6 +185,7 @@ you can then edit.
| `dns-editor` | `zones:write`, `records:write`, `views:read`, `acls:read`, `options:read`, `blocking:read` |
| `operator` | `*:write` (everything except user management) |
| `acme` | `records:write names=_acme-challenge.* types=TXT` |
| `certificates` | `certificates:write`: certificates for other hosts (`jarl cert get`) for any name in Jarl's zones. Narrow it to a host's names, e.g. `certificates:write names=web01.adbraun.lol` ([certificates.md](certificates.md#certificates-for-other-hosts)) |
```sh
jarl user create alice --role dns-editor
@@ -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 ci --grant "records:write zones=lab.adbraun.lol" --expires 90d
jarl token create certbot --user certbot --role acme # admin: a token for another user
jarl token create web01 --grant "certificates:write names=web01.adbraun.lol" # jarl cert get on one host
jarl token list --all
jarl token revoke 12
```
+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'
records use, marked "records in external": say a VPS's AAAA record that
only the external view has. Fill names those from the other view's
record, when the zone's own view has none for the address, and says so.
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.
record only when asked: `--other-views` (with `--fill` or `jarl zone
fill-ptr`), or the dialog's **Also for addresses only other views'
records use**, which is ticked for you when you pick a "records in"
network. Otherwise one view's host names would turn up in another's
reverse zone, which is what views are for keeping apart. Jarl says which
names came from another view. Keeping PTR records in step (below) stays
within a zone's own view and its linked views: a later change to that
external record doesn't reach the internal reverse zone.
```sh
jarl zone create 2600:3c01::f03c:93ff:fe2b:1/128 --view internal --fill --other-views
jarl zone fill-ptr 10.0.10.0/24 --view vps --other-views
```
## Creating and filling
@@ -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.
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
button to add them. `jarl zone show` shows it as `Missing PTRs`.
A reverse zone's page shows the addresses that still lack a PTR record and
the name each would get (the first 25), with a button to add them. `jarl
zone show` shows how many as `Missing PTRs`; the API lists them in
`ptr_missing_list`. Only the zone's own view's records count: an address
that only another view's records use isn't missing (say internal's
`claude.adbraun.lol` and the vps view's copy of `10.0.10.in-addr.arpa`).
Those are listed apart, with the name each would get: `Other views` in
`jarl zone show`, `ptr_other_views` and `ptr_other_views_list` in the
API, and a quiet note on the page with an **Add PTR records for them in
view vps** button. Filling never adds them unasked. `jarl zone show`
lists the missing ones too:
```
Missing PTRs: 1 (addresses the view's records use here; add them: jarl zone fill-ptr 10.0.10.0/24)
10.0.10.7 -> printer.adbraun.lol
Other views: 2 addresses with A or AAAA records only in view internal, so not missing here (add PTRs for them in this view too: jarl zone fill-ptr 10.0.10.0/24 --other-views)
10.0.10.26 -> gomft.adbraun.lol (view internal)
10.0.10.101 -> claude.adbraun.lol (view internal)
```
## Keeping PTR records in step
@@ -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,
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
views it is linked with ([views.md](views.md#linked-zones)). A whole set
replaced at once (`jarl record set`, a batch) works too.
same to its PTR record, in the reverse zone of each view that has the
record. A whole set replaced at once (`jarl record set`, a batch) works
too.
**Linked zones** ([views.md](views.md#linked-zones)). PTR records follow
each view's own address records:
* A shared record gets a PTR record in every linked view that has it; a
record local to one view gets one in that view only.
* An address that goes from one view (deleted, changed, or overridden by
a local record) loses its PTR record there only. Views that still have
the address keep theirs.
* Each zone's own **Reverse DNS** setting counts: a linked zone without
it doesn't get PTR records from its partner's changes.
* In a linked reverse zone, these PTR records (and those **Fill** adds)
are local to their view. A PTR record that is shared becomes local in
each view the first time one of them changes it, so that a change in
one view doesn't reach the others.
* **One name per address.** When an address's PTR record names another
host, it stays: a second A record for the same address doesn't add a
+5 -1
View File
@@ -263,7 +263,11 @@ database, roughly in proportion to the number of views.
lists for `retention.hour`, daily ones for `retention.day`. Older data is
deleted every hour.
- Charts use the finest resolution that covers the range: minutes up to 48
hours, hours up to 90 days, days beyond.
hours (and `retention.minute`), hours up to `retention.hour`, days beyond.
- The Statistics page offers only the ranges the data is kept for: up to 90
days with the defaults, plus a year and 2 years when `retention.day` (or
`retention.hour`) is that long. The Explore page offers the ranges up to
`retention.queries`.
- Top lists are exact within an hour (up to 200,000 distinct names or
clients per hour, beyond which the rarest are dropped). Daily lists are
built from the hourly top lists, so for very long tails they are
+1 -1
View File
@@ -34,7 +34,7 @@ showing them.
| `cname_multiple` | error | A name can have only one CNAME |
| `cname_apex` | error | No CNAME at the zone apex (it needs SOA and NS) |
| `cname_loop` | error | CNAMEs that point back to themselves |
| `cname_chain` | warning | CNAME to a CNAME: works, but slows resolution |
| `cname_chain` | info | a chain of three or more CNAMEs (one CNAME to another says nothing): works, but each is another lookup for some resolvers |
| `cname_dangling` | warning | CNAME to a name that doesn't exist in the zone |
| `dname_multiple`, `below_dname` | error | One DNAME per name, and nothing below a DNAME (RFC 6672) |
| `apex_ns_missing` | error | A zone needs NS records at its apex |
+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
options (`allow-transfer { key jarl-view-external; }`).
**Moving between views.** A zone's page lists the other views that have a
zone of the same name ("also in external", in each view's colour). Each
one links straight to that zone, on the tab you are on.
## Linked zones
Most split-horizon zones are the same in every view apart from a few
@@ -183,7 +187,9 @@ own and its own zone options.
the web UI). A local record overrides the shared records of the same name
and type in that view: `www A 10.0.10.5` local to internal, while the
external view keeps the shared `www A 203.0.113.20`. Delete the local
records and the 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
is local in its view, shared otherwise.
* **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
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
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
the records like any others: shared, unless the name and type is local
@@ -235,7 +244,10 @@ before it).
## Comparing views
A zone's **Compare views** tab, or `jarl zone diff`, compares it with the
same zone in another view (or all of them), from the view you're on:
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
+46 -1
View File
@@ -34,6 +34,9 @@ type Zone struct {
LinkedWith []string `json:"linked_with,omitempty"`
// Links are the zones this one is linked with, with their views.
Links []ZoneLink `json:"links,omitempty"`
// InViews are the zones of this name in the other views (any type),
// in view order: for going straight to one.
InViews []ZoneLink `json:"in_views,omitempty"`
// OtherViews are the other views with a primary zone of this name
// (linked or not): what GET /zones/{zone}/compare compares.
OtherViews []string `json:"other_views,omitempty"`
@@ -42,6 +45,15 @@ type Zone struct {
// (POST /zones/{zone}/fill-ptr adds them).
Prefix string `json:"prefix,omitempty"`
PTRMissing int `json:"ptr_missing,omitempty"`
// PTRMissingList are the first 25 of them: the PTR records adding
// them would create.
PTRMissingList []PTRFill `json:"ptr_missing_list,omitempty"`
// PTROtherViews counts the addresses in it, without a PTR record,
// that only other views' records use: not missing, but fill-ptr with
// other_views=1 adds them, naming them from there. PTROtherViewsList
// are the first 25 (From says which view).
PTROtherViews int `json:"ptr_other_views,omitempty"`
PTROtherViewsList []PTRFill `json:"ptr_other_views_list,omitempty"`
// Pending is set when listing with a changeset: add, change or delete.
Pending string `json:"pending,omitempty"`
}
@@ -67,6 +79,21 @@ type View struct {
model.View
Zones int `json:"zone_count"`
Color string `json:"color,omitempty"` // web UI colour, see model.ViewColors
// Limited is set for a view listed only because the principal can see
// zones in it (no views:read): only its name, colour and the zones
// they can see are given.
Limited bool `json:"limited,omitempty"`
}
// PTRFill is a PTR record a reverse zone lacks: Name (the PTR owner) for
// Address would give Target. Others are its other names; From the view
// whose record names it, when the zone's own view has none.
type PTRFill struct {
Address string `json:"address"`
Name string `json:"name"`
Target string `json:"target"`
Others []string `json:"others,omitempty"`
From string `json:"from,omitempty"`
}
// ViewColor sets the colour the web UI shows a view in.
@@ -399,6 +426,9 @@ type UserInput struct {
Role *string `json:"role,omitempty"` // a role template name
Disabled *bool `json:"disabled,omitempty"`
Comment *string `json:"comment,omitempty"`
// CertLimit is how many certificates the user may own (jarl cert
// get); a negative number goes back to certificates.max_per_user.
CertLimit *int `json:"cert_limit,omitempty"`
}
// TokenInput creates a token.
@@ -465,8 +495,20 @@ type DNSSECResponse struct {
View string `json:"view,omitempty"`
Type string `json:"type"`
*dnssec.Status
// DSAt says where the zone's DS record goes: "parent_zone" (a primary
// zone in Jarl, below), "registrar" (the zone is a registered domain),
// "parent" (a subdomain whose parent zone is hosted elsewhere),
// "reverse" (whoever delegates the addresses) or "none" (nothing on
// the internet delegates the zone, so it can't have one).
DSAt string `json:"ds_at"`
// DSPlace completes "add the DS record …" ("at your registrar"); ""
// for "none".
DSPlace string `json:"ds_place,omitempty"`
// DSNote explains DSAt "none".
DSNote string `json:"ds_note,omitempty"`
// ParentZone is set when the parent zone is a primary zone in Jarl
// (in the same view): the DS record then goes there, as a record.
// (in the zone's own view, else preferably a public one): the DS
// record then goes there, as a record.
ParentZone string `json:"parent_zone,omitempty"`
ParentZoneID int64 `json:"parent_zone_id,omitempty"`
ParentView string `json:"parent_view,omitempty"`
@@ -616,6 +658,9 @@ type BlockRule struct {
type ZoneLink struct {
ID int64 `json:"id"`
View string `json:"view"`
// Type and Linked are set in Zone.InViews.
Type string `json:"type,omitempty"`
Linked bool `json:"linked,omitempty"`
}
// EncDNSSummary is what the dashboard says about encrypted DNS.
+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
ResStats = "stats" // query statistics
ResBlocking = "blocking" // blocklists and block/allow rules
ResAll = "*"
// ResCerts is certificates for other hosts (jarl cert get), by the
// names in them. It is never part of "*": it hands out private keys.
ResCerts = "certificates"
ResAll = "*"
)
// Resources lists the resources in display order.
var Resources = []string{ResZones, ResRecords, ResViews, ResACLs, ResTSIG, ResOptions, ResAudit, ResStats, ResBlocking}
var Resources = []string{ResZones, ResRecords, ResViews, ResACLs, ResTSIG, ResOptions, ResAudit, ResStats, ResBlocking, ResCerts}
// Access levels.
const (
@@ -74,8 +77,11 @@ func ValidateGrant(g *model.Grant) error {
for i, t := range g.Types {
g.Types[i] = strings.ToUpper(t)
}
if (len(g.Names) > 0 || len(g.Types) > 0) && g.Resource != ResRecords {
return fmt.Errorf("names and types filters only apply to the records resource")
if len(g.Types) > 0 && g.Resource != ResRecords {
return fmt.Errorf("the types filter only applies to the records resource")
}
if len(g.Names) > 0 && g.Resource != ResRecords && g.Resource != ResCerts {
return fmt.Errorf("the names filter only applies to the records and certificates resources")
}
if (len(g.Zones) > 0 || len(g.Views) > 0) && g.Resource != ResRecords && g.Resource != ResZones && g.Resource != ResViews {
return fmt.Errorf("zone and view filters only apply to the zones, records and views resources")
@@ -228,6 +234,9 @@ func GrantAllows(g model.Grant, a Action) bool {
if g.Resource != ResAll && g.Resource != a.Resource {
return false
}
if g.Resource == ResAll && a.Resource == ResCerts {
return false // only an explicit certificates grant
}
if a.Write && g.Access != Write {
return false
}
@@ -382,4 +391,7 @@ var RoleTemplates = map[string][]model.Grant{
},
"operator": {{Resource: ResAll, Access: Write}},
"acme": {{Resource: ResRecords, Access: Write, Names: []string{"_acme-challenge.*"}, Types: []string{"TXT"}}},
// certificates for other hosts (jarl cert get): narrow it with names=
// to the host's own.
"certificates": {{Resource: ResCerts, Access: Write}},
}
+130
View File
@@ -52,6 +52,9 @@ type Config struct {
PropagationTimeout time.Duration
// SkipPropagationCheck is !CheckPropagation (set by New).
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.
@@ -132,6 +135,8 @@ type Input struct {
Challenge *string `json:"challenge"`
KeyType *string `json:"key_type"`
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}$`)
@@ -210,6 +215,9 @@ func (m *Manager) apply(c *model.Certificate, in Input, creating bool) error {
if in.Comment != nil {
c.Comment = *in.Comment
}
if in.Owner != nil {
c.OwnerID = *in.Owner
}
switch c.Source {
case model.CertSourceACME:
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
}
// 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;
// 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) {
+5
View File
@@ -51,6 +51,10 @@ type Certificates struct {
// HTTPS): one directory per certificate, readable by bind.file_group.
// "" is <bind.config_dir>/tls.
Dir string `yaml:"dir"`
// MaxPerUser is how many certificates a user may own through jarl
// cert get (certificates for other hosts), unless the user has a
// limit of their own (jarl user update --cert-limit). Default 10.
MaxPerUser int `yaml:"max_per_user"`
}
// Metrics configures the Prometheus endpoint /metrics (docs/metrics.md).
@@ -467,6 +471,7 @@ func Default() *Config {
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,
},
Certificates: Certificates{MaxPerUser: 10},
ACME: ACME{HTTPReq: HTTPReq{RequireHTTPS: true}, Account: ACMEAccount{Directory: "letsencrypt"},
Resolvers: []string{"1.1.1.1:53", "9.9.9.9:53", "8.8.8.8:53"}, CheckPropagation: true},
Blocking: Blocking{Refresh: Duration(24 * time.Hour), MaxDownloadMB: 64, MaxEntries: 1_000_000},
+23
View File
@@ -11,6 +11,7 @@ import (
"github.com/miekg/dns"
"golang.org/x/net/idna"
"golang.org/x/net/publicsuffix"
)
// Root is the root domain name.
@@ -305,6 +306,28 @@ func EmailFromMailbox(rname string) string {
return local + "@" + strings.Join(labels[1:], ".")
}
// RegisteredDomain returns the domain registered at a registrar that name
// is in, without a trailing dot: adbraun.lol for lab.adbraun.lol, from
// the public suffix list. ok is false when no registrar is involved:
// reverse zones and the rest of .arpa (delegated by whoever holds the
// addresses), names under a suffix the list doesn't know (home.lan,
// internal) and public suffixes themselves (co.uk).
func RegisteredDomain(name string) (string, bool) {
n := strings.ToLower(strings.TrimSuffix(name, "."))
if n == "" || n == "arpa" || strings.HasSuffix(n, ".arpa") {
return "", false
}
suffix, icann := publicsuffix.PublicSuffix(n)
if !icann || suffix == n {
return "", false
}
d, err := publicsuffix.EffectiveTLDPlusOne(n)
if err != nil {
return "", false
}
return d, true
}
// IsReverse reports whether name is in in-addr.arpa or ip6.arpa.
func IsReverse(name string) bool {
return IsSubdomain(name, "in-addr.arpa.") || IsSubdomain(name, "ip6.arpa.") || IsSubdomain(name, "ip6.int.")
+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)
}
}
}
+3
View File
@@ -330,6 +330,9 @@ func (e *Engine) Stage(ctx context.Context, csID int64, reqs []Request, p *auth.
if err != nil {
return nil, err
}
if len(ops) == 0 {
return nil, errf(KindBadRequest, "nothing to change")
}
intro := validate.Introduced(before, e.validate(work))
if validate.HasErrors(intro) {
return nil, &Error{Kind: KindValidation, Message: describeErrors(intro), Problems: validate.Errors(intro)}
+36
View File
@@ -462,3 +462,39 @@ func TestParseWarningsReachTheResult(t *testing.T) {
t.Errorf("ops = %+v", res.Ops)
}
}
func TestUnchangedZoneUpdateStagesNothing(t *testing.T) {
env := newEnv(t)
setupZone(env)
env.submit(env.admin, NewRequest(OpZoneCreate, 0, ZoneInput{Name: model.Ptr("wg.example.com")}))
z := env.state().FindZone("wg.example.com.", 0)
if z.SOA.MName != "ns1.example.com." {
env.submit(env.admin, NewRequest(OpZoneUpdate, z.ID, ZoneInput{SOA: &SOAInput{MName: model.Ptr("ns1.example.com.")}}))
z = env.state().FindZone("wg.example.com.", 0)
}
cs, err := env.e.CreateChangeset(env.ctx, env.admin, "web", "")
if err != nil {
t.Fatal(err)
}
// The settings form saved as it was.
same := ZoneInput{Comment: model.Ptr(z.Comment), DefaultTTL: model.Ptr(z.DefaultTTL), SerialPolicy: model.Ptr(z.SerialPolicy),
SOA: &SOAInput{RName: model.Ptr("hostmaster@example.com"), Refresh: model.Ptr(z.SOA.Refresh), Retry: model.Ptr(z.SOA.Retry),
Expire: model.Ptr(z.SOA.Expire), Minimum: model.Ptr(z.SOA.Minimum)}}
if z.SOA.RName != "hostmaster.example.com." {
same.SOA.RName = nil
}
_, err = env.e.Stage(env.ctx, cs.ID, []Request{NewRequest(OpZoneUpdate, z.ID, same)}, env.admin)
if e, ok := AsError(err); !ok || e.Kind != KindBadRequest || e.Message != "nothing to change" {
t.Fatalf("unchanged update: %v", err)
}
if e := env.submitErr(env.admin, NewRequest(OpZoneUpdate, z.ID, same)); e.Message != "nothing to do" {
t.Errorf("unchanged update applied: %+v", e)
}
// A real change still stages, and an absolute MNAME stays absolute.
if _, err := env.e.Stage(env.ctx, cs.ID, []Request{NewRequest(OpZoneUpdate, z.ID, ZoneInput{Comment: model.Ptr("the VPN")})}, env.admin); err != nil {
t.Fatal(err)
}
if got := env.state().FindZone("wg.example.com.", 0).SOA.MName; got != "ns1.example.com." {
t.Errorf("mname = %q", got)
}
}
+293 -211
View File
@@ -5,6 +5,7 @@ import (
"encoding/json"
"fmt"
"net/netip"
"reflect"
"slices"
"strings"
"time"
@@ -31,6 +32,12 @@ type expander struct {
// exposures classifies the views as public or private (nil in tests:
// every view private).
exposures func(*model.State) []validate.Exposure
// ptrForced are the address sets whose PTR records the request being
// expanded asked to update (ptrForce).
ptrForced map[rrKey]bool
// sharing are the local sets the request deletes so that the shared
// version reaches them (dropOverrides).
sharing map[rrKey]bool
}
func (x *expander) ids(n int) ([]int64, error) {
@@ -69,11 +76,13 @@ func (x *expander) resolveZone(zoneID int64, zone, view string) (*model.Zone, er
if zone == "" {
return nil, errf(KindBadRequest, "the zone is missing (zone_id or zone)")
}
return FindZoneRef(x.st, zone, view)
return FindZoneRef(x.st, zone, view, func(z *model.Zone) bool { return x.p.CanSeeZone(z.Name, x.st.ViewName(z)) })
}
// FindZoneRef finds a zone by name and optional view name.
func FindZoneRef(st *model.State, zone, view string) (*model.Zone, error) {
// FindZoneRef finds a zone by name and optional view name. Without a
// view, a name in several views needs one, counting only the zones sees
// allows (nil: all): a token for one view finds that view's zone.
func FindZoneRef(st *model.State, zone, view string, sees func(*model.Zone) bool) (*model.Zone, error) {
name, err := ParseZoneName(zone)
if err != nil {
return nil, errf(KindBadRequest, "%v", err)
@@ -90,6 +99,16 @@ func FindZoneRef(st *model.State, zone, view string) (*model.Zone, error) {
return z, nil
}
zs := st.ZonesNamed(name)
if sees != nil && len(zs) > 1 {
var vis []*model.Zone
for _, z := range zs {
if sees(z) {
vis = append(vis, z)
}
}
zs = vis // none: not found, without naming the views
}
st.SortByView(zs)
switch len(zs) {
case 0:
return nil, errf(KindNotFound, "zone %s does not exist", dnsname.Display(name))
@@ -135,13 +154,20 @@ func dateSerial(now time.Time) uint32 {
}
func (x *expander) expand(req Request) ([]model.Op, error) {
x.ptrForced, x.sharing = nil, nil
ops, err := x.expandOne(req)
if err != nil {
return nil, err
}
switch req.Op {
case OpRecordCreate, OpRecordUpdate, OpRecordDelete, OpRRSetReplace, OpRecordDisable, OpRecordEnable, OpZoneFillPTR:
// Changes to linked zones reach the other zones of the link.
case OpRecordCreate, OpRecordUpdate, OpRecordDelete, OpRRSetReplace, OpRecordDisable, OpRecordEnable:
// Changes to linked zones reach the other zones of the link, then
// PTR records follow the addresses in each view.
if ops, err = x.linkSync(ops); err != nil {
return nil, err
}
return x.ptrSync(ops)
case OpZoneFillPTR:
return x.linkSync(ops)
}
return ops, nil
@@ -193,6 +219,11 @@ func (x *expander) expandOne(req Request) ([]model.Op, error) {
if in.LinkWith != "" {
return nil, errf(KindBadRequest, "link_with is for new zones; link existing ones with zone.link (jarl zone link)")
}
// An update that leaves the zone as it is stages nothing (a
// settings form saved without changes).
if zoneUnchanged(x.st, z, &in) {
return nil, nil
}
}
return ops, nil
case OpRecordCreate:
@@ -440,8 +471,8 @@ func (x *expander) zoneCreate(req Request) ([]model.Op, error) {
}
}
}
fill := in.Fill
in.Fill = false
fill, fillOther := in.Fill, in.FillOtherViews
in.Fill, in.FillOtherViews = false, false
if fill {
if zt != model.ZonePrimary {
return nil, errf(KindBadRequest, "fill only applies to primary zones")
@@ -453,10 +484,15 @@ func (x *expander) zoneCreate(req Request) ([]model.Op, error) {
if in.ViewID != nil {
vid = *in.ViewID
}
fills := PTRFills(x.st, vid, name, moved)
fills := PTRFills(x.st, vid, name, moved, fillOther)
if len(fills) == 0 {
x.warnings = append(x.warnings, fmt.Sprintf("no A or AAAA record of %s has an address in %s, so there was nothing to fill it with", viewOrServer(x.st, vid), dnsname.Display(name)))
}
if !fillOther {
if h := otherViewsHint(x.st, vid, name, moved, len(fills), "to add PTR records for them in this view too: jarl zone fill-ptr ZONE --other-views"); h != "" {
x.warnings = append(x.warnings, h)
}
}
x.warnings = append(x.warnings, fillWarnings(fills)...)
for _, f := range fills {
in.Records = append(in.Records, RecordInput{Name: model.Ptr(f.Name), Type: model.Ptr("PTR"), Data: model.Ptr(f.Target)})
@@ -751,65 +787,227 @@ func addrOf(p *rr.Parsed) (netip.Addr, bool) {
return netip.Addr{}, false
}
// ptrOps returns operations creating the PTR record for an address record.
//
// auto is set when the zone's auto_ptr asked rather than the request: a
// missing reverse zone isn't worth a warning then (most addresses have
// none), and an address whose PTR record names another host keeps it.
func (x *expander) ptrCreate(z *model.Zone, owner string, p *rr.Parsed, auto bool) ([]model.Op, error) {
addr, ok := addrOf(p)
if !ok {
return nil, errf(KindBadRequest, "ptr only applies to A and AAAA records")
// PTR records follow address records per view (docs/reverse.md): after a
// request's operations (and those keeping linked zones in step), ptrSync
// compares each touched A or AAAA set before and after, in every view it
// is in, and changes the PTR records of that view's reverse zone to match.
// So a record local to one view of a linked zone changes PTR records in
// that view only, and an address that goes from one view keeps its PTR
// record in the views that still have it.
// ptrForce marks a name and type whose PTR records the request asked to
// update (ptr: true), in its zone and the zones linked with it.
func (x *expander) ptrForce(z *model.Zone, name, typ string) {
if x.ptrForced == nil {
x.ptrForced = map[rrKey]bool{}
}
var ops []model.Op
found := false
for _, vid := range ptrViews(x.st, z) {
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...)
x.ptrForced[rrKey{z.ID, name, typ}] = true
for _, o := range x.st.LinkedWith(z) {
x.ptrForced[rrKey{o.ID, name, typ}] = true
}
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
// z's address records: z's own, and, for a linked zone, the other views
// of the link (the record is in all of them).
func ptrViews(st *model.State, z *model.Zone) []int64 {
out := []int64{z.ViewID}
for _, o := range st.LinkedWith(z) {
if !slices.Contains(out, o.ViewID) {
out = append(out, o.ViewID)
// Whether a set has an address: none, only in disabled records, enabled.
const (
addrNone = iota
addrDisabled
addrEnabled
)
// addrStates returns the addresses of a zone's A or AAAA set.
func addrStates(st *model.State, k rrKey) map[netip.Addr]int {
out := map[netip.Addr]int{}
for _, r := range st.RRset(k.zone, k.name, k.typ) {
a, err := netip.ParseAddr(r.Data)
if err != nil {
continue
}
s := addrDisabled
if !r.Disabled {
s = addrEnabled
}
out[a.Unmap()] = max(out[a.Unmap()], s)
}
return out
}
// ptrSync returns ops followed by the PTR changes they call for: for the
// zone's auto_ptr, or because the request asked (ptrForce).
func (x *expander) ptrSync(ops []model.Op) ([]model.Op, error) {
forced := x.ptrForced
x.ptrForced = nil
work := x.st.Clone()
c := &applyCtx{p: x.p, now: x.now}
for _, op := range ops {
if err := applyOp(work, op, c); err != nil {
return nil, err
}
}
keys := affectedKeys(x.st, work, ops)
for _, k := range sortedForced(forced) {
if !slices.Contains(keys, k) {
keys = append(keys, k)
}
}
var extra []model.Op
for _, k := range keys {
z := work.Zones[k.zone]
if z == nil || (k.typ != "A" && k.typ != "AAAA") || strings.HasPrefix(k.name, "*.") {
continue
}
force := forced[k]
if !force && !z.AutoPTR {
continue
}
before, after := addrStates(x.st, k), addrStates(work, k)
var addrs []netip.Addr
for _, m := range []map[netip.Addr]int{before, after} {
for a := range m {
if !slices.Contains(addrs, a) {
addrs = append(addrs, a)
}
}
}
slices.SortFunc(addrs, func(a, b netip.Addr) int { return a.Compare(b) })
for _, a := range addrs {
was, is := before[a], after[a]
if !force && (was == is || was == addrNone && is == addrDisabled) {
continue
}
rz, ptrName := reverseZoneFor(work, z.ViewID, a)
if rz == nil {
if force && is == addrEnabled {
x.warnings = append(x.warnings, fmt.Sprintf("no reverse zone for %s in %s, so no PTR record was added there (create one: jarl zone create %s/%d%s)",
a, viewOrServer(work, z.ViewID), a, a.BitLen(), viewFlag(work, z.ViewID)))
}
continue
}
more, err := x.ptrChange(work, rz, ptrName, k.name, is, !force)
if err != nil {
return nil, err
}
for _, op := range more {
if err := applyOp(work, op, c); err != nil {
return nil, err
}
}
extra = append(extra, more...)
}
}
if len(extra) == 0 {
return ops, nil
}
// The PTR records are local (see ptrChange), so this only keeps a
// linked reverse zone consistent.
extra, err := x.linkSync(extra)
if err != nil {
return nil, err
}
return append(ops, extra...), nil
}
func sortedForced(m map[rrKey]bool) []rrKey {
var out []rrKey
for k := range m {
out = append(out, k)
}
slices.SortFunc(out, func(a, b rrKey) int {
if a.zone != b.zone {
return int(a.zone - b.zone)
}
if a.name != b.name {
return strings.Compare(a.name, b.name)
}
return strings.Compare(a.typ, b.typ)
})
return out
}
func viewFlag(st *model.State, viewID int64) string {
if v := st.Views[viewID]; v != nil {
return " --view " + v.Name
}
return ""
}
// ptrChange returns the operations that give reverse zone rz's PTR
// records at ptrName for host owner the state of its address: none
// (deleted), disabled, or enabled (added when missing).
//
// In a linked reverse zone the PTR records are local to each view, since
// each view's follow its own address records: a shared set at ptrName
// is made local in every view of the link first, as each view has it.
func (x *expander) ptrChange(st *model.State, rz *model.Zone, ptrName, owner string, state int, auto bool) ([]model.Op, error) {
nwarn := len(x.warnings)
ops, err := x.ptrChangeIn(st, rz, ptrName, owner, state, auto)
if err != nil || len(ops) == 0 || rz.LinkGroup == 0 {
return ops, err
}
var local []model.Op
for _, m := range st.LinkMembers(rz.LinkGroup) {
for _, r := range st.RRset(m.ID, ptrName, "PTR") {
if r.SharedID != 0 {
local = append(local, newOp(OpRecordUpdate, r.ID, r.Version, RecordInput{SharedID: model.Ptr(int64(0))},
fmt.Sprintf("linked: %s PTR %s becomes local to %s (PTR records follow each view's own address records)", r.Name, r.Data, zoneDesc(st, m))))
}
}
}
if len(local) == 0 {
return ops, nil
}
// Again on the local records (their versions changed).
scratch := st.Clone()
c := &applyCtx{p: x.p, now: x.now}
for _, op := range local {
if err := applyOp(scratch, op, c); err != nil {
return nil, err
}
}
x.warnings = x.warnings[:nwarn]
ops, err = x.ptrChangeIn(scratch, rz, ptrName, owner, state, auto)
if err != nil {
return nil, err
}
return append(local, ops...), nil
}
func (x *expander) ptrChangeIn(st *model.State, rz *model.Zone, ptrName, owner string, state int, auto bool) ([]model.Op, error) {
var ops []model.Op
switch state {
case addrNone:
for _, r := range st.RRset(rz.ID, ptrName, "PTR") {
if strings.EqualFold(r.Data, owner) {
ops = append(ops, newOp(OpRecordDelete, r.ID, r.Version, nil, fmt.Sprintf("delete %s PTR %s", r.Name, r.Data)))
}
}
case addrDisabled:
for _, r := range st.RRset(rz.ID, ptrName, "PTR") {
if strings.EqualFold(r.Data, owner) && !r.Disabled {
ops = append(ops, newOp(OpRecordUpdate, r.ID, r.Version, RecordInput{Disabled: model.Ptr(true)}, fmt.Sprintf("disable %s PTR %s", r.Name, r.Data)))
}
}
default:
return x.ptrCreateIn(st, rz, ptrName, owner, auto)
}
return ops, nil
}
// ptrCreateIn returns operations creating the PTR record ptrName -> owner
// in reverse zone rz (none when it is there; enabling it when disabled).
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
for _, r := range x.st.ZoneRecords(rz.ID) {
if r.Name == ptrName && r.Type == "PTR" {
if strings.EqualFold(r.Data, owner) {
if r.Disabled {
in := RecordInput{Disabled: model.Ptr(false)}
return []model.Op{newOp(OpRecordUpdate, r.ID, r.Version, in, fmt.Sprintf("enable %s PTR %s", r.Name, r.Data))}, nil
}
return nil, nil // already there
}
if !r.Disabled {
other = r.Data
for _, r := range st.RRset(rz.ID, ptrName, "PTR") {
if strings.EqualFold(r.Data, owner) {
if r.Disabled {
in := RecordInput{Disabled: model.Ptr(false)}
return []model.Op{newOp(OpRecordUpdate, r.ID, r.Version, in, fmt.Sprintf("enable %s PTR %s", r.Name, r.Data))}, nil
}
return nil, nil // already there
}
if !r.Disabled {
other = r.Data
}
}
if other != "" {
@@ -824,38 +1022,12 @@ func (x *expander) ptrCreateIn(rz *model.Zone, ptrName, owner string, auto bool)
return nil, err
}
in := RecordInput{ZoneID: rz.ID, Name: model.Ptr(ptrName), Type: model.Ptr("PTR"), Data: model.Ptr(owner)}
if rz.LinkGroup != 0 {
in.SharedID = model.Ptr(int64(0))
}
return []model.Op{newOp(OpRecordCreate, ids[0], 0, in, fmt.Sprintf("add %s IN PTR %s", ptrName, owner))}, nil
}
// ptrSetDisabled returns operations disabling or enabling the PTR record
// of an address record.
func (x *expander) ptrSetDisabled(z *model.Zone, owner string, p *rr.Parsed, disabled bool) []model.Op {
addr, ok := addrOf(p)
if !ok {
return nil
}
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 {
if disabled {
return "disable"
@@ -863,23 +1035,6 @@ func enableWord(disabled bool) string {
return "enable"
}
func (x *expander) ptrDelete(z *model.Zone, owner string, p *rr.Parsed) []model.Op {
addr, ok := addrOf(p)
if !ok {
return nil
}
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).
// The stored operation holds the canonical data, so applying it doesn't
// notice again.
@@ -906,7 +1061,12 @@ func (x *expander) recordCreate(req Request) ([]model.Op, error) {
return nil, err
}
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.Name, in.Type, in.Data = &owner, &p.Type, &p.Data
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.
in.Local = nil
ops := []model.Op{newOp(OpRecordCreate, ids[0], 0, in, summary)}
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
return []model.Op{newOp(OpRecordCreate, ids[0], 0, in, summary)}, nil
}
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 {
ops = append(ops, newOp(OpRecordUpdate, r.ID, base, in, summary))
}
disabled := r.Disabled
if in.Disabled != nil {
disabled = *in.Disabled
}
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 ptr {
// The PTR records of the old and the new record, whichever is an
// address (ptrSync).
_, isAddr := addrOf(p)
if old, err := rr.Parse(z.Name, r.Name, r.Type, r.Data); err == nil {
ops = append(ops, x.ptrDelete(z, r.Name, old)...)
}
if !disabled {
more, err := x.ptrCreate(z, owner, p, false)
if err != nil {
return nil, err
if _, ok := addrOf(old); ok {
isAddr = true
x.ptrForce(z, r.Name, r.Type)
}
ops = append(ops, more...)
}
}
if ptr && !moved && toggled {
if _, ok := addrOf(p); !ok {
if !isAddr {
return nil, errf(KindBadRequest, "ptr only applies to A and AAAA records")
}
ops = append(ops, x.ptrSetDisabled(z, owner, p, disabled)...)
}
if 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
if _, ok := addrOf(p); ok {
x.ptrForce(z, owner, p.Type)
}
ops = append(ops, more...)
}
return ops, nil
}
@@ -1146,9 +1253,8 @@ func (x *expander) recordDelete(req Request) ([]model.Op, error) {
base = r.Version
}
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 p, err := rr.Parse(z.Name, r.Name, r.Type, r.Data); err == nil && (in.PTR || autoPTR(z, r.Name, p)) {
ops = append(ops, x.ptrDelete(z, r.Name, p)...)
if in.PTR {
x.ptrForce(x.st.Zones[r.ZoneID], r.Name, r.Type)
}
return ops, nil
}
@@ -1184,25 +1290,12 @@ func (x *expander) recordDelete(req Request) ([]model.Op, error) {
if in.MissingOK {
what += " (if present)"
}
ptr := in.PTR
if in.PTR && parsed != nil {
x.ptrForce(z, owner, typ)
}
in.ZoneID, in.Zone, in.View, in.PTR = z.ID, "", "", false
in.Name, in.Type = &owner, &typ
ops := []model.Op{newOp(OpRecordDelete, 0, 0, in, "delete "+what)}
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
return []model.Op{newOp(OpRecordDelete, 0, 0, in, "delete "+what)}, nil
}
// 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 {
summary = fmt.Sprintf("delete all %s records of %s%s", typ, owner, where)
}
ops := append(pre, newOp(OpRRSetReplace, 0, 0, in, summary))
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
return append(pre, newOp(OpRRSetReplace, 0, 0, in, summary)), nil
}
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 {
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)
}
+12 -2
View File
@@ -163,8 +163,11 @@ type ZoneInput struct {
// step (docs/reverse.md).
AutoPTR *bool `json:"auto_ptr,omitempty"`
// Fill (create only, reverse zones) adds a PTR record for every
// address of the view's A and AAAA records in the zone's range.
Fill bool `json:"fill,omitempty"`
// address of the view's A and AAAA records in the zone's range;
// FillOtherViews also for the addresses only other views' records
// use, named from there.
Fill bool `json:"fill,omitempty"`
FillOtherViews bool `json:"fill_other_views,omitempty"`
// Create only: name servers for the apex NS records (default: the
// configured default name servers), additional records, and a zone to
@@ -224,6 +227,13 @@ type LinkInput struct {
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.
type RRSetInput struct {
ZoneID int64 `json:"zone_id,omitempty"`
+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
// keep theirs.
// - z's override was just removed: z gets the shared records back from
// a zone that has them.
// - z's override was just removed: z goes without the name and type,
// and the other zones' shared copies become local to each.
// - otherwise z's shared records are the truth: every zone that doesn't
// override the name and type gets the same (created, updated or
// deleted to match).
@@ -123,24 +123,40 @@ func (x *expander) syncKey(st *model.State, z *model.Zone, name, typ string) ([]
}
truth := st.SharedRecords(z.ID, name, typ)
members := st.LinkedWith(z)
if len(truth) == 0 && x.st.HasLocal(z.ID, name, typ) && x.sharing[rrKey{z.ID, name, typ}] {
// Another view's version was shared (dropOverrides): z gets it.
for _, o := range members {
if src := st.SharedRecords(o.ID, name, typ); len(src) > 0 && !st.HasLocal(o.ID, name, typ) {
for _, r := range src {
op, err := x.copyRecord(st, z, r, "linked: "+name+" "+typ+" is shared again in "+where(z)+": add "+r.Data)
if err != nil {
return nil, err
}
ops = append(ops, op)
}
break
}
}
return ops, nil
}
if len(truth) == 0 && x.st.HasLocal(z.ID, name, typ) {
// 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 {
if st.HasLocal(o.ID, name, typ) {
continue
}
src := st.SharedRecords(o.ID, name, typ)
if len(src) == 0 {
continue
var more []model.Op
for _, r := range st.SharedRecords(o.ID, name, typ) {
more = append(more, newOp(OpRecordUpdate, r.ID, r.Version, RecordInput{SharedID: model.Ptr(int64(0))},
fmt.Sprintf("linked: %s %s %s stays in %s, local to it now (%s has none)", name, typ, r.Data, where(o), where(z))))
}
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)
if len(more) > 0 && !x.p.Can(recordAction(st, o, name, typ, true)) {
return nil, errf(KindForbidden, "deleting %s %s from %s makes it local in %s, which you may not change; ask someone who may to delete it, or share %s's version (jarl record update ID --shared)",
dnsname.Display(name), typ, where(z), where(o), where(o))
}
break
ops = append(ops, more...)
}
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:
// 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) {
has := x.st.HasLocal(z.ID, name, typ)
if want == nil {
return has, nil
if want != nil {
return *want, nil
}
return *want, nil
if x.st.HasLocal(z.ID, name, typ) {
return true, nil
}
if len(x.st.SharedRecords(z.ID, name, typ)) > 0 {
return false, nil
}
var not []string
for _, o := range x.st.LinkedWith(z) {
if !x.p.Can(recordAction(x.st, o, name, typ, true)) {
not = append(not, x.st.ViewName(o))
}
}
if len(not) > 0 {
x.warnings = append(x.warnings, fmt.Sprintf("%s %s is local to view %s: you may not change it in %s", dnsname.Display(name), typ, x.st.ViewName(z), strings.Join(not, ", ")))
return true, nil
}
return false, nil
}
// shareAllOps returns the operations that start a link with zone z: it
@@ -468,6 +502,10 @@ func (x *expander) dropOverrides(z *model.Zone, name, typ string) []model.Op {
for _, o := range x.st.LinkedWith(z) {
for _, r := range x.st.RRset(o.ID, name, typ) {
if r.SharedID == 0 {
if x.sharing == nil {
x.sharing = map[rrKey]bool{}
}
x.sharing[rrKey{o.ID, name, typ}] = true
ops = append(ops, newOp(OpRecordDelete, r.ID, r.Version, nil, fmt.Sprintf("linked: %s %s %s in %s is replaced by the shared version", r.Name, r.Type, r.Data, zoneDesc(x.st, o))))
}
}
+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") {
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{}}))
if !slices.Contains(env.view("internal"), "www A 192.0.2.11 S") {
t.Errorf("override removed: internal %q", env.view("internal"))
if slices.ContainsFunc(env.view("internal"), func(s string) bool { return strings.HasPrefix(s, "www A ") }) || !slices.Contains(env.view("external"), "www A 192.0.2.11 L") {
t.Errorf("override removed: internal %q external %q", env.view("internal"), env.view("external"))
}
env.submit(env.admin, NewRequest(OpRecordUpdate, env.recordID("external", "www", "A"), RecordInput{Local: model.Ptr(false)}))
if !slices.Contains(env.view("internal"), "www A 192.0.2.11 S") || !slices.Contains(env.view("external"), "www A 192.0.2.11 S") {
t.Errorf("shared again: internal %q external %q", env.view("internal"), env.view("external"))
}
// Make a shared set local, then share another view's version.
@@ -386,3 +391,59 @@ func TestCompareReport(t *testing.T) {
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)
}
}
+38 -22
View File
@@ -8,7 +8,6 @@ import (
"jarl/internal/dnsname"
"jarl/internal/model"
"jarl/internal/rr"
"jarl/internal/validate"
)
@@ -88,11 +87,12 @@ type PTRFill struct {
// PTRFills returns the PTR records a reverse zone named zoneName in a view
// lacks for the addresses of the view's A and AAAA records: one per
// address, naming its mail or name server host when it is one, otherwise
// a host rather than the domain itself, then the shortest name. An
// address no record of the view uses is named from another view's
// records (fillAddresses), marked From. existing are the zone's records
// (none for a new zone).
func PTRFills(st *model.State, viewID int64, zoneName string, existing []*model.Record) []PTRFill {
// a host rather than the domain itself, then the shortest name. With
// otherViews, an address no record of the view uses is named from another
// view's records (fillAddresses), marked From; that is only on request,
// since it publishes one view's host names in another. existing are the
// zone's records (none for a new zone).
func PTRFills(st *model.State, viewID int64, zoneName string, existing []*model.Record, otherViews bool) []PTRFill {
prefix, ok := dnsname.PrefixFromReverse(zoneName)
if !ok {
return nil
@@ -106,7 +106,11 @@ func PTRFills(st *model.State, viewID int64, zoneName string, existing []*model.
servers := serverNames(st, viewID)
byAddr := map[netip.Addr][]addrUse{}
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) {
continue
}
@@ -196,9 +200,23 @@ func displayAll(names []string) []string {
return out
}
// otherViewsHint says how many addresses only other views' records use in
// a reverse zone, and how to add them; "" when there are none.
func otherViewsHint(st *model.State, viewID int64, zoneName string, existing []*model.Record, own int, how string) string {
n := len(PTRFills(st, viewID, zoneName, existing, true)) - own
if n <= 0 {
return ""
}
what, has, they := "addresses", "have", "they are"
if n == 1 {
what, has, they = "address", "has", "it is"
}
return fmt.Sprintf("%d %s in %s %s A or AAAA records only in other views, so %s not missing here; %s", n, what, dnsname.Display(zoneName), has, they, how)
}
// zoneFillPTR expands zone.fill_ptr: the PTR records a reverse zone lacks.
func (x *expander) zoneFillPTR(req Request) ([]model.Op, error) {
var in LinkInput
var in FillPTRInput
if err := decode(req.Data, &in); err != nil {
return nil, err
}
@@ -216,9 +234,15 @@ func (x *expander) zoneFillPTR(req Request) ([]model.Op, error) {
if _, ok := dnsname.PrefixFromReverse(z.Name); !ok {
return nil, errf(KindBadRequest, "zone %s isn't a reverse zone of whole octets or nibbles (like 10.0.10.in-addr.arpa)", zoneDesc(x.st, z))
}
fills := PTRFills(x.st, z.ViewID, z.Name, x.st.ZoneRecords(z.ID))
fills := PTRFills(x.st, z.ViewID, z.Name, x.st.ZoneRecords(z.ID), in.OtherViews)
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)...)
ids, err := x.ids(len(fills))
@@ -227,9 +251,11 @@ func (x *expander) zoneFillPTR(req Request) ([]model.Op, error) {
}
var ops []model.Op
for i, f := range fills {
// Local to the view in a linked zone, like the PTR records that
// follow address records (ptrChange).
ri := RecordInput{ZoneID: z.ID, Name: model.Ptr(f.Name), Type: model.Ptr("PTR"), Data: model.Ptr(f.Target)}
if z.LinkGroup != 0 {
ri.SharedID = model.Ptr(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)))
}
@@ -414,7 +440,7 @@ func ReverseCandidates(st *model.State, exposures []validate.Exposure, local []n
continue
}
s := ReverseSuggestion{ViewID: sc.id, View: viewName(st, sc.id), Prefix: p.String(), Zone: strings.TrimSuffix(name, "."), Group: group, Hidden: hidden, Why: why, Samples: []string{}}
fills := PTRFills(st, sc.id, name, nil)
fills := PTRFills(st, sc.id, name, nil, opt.All && opt.View != "")
s.Fill = len(fills)
for i, f := range fills {
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")
// 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 {
if v := st.Views[id]; v != nil {
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")}))
st := env.state()
z6 := st.FindZone("b.a.b.a.b.a.b.a.b.a.d.f.ip6.arpa.", st.ViewByName("internal").ID)
env.submit(env.admin, NewRequest(OpZoneFillPTR, z6.ID, 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.") {
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)
}
@@ -288,18 +288,140 @@ func TestReverseFromOtherView(t *testing.T) {
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}))
st := env.state()
var zone string
for _, z := range st.ZonesInView(st.ViewByName("internal").ID) {
if strings.HasSuffix(z.Name, ".ip6.arpa.") {
zone = z.Name
for _, s := range ReverseCandidates(env.state(), env.e.Exposures(env.state()), nil, ReverseOptions{All: true, View: "internal"}) {
if strings.HasPrefix(s.Prefix, "2600:") && (s.Fill != 1 || len(s.Samples) != 1) {
t.Errorf("candidate counts the other view's address: %+v", s)
}
}
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)
}
if !slices.ContainsFunc(res.Warnings, func(w string) bool { return strings.Contains(w, "only view external has a record for it") }) {
t.Errorf("warnings %q", res.Warnings)
}
}
// ptrsMarked is ptrs with S (shared) or L (local) after each record.
func (env *testEnv) ptrsMarked(view, zone string) []string {
env.t.Helper()
s := env.state()
z := s.FindZone(zone, s.ViewByName(view).ID)
var out []string
for _, r := range s.ZoneRecords(z.ID) {
if r.Type == "PTR" {
mark := " L"
if r.SharedID != 0 {
mark = " S"
}
out = append(out, strings.TrimSuffix(r.Name, ".10.0.10.in-addr.arpa.")+" "+strings.TrimSuffix(r.Data, ".example.com.")+mark)
}
}
return out
}
// Auto-PTR with linked forward and reverse zones: PTR records follow each
// view's own address records, and are local to their view.
func TestAutoPTRLinked(t *testing.T) {
env := linkedEnv(t)
env.submit(env.admin, NewRequest(OpZoneCreate, 0, ZoneInput{Name: model.Ptr("example.com"), View: model.Ptr("external"), LinkWith: "internal"}))
env.submit(env.admin, NewRequest(OpZoneCreate, 0, ZoneInput{Name: model.Ptr("10.0.10.0/24"), View: model.Ptr("internal")}))
env.submit(env.admin, NewRequest(OpZoneCreate, 0, ZoneInput{Name: model.Ptr("10.0.10.0/24"), View: model.Ptr("external"), LinkWith: "internal"}))
st := env.state()
for _, v := range []string{"internal", "external"} {
env.submit(env.admin, NewRequest(OpZoneUpdate, st.FindZone("example.com.", st.ViewByName(v).ID).ID, ZoneInput{AutoPTR: model.Ptr(true)}))
}
rz := "10.0.10.in-addr.arpa."
local := func(view, name, data string) Request {
r := recIn(view, name, "A", data)
var in RecordInput
_ = decode(r.Data, &in)
in.Local = model.Ptr(true)
return NewRequest(OpRecordCreate, 0, in)
}
// A local record: a local PTR record in its own view only.
env.submit(env.admin, local("internal", "a", "10.0.10.1"))
want(t, "local a internal", env.ptrsMarked("internal", rz), "1 a L")
want(t, "local a external", env.ptrsMarked("external", rz))
// The other view adds the same name (shared, as it has no local
// one): its own PTR record; deleting it in one view leaves the other.
env.submit(env.admin, recIn("external", "a", "A", "10.0.10.1"))
want(t, "both internal", env.ptrsMarked("internal", rz), "1 a L")
want(t, "both external", env.ptrsMarked("external", rz), "1 a L")
env.submit(env.admin, NewRequest(OpRecordDelete, env.recordID("external", "a", "A"), nil))
want(t, "deleted in external: internal", env.ptrsMarked("internal", rz), "1 a L")
want(t, "deleted in external: external", env.ptrsMarked("external", rz))
// Both local; one changes its address, the other keeps its PTR.
env.submit(env.admin, local("external", "a", "10.0.10.1"))
env.submit(env.admin, NewRequest(OpRecordUpdate, env.recordID("internal", "a", "A"), RecordInput{Data: model.Ptr("10.0.10.2")}))
want(t, "moved internal", env.ptrsMarked("internal", rz), "2 a L")
want(t, "moved external", env.ptrsMarked("external", rz), "1 a L")
// Disabling in one view disables there only.
env.submit(env.admin, NewRequest(OpRecordUpdate, env.recordID("external", "a", "A"), RecordInput{Disabled: model.Ptr(true)}))
want(t, "disabled external", env.ptrs("external", rz), "1.10.0.10.in-addr.arpa. a.example.com. disabled")
want(t, "disabled: internal", env.ptrsMarked("internal", rz), "2 a L")
env.submit(env.admin, NewRequest(OpRecordDelete, env.recordID("external", "a", "A"), nil))
env.submit(env.admin, NewRequest(OpRecordDelete, env.recordID("internal", "a", "A"), nil))
want(t, "gone internal", env.ptrsMarked("internal", rz))
want(t, "gone external", env.ptrsMarked("external", rz))
// A shared record: a local PTR in each view; deleting it removes both.
env.submit(env.admin, recIn("internal", "b", "A", "10.0.10.3"))
want(t, "shared internal", env.ptrsMarked("internal", rz), "3 b L")
want(t, "shared external", env.ptrsMarked("external", rz), "3 b L")
// Overridden in one view with another address: that view's PTR moves.
env.submit(env.admin, NewRequest(OpRecordUpdate, env.recordID("external", "b", "A"), RecordInput{Local: model.Ptr(true), Data: model.Ptr("10.0.10.4")}))
want(t, "override internal", env.ptrsMarked("internal", rz), "3 b L")
want(t, "override external", env.ptrsMarked("external", rz), "4 b L")
// Sharing internal's version: external gets it back, and its PTR.
env.submit(env.admin, NewRequest(OpRecordUpdate, env.recordID("internal", "b", "A"), RecordInput{Local: model.Ptr(false)}))
want(t, "shared again internal", env.ptrsMarked("internal", rz), "3 b L")
want(t, "shared again external", env.ptrsMarked("external", rz), "3 b L")
// The override deleted: external goes without b (and its PTR);
// internal keeps it, local now.
env.submit(env.admin, NewRequest(OpRecordUpdate, env.recordID("external", "b", "A"), RecordInput{Local: model.Ptr(true), Data: model.Ptr("10.0.10.4")}))
env.submit(env.admin, NewRequest(OpRecordDelete, env.recordID("external", "b", "A"), nil))
want(t, "override gone internal", env.ptrsMarked("internal", rz), "3 b L")
want(t, "override gone external", env.ptrsMarked("external", rz))
env.submit(env.admin, NewRequest(OpRecordDelete, env.recordID("internal", "b", "A"), nil))
want(t, "b gone internal", env.ptrsMarked("internal", rz))
// A shared PTR record from before (shared by hand) becomes local in
// each view when one view's address goes.
env.submit(env.admin, recIn("internal", "c", "A", "10.0.10.5"))
env.submit(env.admin, NewRequest(OpRecordDelete, 0, RecordInput{Zone: rz, View: "internal", Name: model.Ptr("5"), Type: model.Ptr("PTR")}))
env.submit(env.admin, NewRequest(OpRecordDelete, 0, RecordInput{Zone: rz, View: "external", Name: model.Ptr("5"), Type: model.Ptr("PTR")}))
env.submit(env.admin, NewRequest(OpRecordCreate, 0, RecordInput{Zone: rz, View: "internal", Name: model.Ptr("5"), Type: model.Ptr("PTR"), Data: model.Ptr("c.example.com."), Local: model.Ptr(false)}))
want(t, "shared ptr external", env.ptrsMarked("external", rz), "5 c S")
env.submit(env.admin, NewRequest(OpRecordUpdate, env.recordID("external", "c", "A"), RecordInput{Local: model.Ptr(true), Data: model.Ptr("10.0.10.6")}))
want(t, "unshared internal", env.ptrsMarked("internal", rz), "5 c L")
want(t, "unshared external", env.ptrsMarked("external", rz), "6 c L")
}
+1 -1
View File
@@ -204,7 +204,7 @@ func (s *State) SortedBlockRules() []*BlockRule {
sort.Slice(out, func(i, j int) bool {
a, b := out[i], out[j]
if a.Name != b.Name {
return CanonicalLess(a.Name+".", b.Name+".")
return NameLess(a.Name+".", b.Name+".")
}
if a.Exact != b.Exact {
return !a.Exact
+18 -1
View File
@@ -39,7 +39,8 @@ type Certificate struct {
Comment string `json:"comment"`
// 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:"-"`
KeyPEM string `json:"-"`
@@ -61,11 +62,27 @@ type Certificate struct {
// LastRoutes says where the challenges went the last time ("adbraun.lol: porkbun").
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"`
UpdatedAt time.Time `json:"updated_at"`
CreatedBy string `json:"created_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.
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:"-"`
TOTPPending string `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.
+41 -10
View File
@@ -524,14 +524,14 @@ func (s *State) SortedZones() []*Zone {
if pi, pj := pos[out[i].ViewID], pos[out[j].ViewID]; pi != pj {
return pi < pj
}
return CanonicalLess(out[i].Name, out[j].Name)
return NameLess(out[i].Name, out[j].Name)
})
return out
}
// SortZones sorts zones by name in DNS canonical order.
// SortZones sorts zones by name (see NameLess).
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).
@@ -646,7 +646,7 @@ func typeOrder(t string) string {
}
// 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.
func SortRecords(rs []*Record, z *Zone) {
apex := ""
@@ -662,7 +662,7 @@ func SortRecords(rs []*Record, z *Zone) {
if b.Name == apex {
return false
}
return CanonicalLess(a.Name, b.Name)
return NameLess(a.Name, b.Name)
}
if a.Type != b.Type {
return typeOrder(a.Type) < typeOrder(b.Type)
@@ -674,19 +674,50 @@ func SortRecords(rs []*Record, z *Zone) {
})
}
// CanonicalLess compares names in DNS canonical order (RFC 4034 section
// 6.1): by label from the right.
func CanonicalLess(a, b string) bool {
// NameLess compares names label by label from the right, like DNS
// canonical order (RFC 4034 section 6.1), except that two all-digit
// 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, "."), ".")
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 {
if la[i] != lb[j] {
return la[i] < lb[j]
if c := compareLabel(la[i], lb[j]); c != 0 {
return c < 0
}
}
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.
type RRSetKey struct {
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())
if err != nil {
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())
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 {
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)
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 {
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
return m
}
@@ -165,3 +165,39 @@ func TestHTTPSAndPlainOnOnePort(t *testing.T) {
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 {
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)
w.Header().Set("WWW-Authenticate", httpreqRealm)
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 {
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 == "" {
u.PasswordHash = "" // service account: tokens only
+253 -5
View File
@@ -2,8 +2,13 @@ package server
import (
"context"
"encoding/pem"
"errors"
"fmt"
"net"
"net/http"
"net/netip"
"strings"
"time"
"jarl/internal/api"
@@ -27,6 +32,11 @@ type certView struct {
NamedUse []string `json:"named_use,omitempty"`
// IssuingSince is set while Jarl is obtaining it.
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 {
@@ -61,6 +71,93 @@ func (s *Server) certsReady(p *auth.Principal) error {
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 {
var ie *certs.InputError
switch {
@@ -70,13 +167,15 @@ func certErr(err error) error {
return notFound("no such certificate")
case errors.Is(err, certs.ErrConflict):
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
}
// certList: GET /certificates.
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
}
all, err := s.cfg.Certs.List(r.Context())
@@ -89,22 +188,32 @@ func (s *Server) certList(w http.ResponseWriter, r *http.Request, p *auth.Princi
}
out := []certView{}
for _, c := range all {
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())})
return nil
}
// certGet: GET /certificates/{name}.
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
}
c, err := s.cfg.Certs.Get(r.Context(), r.PathValue("name"))
c, err := s.certFor(r, p, false)
if err != nil {
return certErr(err)
return err
}
st, err := s.store.LoadState(r.Context())
if err != nil {
@@ -112,10 +221,121 @@ func (s *Server) certGet(w http.ResponseWriter, r *http.Request, p *auth.Princip
}
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 {
return certErr(err)
}
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
}
// 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
// certificates Jarl obtains) it is obtained before the answer.
func (s *Server) certCreate(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
@@ -162,7 +382,10 @@ func (s *Server) certUpdate(w http.ResponseWriter, r *http.Request, p *auth.Prin
// certDelete: DELETE /certificates/{name}.
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
}
if err := s.cfg.Certs.Delete(r.Context(), r.PathValue("name"), p); err != nil {
@@ -257,3 +480,28 @@ func (s *Server) certSummary(ctx context.Context) *api.CertSummary {
}
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()
}
+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() {
if p.Can(auth.Action{Resource: auth.ResViews, View: v.Name}) {
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)
@@ -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 {
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
})
+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)
}
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)
for _, rec := range st.ServedRecords(pz.ID) {
if rec.Name == z.Name && rec.Type == "DS" {
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)
return nil
}
// parentInJarl returns the primary zone in z's view that z is delegated
// from, if Jarl has it.
func parentInJarl(st *model.State, z *model.Zone) *model.Zone {
var best *model.Zone
for _, o := range st.ZonesInView(z.ViewID) {
if o.ID == z.ID || o.Type != model.ZonePrimary || o.Name == z.Name || !dnsname.IsSubdomain(z.Name, o.Name) {
// parentInJarl returns the primary zone in Jarl that z is delegated from:
// in z's own view, else in another view, a public one first (the one the
// internet asks).
func parentInJarl(st *model.State, z *model.Zone, public map[int64]bool) *model.Zone {
in := func(viewID int64) *model.Zone {
var best *model.Zone
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
}
if best == nil || len(o.Name) > len(best.Name) {
best = o
if pz := in(v.ID); pz != nil && (other == nil || public[v.ID] && !public[other.ViewID]) {
other = pz
}
}
return best
return other
}
// dsChecker returns the checker for parent DS lookups, with ?resolver=IP.
+16 -1
View File
@@ -35,11 +35,16 @@ import (
)
// 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 {
if s.cfg.Porkbun == nil || z.Type != model.ZonePrimary {
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)) {
return ""
}
@@ -59,6 +64,16 @@ func (s *Server) registrarZone(r *http.Request, p *auth.Principal) (*model.State
return nil, nil, "", err
}
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 {
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
// 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 {
st, err := s.state(r)
if err != nil {
@@ -18,7 +19,7 @@ func (s *Server) zoneFillPTR(w http.ResponseWriter, r *http.Request, p *auth.Pri
if err != nil {
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
+1 -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 {
if a.Name != b.Name {
if model.CanonicalLess(a.Name, b.Name) {
if model.NameLess(a.Name, b.Name) {
return -1
}
return 1
+2
View File
@@ -206,6 +206,8 @@ func (s *Server) loginTOTP(w http.ResponseWriter, r *http.Request) error {
if err != nil {
s.limiter.fail("ip:"+ip, userKey)
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())
msg := "wrong code: use the current code from your authenticator app, or a recovery code"
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 = "."
}
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 {
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 {
out.Prefix = pf.String()
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) {
@@ -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]}
}
// 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 {
used := engine.FindACLRefs(st, a.Name)
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)
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)
}
+2
View File
@@ -174,6 +174,8 @@ func (s *Server) routes() {
m.HandleFunc("POST "+p+"/certificates", s.authed(s.certCreate))
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}/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("DELETE "+p+"/certificates/{name}", s.authed(s.certDelete))
m.HandleFunc("POST "+p+"/certificates/{name}/issue", s.authed(s.certIssue))
+11
View File
@@ -95,6 +95,13 @@ type Status struct {
Day string `json:"day"`
Queries string `json:"queries"` // the query log ("" when off)
} `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.
@@ -164,8 +171,12 @@ func NewCollector(store *Store, opts Options, zones ZoneIndex, log *slog.Logger)
c.status.Retention.Minute = humanDuration(opts.RetainMinute)
c.status.Retention.Hour = humanDuration(opts.RetainHour)
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
}
+6 -5
View File
@@ -8,7 +8,7 @@ import (
)
// Exploring the query log (docs/stats.md#exploring): every answer of the
// last days (stats.query_retention), filtered by any mix of client, name,
// 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
@@ -47,8 +47,9 @@ type Exploration struct {
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.
Retention string `json:"retention"`
// 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.
@@ -59,7 +60,7 @@ type ExplorePoint struct {
}
// 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.query_retention above 0 in /etc/jarl/jarl.yaml (docs/stats.md#exploring)")
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".
@@ -178,7 +179,7 @@ func (q *Querier) Explore(ctx context.Context, f ExploreFilter, topN, recent int
}
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)}
Retention: humanDuration(q.Opts.RetainQueries), RetentionSeconds: int64(q.Opts.RetainQueries / time.Second)}
cond, args := q.where(f, from)
db := q.Store.db
+1 -1
View File
@@ -45,7 +45,7 @@ var schema = []string{
PRIMARY KEY (res, ts, kind, key)
) WITHOUT ROWID;
CREATE TABLE meta (key TEXT PRIMARY KEY, value TEXT NOT NULL);`,
// 2: the query log, every answer for a while (stats.query_retention),
// 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 (
+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,
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) {
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
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,
&ca, &ua, &c.CreatedBy, &c.UpdatedBy); err != nil {
&ca, &ua, &c.CreatedBy, &c.UpdatedBy, &c.OwnerID); err != nil {
return nil, err
}
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 {
now := time.Now().UTC()
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) {
return ErrConflict
}
@@ -87,7 +87,7 @@ func (s *Store) UpdateCertificate(ctx context.Context, c *model.Certificate) err
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=?,
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)...)
if isUnique(err) {
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,
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),
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)
}
+16
View File
@@ -384,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 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")
}
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) {
u := &model.User{}
var grants, ca, ua string
var admin, disabled int
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
}
if limit.Valid {
n := int(limit.Int64)
u.CertLimit = &n
}
u.Admin, u.Disabled = admin != 0, disabled != 0
u.HasPassword = u.PasswordHash != ""
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 {
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 (?,?,?,?,?,?,?,?)`,
u.Username, u.PasswordHash, boolInt(u.Admin), jsonText(u.Grants), boolInt(u.Disabled), u.Comment, ts(now), ts(now))
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.CertLimit)
if isUnique(err) {
return ErrConflict
}
@@ -64,8 +69,8 @@ func (s *Store) CreateUser(ctx context.Context, u *model.User) error {
// UpdateUser saves a user.
func (s *Store) UpdateUser(ctx context.Context, u *model.User) error {
u.UpdatedAt = time.Now()
res, err := s.db.ExecContext(ctx, `UPDATE users SET username = ?, password_hash = ?, admin = ?, grants = ?, disabled = ?, comment = ?, updated_at = ? WHERE id = ?`,
u.Username, u.PasswordHash, boolInt(u.Admin), jsonText(u.Grants), boolInt(u.Disabled), u.Comment, ts(u.UpdatedAt), u.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.CertLimit, u.ID)
if isUnique(err) {
return ErrConflict
}
+13 -5
View File
@@ -186,7 +186,7 @@ func (c *checker) checkZoneRecords(z *model.Zone) {
for n := range ix.types {
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 {
types := ix.types[name]
@@ -385,8 +385,16 @@ func (c *checker) checkCNAMETarget(z *model.Zone, ix *zoneIndex, r *model.Record
if !ix.inZoneTarget(target) {
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}
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
for hops := 0; hops < 20; hops++ {
if seen[cur] {
@@ -398,18 +406,18 @@ func (c *checker) checkCNAMETarget(z *model.Zone, ix *zoneIndex, r *model.Record
if len(next) == 0 {
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]
if p == nil {
break
}
cur = p.RR.(*dns.CNAME).Target
chain = append(chain, disp(cur))
if !ix.inZoneTarget(cur) {
note()
return
}
}
note()
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))
}
+1 -1
View File
@@ -189,7 +189,7 @@ func sortProblems(ps []Problem) {
return a.Zone < b.Zone
}
if a.Name != b.Name {
return model.CanonicalLess(a.Name, b.Name)
return model.NameLess(a.Name, b.Name)
}
return a.Message < b.Message
})
+16 -2
View File
@@ -161,8 +161,9 @@ func TestRecordConflicts(t *testing.T) {
}, "cname_loop", Error},
{"cname chain", func(b *builder, z *model.Zone) {
b.rec(z, "a", "CNAME", "b")
b.rec(z, "b", "CNAME", "ns1")
}, "cname_chain", Warning},
b.rec(z, "b", "CNAME", "c")
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},
{"null mx not alone", func(b *builder, z *model.Zone) {
b.rec(z, "@", "MX", "0 .")
@@ -380,3 +381,16 @@ func TestProblemStringDisplaysZone(t *testing.T) {
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 -2
View File
@@ -283,7 +283,7 @@ export function heatmap(hm, unit) {
const n = hm.samples[d][hr];
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)");
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 = () => {
tip.textContent = text;
tip.hidden = false;
@@ -313,7 +313,7 @@ export function heatmap(hm, unit) {
h("span", { class: "muted small" }, "0"),
Array.from({ length: steps }, (_, i) => h("span", { class: "hm-swatch heat-" + i })),
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);
}
+1 -1
View File
@@ -9,7 +9,7 @@ export async function render(el) {
const tokensEl = h("div");
const drawTokens = async () => {
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 pw1 = input({ type: "password", autocomplete: "new-password" });
@@ -32,10 +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: "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) => [
c.uses.includes("web") ? badge(c.serving ? "web UI (serving)" : "web UI", c.serving ? "ok" : "") : null, " ",
c.uses.includes("dns") ? [badge("named", ""), (c.named_use || []).map((u) => h("div", { class: "small muted", title: u }, u.replace("DNS over TLS on ", "DoT ").replace("DNS over HTTPS on ", "DoH "))),
!(c.named_use || []).length ? h("div", { class: "small muted" }, "not used by named yet") : null] : null,
!c.uses.length ? h("span", { class: "muted" }, "nothing yet") : null] },
c.uses.includes("web") ? [badge(c.serving ? "web UI (serving)" : "web UI", c.serving ? "ok" : ""), " "] : null,
c.uses.includes("dns") ? namedUse(c) : 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: "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) => {
@@ -45,6 +45,7 @@ export async function render(el) {
{ 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", 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" },
], r.certificates, { empty: "No certificates yet." })));
@@ -64,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) {
const rows = [
["Source", c.source === "acme" ? "Jarl obtains and renews it (" + c.key_type + " key)" : "your files: " + c.cert_file + ", " + c.key_file],
@@ -85,6 +104,62 @@ function details(c) {
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) {
try {
const r = await api("POST", "/certificates/" + encodeURIComponent(c.name) + "/issue", { body: {} });
+12 -5
View File
@@ -44,7 +44,8 @@ export async function dnssecTab(el, zone) {
s.named_error ? h("div", { class: "msg warn small" }, "named: " + s.named_error) : null,
s.key_error ? h("div", { class: "msg err small" }, "The keys can't be read: " + s.key_error) : null,
!s.policy && !s.keys.length ? h("p", { class: "muted small" },
"Signing lets resolvers that validate DNSSEC detect forged answers for the zone. It only matters for a zone on the internet: validation needs a DS record in the parent zone (at your registrar), and resolvers on your own network don't validate zones this server answers itself.") : null,
"Signing lets resolvers that validate DNSSEC detect forged answers for the zone. It only matters for a zone on the internet: validation needs a DS record " + (s.ds_place || "in the parent zone") + ", and resolvers on your own network don't validate zones this server answers itself.") : null,
s.ds_at === "none" ? h("div", { class: "msg warn small" }, s.ds_note) : null,
out)];
const live = s.keys.filter((k) => k.ds && k.goal === "omnipresent");
@@ -98,8 +99,12 @@ async function addToParent(zone, s, k) {
function dsPanel(zone, s, keys) {
const ph = s.phase;
const where = s.parent_zone
? ["The parent zone ", h("a", { href: "#/zones/" + s.parent_zone_id + "/records" }, display(s.parent_zone)), " is in Jarl: add this as a DS record there (name ", h("code", null, parentName(zone, s)), "), with the button or the jarl command below."]
: "Give this to your registrar (at Porkbun: the domain's DNSSEC settings). Registrars ask either for the whole record or for its four fields.";
? ["The parent zone ", h("a", { href: "#/zones/" + s.parent_zone_id + "/records" }, display(s.parent_zone)),
s.parent_view && s.parent_view !== zone.view ? " (view " + s.parent_view + ")" : "",
" is in Jarl: add this as a DS record there (name ", h("code", null, parentName(zone, s)), "), with the button or the jarl command below."]
: s.ds_at === "registrar"
? "Give this to your registrar (at Porkbun: the domain's DNSSEC settings). Registrars ask either for the whole record or for its four fields."
: s.ds_at === "none" ? s.ds_note : "Add this " + s.ds_place + ".";
return h("div", { class: "panel" },
h("h2", null, "DS record for the parent zone"),
ph.can_publish_ds
@@ -153,7 +158,7 @@ async function enableDialog(zone, s, others) {
"The server's clock must be right (NTP): signatures are valid for a fixed time window.",
"TTLs in the zone must be at most a day; Jarl refuses longer ones.",
"Secondaries get the signed zone by zone transfer; they need nothing else.",
"With the default policy the key is never replaced, so the DS at your registrar never has to change. Keep backups: the daily backup includes the keys.",
"With the default policy the key is never replaced, so the DS record at the parent never has to change. Keep backups: the daily backup includes the keys.",
]),
split,
s.policies.length > 1 ? h("div", { class: "form-grid" }, h("label", null, "Policy"), pol) : null,
@@ -175,7 +180,9 @@ async function disableDialog(zone, s) {
[h("strong", null, "Now: "), "the zone switches to BIND's insecure policy. named keeps it signed and publishes a CDS \"delete\" record. Nothing breaks."],
s.parent_zone
? [h("strong", null, "You: "), "remove the DS record from ", display(s.parent_zone), " (", h("code", null, jarlCmd(zone, s, "delete", "")), ", or on its Records page), then click Check DS at parent so named knows it's gone."]
: [h("strong", null, "You: "), "remove the DS record at your registrar (", s.registrar ? "the Registrar tab can do that at Porkbun" : "at Porkbun: the domain's DNSSEC settings", "), then click Check DS at parent so named knows it's gone."],
: s.ds_at === "registrar"
? [h("strong", null, "You: "), "remove the DS record at your registrar (", s.registrar ? "the Registrar tab can do that at Porkbun" : "at Porkbun: the domain's DNSSEC settings", "), then click Check DS at parent so named knows it's gone."]
: [h("strong", null, "You: "), "remove the DS record ", s.ds_place || "from the parent zone", " (if it was ever added), then click Check DS at parent so named knows it's gone."],
[h("strong", null, "named: "), "waits until resolvers have forgotten the DS (the parent's DS TTL, often a day), then removes the keys and signatures."],
[h("strong", null, "Then: "), "this page lets you finish, which switches DNSSEC off."],
]),
+4 -2
View File
@@ -35,13 +35,15 @@ function statusPanel(s) {
];
const addr = (s.dot[0] || s.doh[0] || {}).address || "ADDRESS";
const dotPortArg = s.dot[0] && s.dot[0].port !== 853 ? " -p " + s.dot[0].port : "";
const dohPortArg = s.doh[0] && s.doh[0].port !== 443 ? " -p " + s.doh[0].port : "";
const clients = [];
if (s.dot_host) {
clients.push(h("h3", null, "DNS over TLS"), copyBox(s.dot_host),
h("ul", { class: "small" },
h("li", null, h("strong", null, "Android: "), "Settings → Network → Private DNS → hostname: ", h("code", null, s.hostname), ". Away from home Android can't reach this server and then resolves nothing, so use a VPN home (WireGuard) or switch Private DNS to Automatic when you leave."),
h("li", null, h("strong", null, "Linux (systemd-resolved): "), h("code", null, "DNS=" + addr + "#" + s.hostname), " and ", h("code", null, "DNSOverTLS=yes"), " in /etc/systemd/resolved.conf (", h("code", null, "opportunistic"), " falls back to plain DNS away from home)."),
h("li", null, h("strong", null, "Test: "), h("code", null, "kdig +tls-ca +tls-host=" + s.hostname + dotPortArg + " @" + addr + " example.com"))));
h("li", null, h("strong", null, "Test: "), h("code", null, "dig +tls +tls-ca +tls-hostname=" + s.hostname + dotPortArg + " @" + addr + " example.com"),
" (BIND's dig; +tls-ca checks the certificate against the system's CAs)")));
}
if (s.doh_url) {
clients.push(h("h3", null, "DNS over HTTPS"), copyBox(s.doh_url),
@@ -49,7 +51,7 @@ function statusPanel(s) {
h("li", null, h("strong", null, "Firefox: "), "Settings → Privacy & Security → DNS over HTTPS → Increased protection (falls back to normal DNS) → Custom: the URL above."),
h("li", null, h("strong", null, "Chrome, Edge: "), "Settings → Privacy and security → Security → Use secure DNS → custom: the URL above."),
h("li", null, h("strong", null, "Windows 11: "), "Settings → Network → your connection → DNS server assignment → Edit: " + addr + ", DNS over HTTPS on (manual template), the URL above."),
h("li", null, h("strong", null, "Test: "), h("code", null, "curl -s -H 'accept: application/dns-json' '" + s.doh_url + "?name=example.com'"), " (or ", h("code", null, "dig +https @" + addr + " example.com"), ")")));
h("li", null, h("strong", null, "Test: "), h("code", null, "dig +https +tls-ca +tls-hostname=" + s.hostname + dohPortArg + " @" + addr + " example.com"))));
}
return [
h("div", { class: "panel" }, h("h2", null, "Set up ", badge("on", "ok")), h("dl", { class: "kv" }, rows)),
+8 -7
View File
@@ -16,6 +16,13 @@ const filterWords = { client: "client", name: "name", domain: "domain", view: "v
const keys = Object.keys(filterWords);
const ranges = [["1h", 3600], ["6h", 6 * 3600], ["24h", 86400], ["3d", 3 * 86400], ["7d", 7 * 86400], ["30d", 30 * 86400]];
// exploreRange is the longest range here of at most secs ("1h" at least).
export function exploreRange(secs) {
let out = "1h";
for (const [k, s] of ranges) if (s <= secs) out = k;
return out;
}
// exploreHref is the explore page for these filters (and range).
export function exploreHref(filters, since) {
const q = new URLSearchParams();
@@ -74,7 +81,7 @@ export async function render(el, params, query) {
}
const parts = [head(f, go, since, ex), filterBar(f, since, ex, allViews)];
const maxSecs = retentionSecs(ex.retention);
const maxSecs = ex.retention_seconds || 7 * 86400;
parts.push(h("div", { class: "filter-row", role: "group", "aria-label": "Time range" },
h("span", { class: "muted" }, "Range"),
h("div", { class: "segmented" }, ranges.filter(([, s]) => s <= maxSecs).map(([k]) =>
@@ -234,9 +241,3 @@ function stepWord(s) {
if (s % 3600 === 0) return s === 3600 ? "hour" : s / 3600 + " hours";
return s === 60 ? "minute" : s / 60 + " minutes";
}
function retentionSecs(r) {
const m = /^(\d+)([dhm])$/.exec(r || "");
if (!m) return 7 * 86400;
return Number(m[1]) * { d: 86400, h: 3600, m: 60 }[m[2]];
}
+38 -16
View File
@@ -2,10 +2,27 @@ import { h, mount, fmtTime, relTime, errorBox } from "../ui.js";
import { get } from "../api.js";
import { rerender } from "../app.js";
import { lineChart, chartCard, legend, timeTable, heatmap, heatmapTable, barList, compact, stepSeconds, endLabelWidth } from "../charts.js";
import { exploreHref } from "./explore.js";
import { exploreHref, exploreRange } from "./explore.js";
const ranges = [["1h", "1 hour"], ["6h", "6 hours"], ["24h", "24 hours"], ["7d", "7 days"], ["30d", "30 days"], ["90d", "90 days"]];
const rangeSecs = { "1h": 3600, "6h": 6 * 3600, "24h": 86400, "7d": 7 * 86400, "30d": 30 * 86400, "90d": 90 * 86400 };
// Ranges: the since value, its words and its button. Only the ones the
// statistics are kept for are offered (keptSecs).
const ranges = [["1h", "1 hour"], ["6h", "6 hours"], ["24h", "24 hours"], ["7d", "7 days"], ["30d", "30 days"], ["90d", "90 days"],
["365d", "year", "1y"], ["730d", "2 years", "2y"]];
const rangeSecs = { "1h": 3600, "6h": 6 * 3600, "24h": 86400, "7d": 7 * 86400, "30d": 30 * 86400, "90d": 90 * 86400, "365d": 365 * 86400, "730d": 730 * 86400 };
// keptSecs is how far back the statistics go: the longest of the minute,
// hour and day retentions (a server older than retention_seconds: 2 years).
function keptSecs(status) {
const r = status.retention_seconds;
return r ? Math.max(r.minute, r.hour, r.day) : 730 * 86400;
}
// longestWithin is the longest range of at most secs ("1h" at least).
function longestWithin(secs) {
let out = "1h";
for (const [k] of ranges) if (rangeSecs[k] <= secs) out = k;
return out;
}
// Failed answers keep the same color whatever the range: color follows
// the result code, never its rank.
@@ -36,8 +53,9 @@ const perViewGroups = ["qtype", "rcode", "view", "zone", "blocklist"];
export async function render(el, params, query) {
const gen = ++generation;
const want = query && query.get ? query.get("since") : null;
const since = rangeSecs[want] ? want : "24h";
const status = await get("/stats/status");
const kept = keptSecs(status);
const since = rangeSecs[want] && rangeSecs[want] <= kept ? want : longestWithin(Math.min(86400, kept));
const head = h("div", { class: "page-head" },
h("div", { class: "grow" }, h("h1", null, "Statistics"),
h("div", { class: "muted" }, status.enabled
@@ -50,7 +68,10 @@ export async function render(el, params, query) {
mount(el, head, disabledPanel());
return;
}
const heatSince = rangeSecs[since] < 7 * 86400 ? "7d" : since;
// The heatmap needs hourly counters: at least a week of them, at most
// what is kept.
const hourSecs = status.retention_seconds ? status.retention_seconds.hour : 90 * 86400;
const heatSince = longestWithin(Math.min(Math.max(rangeSecs[since], 7 * 86400), hourSecs));
// Which views: all of them unless some are switched off.
const allViews = status.query_log ? (await get("/views").catch(() => [])).map((v) => v.name) : [];
const off = viewsOff();
@@ -58,14 +79,14 @@ export async function render(el, params, query) {
const filtered = allViews.length > 0 && shown.length < allViews.length;
const vf = filtered ? { views: shown.join(",") } : {};
if (filtered && !shown.length) {
mount(el, head, filterRow(since, allViews, off), h("div", { class: "empty" }, "No views chosen: switch one on above."));
mount(el, head, filterRow(since, kept, allViews, off), h("div", { class: "empty" }, "No views chosen: switch one on above."));
return;
}
let data;
try {
data = await loadAll(since, heatSince, status.query_log, vf, filtered);
} catch (e) {
mount(el, head, filterRow(since, allViews, off), errorBox(e));
mount(el, head, filterRow(since, kept, allViews, off), errorBox(e));
return;
}
if (gen !== generation) return; // the user moved on
@@ -74,7 +95,7 @@ export async function render(el, params, query) {
// sudden drop in traffic.
const timeline = complete(data.timeline), errors = complete(data.errors);
const step = stepSeconds[timeline.step];
const parts = [head, filterRow(since, allViews, off)];
const parts = [head, filterRow(since, kept, allViews, off)];
for (const w of status.warnings || []) parts.push(h("div", { class: "msg warn" }, w));
if (filtered) {
parts.push(h("div", { class: "msg info small" }, "Showing view" + (shown.length > 1 ? "s " : " ") + shown.join(", ") + ": answers from named's response log" +
@@ -120,7 +141,7 @@ export async function render(el, params, query) {
// Who asks for what. With the query log, every value leads to the
// explore page: a client to what it asked, a name to who asked.
const ql = !!(status.retention || {}).queries;
const ex = (filters) => (ql ? (k) => exploreHref(filters(k), exploreSince(since)) : null);
const ex = (filters) => (ql ? (k) => exploreHref(filters(k), exploreSince(since, status)) : null);
if (status.query_log) {
const t = data.tops;
parts.push(h("div", { class: "chart-grid" },
@@ -201,11 +222,11 @@ function complete(tl) {
return tl;
}
function filterRow(since, views, off) {
function filterRow(since, kept, views, off) {
const range = h("div", { class: "filter-row", role: "group", "aria-label": "Time range" },
h("span", { class: "muted" }, "Range"),
h("div", { class: "segmented" }, ranges.map(([k, label]) =>
h("a", { href: "#/stats?since=" + k, class: k === since ? "active" : "", "aria-current": k === since ? "true" : null, title: "last " + label }, k))));
h("div", { class: "segmented" }, ranges.filter(([k]) => rangeSecs[k] <= kept).map(([k, label, button]) =>
h("a", { href: "#/stats?since=" + k, class: k === since ? "active" : "", "aria-current": k === since ? "true" : null, title: "last " + label }, button || k))));
if (!views || views.length < 2) return range;
// A toggle per view (together: linked views, say), and all or none.
const set = (next) => { saveViewsOff(next); rerender(); };
@@ -312,10 +333,11 @@ function disabledPanel() {
"(never individual queries) in a separate database, by default for 7 days per minute, 90 days per hour and 2 years per day. See docs/stats.md."));
}
// exploreSince is the Statistics range for the explore page: the query log
// keeps days, not months.
function exploreSince(since) {
return rangeSecs[since] > 7 * 86400 ? "7d" : since;
// exploreSince is the Statistics range for the explore page: at most what
// the query log keeps (days, usually, not months).
function exploreSince(since, status) {
const q = status.retention_seconds ? status.retention_seconds.queries : 7 * 86400;
return exploreRange(Math.min(rangeSecs[since], q));
}
// dbTile is the statistics database's size: what the statistics cost on
+22 -4
View File
@@ -54,6 +54,7 @@ async function userDialog(u, grants) {
tmpl.value = "";
});
const comment = input({ value: u ? u.comment : "" });
const certLimit = input({ type: "number", min: "0", value: u && u.cert_limit != null ? String(u.cert_limit) : "", placeholder: "certificates.max_per_user" });
await modal(u ? "Edit " + u.username : "New user", h("div", null, h("div", { class: "form-grid" },
field("Username", name),
field("Password", pw),
@@ -62,6 +63,7 @@ async function userDialog(u, grants) {
u ? h("span") : null, u ? disabled : null,
field("Permissions", gr, "One grant per line (ignored for administrators)."),
h("span"), tmpl,
field("Certificates", certLimit, "How many certificates this user may own through jarl cert get (needs a certificates grant). Empty: the default from jarl.yaml."),
field("Comment", comment)), err), [
{ label: "Cancel", value: null },
{ label: u ? "Save" : "Create user", kind: "primary", onclick: async () => {
@@ -69,6 +71,8 @@ async function userDialog(u, grants) {
if (service.input.checked) data.password = "";
else if (pw.value) data.password = pw.value;
if (u) data.disabled = disabled.input.checked;
if (certLimit.value.trim() !== "") data.cert_limit = Number(certLimit.value);
else if (u && u.cert_limit != null) data.cert_limit = -1;
try {
if (u) await api("PATCH", "/users/" + u.id, { body: data });
else await api("POST", "/users", { body: data });
@@ -104,7 +108,7 @@ export async function tokensDialog(u, grants, own) {
const base = own ? "/me/tokens" : "/users/" + u.id + "/tokens";
const draw = async () => {
const tokens = await get(base);
mount(box, tokenTable(tokens, draw), newTokenForm(base, grants, draw));
mount(box, tokenTable(tokens, draw), newTokenForm(base, grants, draw, u));
};
await draw();
await modal("API tokens of " + u.username, box, [{ label: "Close", value: null }], { wide: true });
@@ -185,13 +189,27 @@ async function rotateDialog(t, redraw) {
await modal("Rotate token " + t.name, box, [{ label: "Close", value: null }], { sticky: true });
}
export function newTokenForm(base, grants, redraw) {
// coveredBy reports whether a user (admin, or grants) may do what a grant
// string ("records:write names=..." ) allows, by resource and access. A
// filter on either side only narrows the token further, so it is ignored.
function coveredBy(user, grant) {
if (!user || user.admin) return true;
const [res, acc] = grant.split(/\s+/)[0].split(":");
// "*" never covers certificates (they hand out private keys).
return (user.grants || []).some((g) => ((g.resource === "*" && res !== "certificates") || g.resource === res) && (g.access === "write" || acc === "read"));
}
// newTokenForm makes a token for owner (the user it belongs to). Its
// templates are only the roles within what owner may do: a token never
// does more than its user, so offering more would only mislead.
export function newTokenForm(base, grants, redraw, owner) {
const err = h("div");
const out = h("div");
const name = input({ placeholder: "e.g. certbot on web01" });
const exp = select([["", "never expires"], ["30d", "30 days"], ["90d", "90 days"], ["365d", "1 year"]], "90d");
const gr = textarea("", { class: "mono", rows: 3, placeholder: "optional restriction, e.g.\nrecords:write zones=example.com names=_acme-challenge.* types=TXT" });
const tmpl = select([["", "Insert a template…"], ...Object.keys(grants.roles).map((r) => [r, r])], "");
const roles = Object.keys(grants.roles).filter((r) => grants.roles[r].every((g) => coveredBy(owner, g)));
const tmpl = select([["", "Insert a template…"], ...roles.map((r) => [r, r])], "");
tmpl.addEventListener("change", () => {
if (!tmpl.value) return;
gr.value = (gr.value.trim() ? gr.value.trim() + "\n" : "") + grants.roles[tmpl.value].join("\n");
@@ -203,7 +221,7 @@ export function newTokenForm(base, grants, redraw) {
field("Name", name),
field("Expires", exp),
field("Restrict to", gr, "Leave empty for all of the user's permissions. A token can never do more than its user."),
h("span"), tmpl),
roles.length ? [h("span"), tmpl] : null),
err,
h("div", { class: "actions", style: { marginTop: "8px" } }, h("button", { class: "primary", onclick: async () => {
mount(err); mount(out);
+61 -10
View File
@@ -9,6 +9,15 @@ import { registrarTab } from "./registrar.js";
let renderSeq = 0;
// tabIn is the "/tab" part of the address of zone o (an in_views entry)
// that keeps the tab open now, when o has that tab.
function tabIn(o, tab) {
if (!tab || tab === "registrar") return "";
if (["records", "file", "dnssec", "compare"].includes(tab) && o.type !== "primary") return "";
if (tab === "servers" && !["primary", "secondary", "mirror"].includes(o.type)) return "";
return "/" + tab;
}
export async function render(el, params, query) {
const id = params.id;
const [zone, views] = await Promise.all([get("/zones/" + id, draftQuery()), get("/views")]);
@@ -33,12 +42,19 @@ export async function render(el, params, query) {
badge(zone.type, "accent"), " ",
zone.pending ? [pendingBadge(zone.pending), " "] : null,
zone.view ? ["view ", viewLink(view, zone.view, "#/zones/" + id + (params.tab ? "/" + params.tab : "")), " · "] : null,
zone.in_views ? [h("span", { title: "The zone " + zone.display_name + " in another view" }, "also in ",
zone.in_views.map((o, i) => {
const v = views.find((x) => x.name === o.view);
return [i ? ", " : "", h("a", { href: "#/zones/" + o.id + tabIn(o, params.tab), class: v && v.color ? "vc-" + v.color : null,
title: "Go to " + zone.display_name + " in view " + o.view + (o.type !== zone.type ? " (" + o.type + ")" : "") + (o.linked ? ", linked with this one" : "") }, o.view),
o.type !== zone.type ? h("span", { class: "muted" }, " (" + o.type + ")") : null];
})), " · "] : null,
zone.type === "primary" ? "serial " + zone.soa.serial + " · " : null,
zone.prefix ? h("span", { class: "mono" }, zone.prefix + " · ") : null,
zone.auto_ptr ? [h("a", { href: "#/zones/" + id + "/settings", title: "Adding, changing, disabling or deleting an A or AAAA record does the same to its PTR record" }, badge("PTRs kept in step", "ok")), " "] : null,
zone.dnssec_policy ? [h("a", { href: "#/zones/" + id + "/dnssec" }, badge(zone.dnssec_policy === "insecure" ? "DNSSEC: turning off" : "DNSSEC", zone.dnssec_policy === "insecure" ? "warn" : "ok")), " "] : null,
zone.links ? [h("span", { class: "badge accent" }, "linked with ",
zone.links.map((l, i) => [i ? ", " : "", h("a", { href: "#/zones/" + l.id + (params.tab ? "/" + params.tab : ""), title: "The zone in view " + l.view }, l.view)])), " "] : null,
zone.links.map((l, i) => [i ? ", " : "", h("a", { href: "#/zones/" + l.id + tabIn({ type: zone.type }, params.tab), title: "The zone in view " + l.view }, l.view)])), " "] : null,
zone.zone_file ? h("span", { class: "mono small" }, zone.zone_file) : null)),
h("div", { class: "actions" },
zone.disabled
@@ -51,7 +67,26 @@ export async function render(el, params, query) {
h("strong", null, "This zone is disabled: "), "named doesn't load it. It is kept here, with its records, and can still be edited; enabling it serves it as it is then.")
: zone.ptr_missing && can("zones", true) ? h("div", { class: "msg info" },
plural(zone.ptr_missing, "address", "addresses") + " in " + zone.prefix + " that " + (zone.view ? "view " + zone.view + "'s" : "your") + " A and AAAA records use " + (zone.ptr_missing === 1 ? "has" : "have") + " no PTR record here. ",
h("button", { class: "small", onclick: () => fillPTR(zone) }, "Add " + (zone.ptr_missing === 1 ? "it" : "them"))) : null;
h("button", { class: "small", onclick: () => fillPTR(zone) }, "Add " + (zone.ptr_missing === 1 ? "it" : "them")),
h("ul", { class: "small ptr-missing" }, (zone.ptr_missing_list || []).map((f) => h("li", null,
h("span", { class: "mono" }, f.address), " → ", h("span", { class: "mono" }, display(f.target)),
f.from ? h("span", { class: "muted" }, " (from view " + f.from + ")") : null,
f.others && f.others.length ? h("span", { class: "muted" }, " (also " + f.others.map(display).join(", ") + ")") : null)),
zone.ptr_missing > (zone.ptr_missing_list || []).length ? h("li", { class: "muted" }, "and " + (zone.ptr_missing - zone.ptr_missing_list.length) + " more") : null))
: null;
// Addresses only other views have records for: not missing here, but
// they can be added (publishing those views' names in this one).
const others = zone.ptr_other_views_list || [];
const fromViews = [...new Set(others.map((f) => f.from))];
const otherNote = !zone.disabled && zone.ptr_other_views && can("zones", true) ? h("div", { class: "muted small" },
plural(zone.ptr_other_views, "address", "addresses") + " in " + zone.prefix + " " + (zone.ptr_other_views === 1 ? "has" : "have") + " A or AAAA records only in view " + fromViews.join(", ") +
", not in view " + zone.view + ", so " + (zone.ptr_other_views === 1 ? "it isn't" : "they aren't") + " counted as missing here. ",
h("button", { class: "small", title: "Clients of view " + zone.view + " will then get these names, which come from view " + fromViews.join(", "), onclick: () => fillPTR(zone, true) },
"Add PTR records for " + (zone.ptr_other_views === 1 ? "it" : "them") + " in view " + zone.view),
h("ul", { class: "small ptr-missing" }, others.map((f) => h("li", null,
h("span", { class: "mono" }, f.address), " → ", h("span", { class: "mono" }, display(f.target)),
fromViews.length > 1 ? h("span", { class: "muted" }, " (view " + f.from + ")") : null)),
zone.ptr_other_views > others.length ? h("li", { class: "muted" }, "and " + (zone.ptr_other_views - others.length) + " more") : null)) : null;
const tabBar = h("div", { class: "tabs" }, tabs.map(([k, label]) => h("a", { href: "#/zones/" + id + "/" + k, class: k === tab ? "active" : "" }, label)));
// The header and tabs show at once. Below them, switching tabs keeps the
// previous tab's content (faded) until the new one is loaded, rather
@@ -62,7 +97,7 @@ export async function render(el, params, query) {
holder.classList.add("stale");
body.className = "zone-body";
body.dataset.zone = id;
mount(el, head, banner, tabBar, holder);
mount(el, head, banner, otherNote, tabBar, holder);
if (tab === "records") await recordsTab(body, zone, query);
else if (tab === "settings") await settingsTab(body, zone);
else if (tab === "options") await optionsEditor(body, { scope: "zone", scope_id: zone.id, catalogScope: "zone:" + zone.type, title: "Zone options" });
@@ -88,7 +123,10 @@ export async function render(el, params, query) {
// the same. ?name= (and &type=) narrows it to one name.
async function compareTab(el, zone, query) {
const others = zone.other_views || [];
const withSel = select([...(others.length > 1 ? [["", "all other views"]] : []), ...others.map((v) => [v, v])], query.get("with") || others[0] || "");
// A linked zone starts with a view it is linked to: that is the
// comparison that matters (its differences are its own records).
const linked = (zone.linked_with || []).filter((v) => others.includes(v));
const withSel = select([...(others.length > 1 ? [["", "all other views"]] : []), ...others.map((v) => [v, v])], query.get("with") || linked[0] || others[0] || "");
const nameIn = input({ value: query.get("name") || "", placeholder: "all names, or one: www, @", class: "mono", style: { width: "200px" } });
const typeIn = input({ value: query.get("type") || "", placeholder: "any type", class: "mono", style: { width: "90px" } });
let showSame = false;
@@ -801,12 +839,21 @@ async function recordsTab(el, zone, query) {
// ---- settings
// inZone reports whether an absolute name is the zone or inside it.
function inZone(name, zoneName) {
if (!name) return true;
return name === zoneName || name.endsWith("." + zoneName);
}
async function settingsTab(el, zone) {
const views = await get("/views");
const err = h("div");
const rows = [];
const s = zone.soa || {};
const mname = input({ value: display(s.mname), class: "mono" });
// The server reads a name without a final dot as relative to the zone,
// so a name outside it keeps its dot.
const mnameText = inZone(s.mname, zone.name) ? display(s.mname) : (s.mname || "");
const mname = input({ value: mnameText, class: "mono" });
const rname = input({ value: zone.rname_email || emailOf(s.rname) });
const refresh = input({ type: "number", value: s.refresh });
const retry = input({ type: "number", value: s.retry });
@@ -820,7 +867,7 @@ async function settingsTab(el, zone) {
const autoPTR = checkbox("Keep PTR records in step with the A and AAAA records", !!zone.auto_ptr);
if (zone.type === "primary") {
rows.push(
field("Primary name server", mname, "SOA MNAME."),
field("Primary name server", mname, "SOA MNAME. A name outside " + zone.display_name + " ends with '.'; without it, the name is taken as relative to the zone."),
field("Contact", rname, "SOA RNAME, as an email address."),
field("Refresh", refresh, "Seconds between secondaries checking for updates."),
field("Retry", retry, "Seconds before a failed refresh is retried."),
@@ -838,7 +885,8 @@ async function settingsTab(el, zone) {
mount(err);
const body = { comment: comment.value };
if (zone.type === "primary") {
body.soa = { mname: mname.value.trim(), rname: rname.value.trim(), refresh: +refresh.value, retry: +retry.value, expire: +expire.value, minimum: +minimum.value };
body.soa = { rname: rname.value.trim(), refresh: +refresh.value, retry: +retry.value, expire: +expire.value, minimum: +minimum.value };
if (mname.value.trim() !== mnameText) body.soa.mname = mname.value.trim();
body.default_ttl = +ttl.value;
body.serial_policy = policy.value;
if (!!zone.auto_ptr !== autoPTR.input.checked) body.auto_ptr = autoPTR.input.checked;
@@ -849,7 +897,10 @@ async function settingsTab(el, zone) {
const r = await change("PATCH", "/zones/" + zone.id, body);
toast(describeResult(r));
rerender();
} catch (e) { mount(err, errorBox(e)); }
} catch (e) {
if (e.status === 400 && /^nothing to (change|do)/.test(e.message)) toast("Nothing changed");
else mount(err, errorBox(e));
}
};
mount(el, h("div", { class: "panel" }, h("div", { class: "form-grid" }, rows), err,
h("div", { class: "actions", style: { marginTop: "12px" } }, h("button", { class: "primary", onclick: save }, "Save settings"))));
@@ -914,9 +965,9 @@ async function toggleZone(zone) {
}
// fillPTR adds the PTR records a reverse zone lacks (docs/reverse.md).
async function fillPTR(zone) {
async function fillPTR(zone, otherViews) {
try {
const r = await change("POST", "/zones/" + zone.id + "/fill-ptr");
const r = await change("POST", "/zones/" + zone.id + "/fill-ptr", undefined, otherViews ? { query: { other_views: "1" } } : {});
toast(describeResult(r));
(r.warnings || []).forEach((w) => toast(w, "warn", 10000));
rerender();
+9 -2
View File
@@ -103,7 +103,10 @@ export async function newZoneDialog(views, zones, settings, preset = {}) {
cloneSel.addEventListener("change", syncLink);
const comment = input();
const fill = checkbox("Add a PTR record for each address the view's A and AAAA records use in this network", true);
const fillRow = field("PTR records", fill, "Names already in your zones, so the reverse zone answers from the start. Leave it off to add them yourself.");
// Off unless asked (or a network only other views use is picked): it
// publishes one view's host names in another.
const fillOther = checkbox("Also for addresses only other views' records use, named from there", false);
const fillRow = field("PTR records", h("div", null, fill, views.length ? fillOther : null), "Names already in your zones, so the reverse zone answers from the start. Leave it off to add them yourself.");
const updateClones = () => {
// Records can only be copied into a single zone.
const n = (kind === "reverse" ? reverseZoneFor(name.value).name || "-" : name.value.trim().toLowerCase().replace(/\.$/, "")) + ".";
@@ -206,6 +209,7 @@ export async function newZoneDialog(views, zones, settings, preset = {}) {
name.value = c.prefix;
sizeFrom = c.samples.length ? c.samples[0].split(" ")[0] : null;
if (views.length && c.view) view.value = c.view;
if ((c.hidden || "").startsWith("records in ")) { fill.input.checked = true; fillOther.input.checked = true; }
changed();
} },
h("span", { class: "mono" }, c.prefix),
@@ -273,7 +277,10 @@ export async function newZoneDialog(views, zones, settings, preset = {}) {
if (view.value) data.view = view.value;
const opts = [];
const list = (ta) => lines(ta.value).map((l) => l.replace(/;$/, "") + ";").join(" ");
if (t === "primary" && kind === "reverse" && fill.input.checked) data.fill = true;
if (t === "primary" && kind === "reverse" && fill.input.checked) {
data.fill = true;
if (fillOther.input.checked) data.fill_other_views = true;
}
if (t === "primary") {
data.nameservers = lines(ns.value);
if (contact.value.trim()) data.soa = { rname: contact.value.trim() };
+1 -1
View File
@@ -398,7 +398,7 @@ td.num, th.num { text-align: right; font-variant-numeric: tabular-nums; }
.hm-row { display: flex; align-items: center; }
.hm-cell { height: 22px; border-radius: 3px; }
.hm-cell:hover, .hm-cell:focus { outline: 2px solid var(--text); outline-offset: -1px; }
.hm-cell.empty, .hm-swatch.empty { background: transparent; box-shadow: inset 0 0 0 1px var(--chart-grid); }
.hm-empty { background: transparent; box-shadow: inset 0 0 0 1px var(--chart-grid); }
.heat-0 { background: var(--heat-0); } .heat-1 { background: var(--heat-1); } .heat-2 { background: var(--heat-2); }
.heat-3 { background: var(--heat-3); } .heat-4 { background: var(--heat-4); } .heat-5 { background: var(--heat-5); }
.heat-6 { background: var(--heat-6); }
+90 -1
View File
@@ -703,6 +703,21 @@ run "fill-ptr" 0 $J zone fill-ptr fdab:99::/48
expect_dig "filled later: IPv6 PTR" "nas.revtest.test." -x fdab:99:0:10::10
run "fill-ptr again: nothing to add (exit 6)" 6 $J zone fill-ptr fdab:99::/48
contains "nothing to add"
# The same network in another view: internal's hosts aren't missing there,
# and are named from internal only when asked.
run "the network in external too, filled" 0 $J zone create 10.99.1.0/24 --view external --fill
contains "A or AAAA records only in other views"
run "nothing missing there" 0 $J zone show 1.99.10.in-addr.arpa --view external
not_contains "Missing PTRs"
contains "^Other views: *2 addresses with A or AAAA records only in view internal"
contains "10.99.1.11 -> pi.revtest.test (view internal)"
run "fill-ptr there: nothing of its own (exit 6)" 6 $J zone fill-ptr 10.99.1.0/24 --view external
contains "--other-views"
run "fill-ptr --other-views" 0 $J zone fill-ptr 10.99.1.0/24 --view external --other-views
contains "only view internal has a record for it"
run "now named there" 0 $J record list 1.99.10.in-addr.arpa --view external
contains "pi.revtest.test"
run "drop the external copy" 0 $J zone delete 1.99.10.in-addr.arpa --view external --yes
run "keep PTRs in step" 0 $J zone update revtest.test --view internal --auto-ptr
run "zone show says so" 0 $J zone show revtest.test --view internal
contains "^PTR records: *kept in step"
@@ -736,7 +751,7 @@ run "and back on" 0 $J view update internal --reverse-suggestions
run "an AAAA only in external" 0 $J record add adbraun.test vps AAAA 2600:3c01::f03c:93ff:fe2b:1 --view external
run "offered for internal" 0 $J zone suggest-reverse --all --view internal
contains "2600:3c01::f03c:93ff:fe2b:1/128 .*records in external"
run "created in internal, filled from external's record" 0 $J zone create 2600:3c01::f03c:93ff:fe2b:1/128 --view internal --fill
run "created in internal, filled from external's record" 0 $J zone create 2600:3c01::f03c:93ff:fe2b:1/128 --view internal --fill --other-views
expect_dig "internal answers its PTR" "vps.adbraun.test." -x 2600:3c01::f03c:93ff:fe2b:1
# A zone created inside another takes the parent's records under it.
run "a /16 reverse zone" 0 $J zone create 10.88.0.0/16 --view internal
@@ -896,6 +911,80 @@ if [ -n "$PEBBLE" ]; then
run "a second certificate for the web UI is refused (exit 6)" 6 $J cert create other --domain nas.adbraun.test --use web
run "certificate metrics" 0 curl -sf http://127.0.0.1:$HTTP/metrics
contains '^jarl_certificate_failing{certificate="ns1",source="acme"} 0$'
# Certificates for other hosts: jarl cert get (docs/certificates.md#certificates-for-other-hosts).
run "user for other hosts' certificates" 0 $J user create hosts --no-password --grant "certificates:write names=*.adbraun.test"
run "its token" 0 sh -c "$J token create mx --user hosts -q > $E/hosts.token"
run "a second user with the same grant" 0 $J user create hosts2 --no-password --grant "certificates:write names=*.adbraun.test"
run "and its token" 0 sh -c "$J token create web --user hosts2 -q > $E/hosts2.token"
run "a user without a certificates grant" 0 $J user create nocert --no-password --role acme
run "and its token too" 0 sh -c "$J token create nocert --user nocert -q > $E/nocert.token"
CG="--token @$E/hosts.token"; CGD=$E/certget
mkdir -p $CGD
HOOK='echo "$JARL_CERT_NAME $JARL_CERT_RENEWED $(basename $JARL_CERT_KEY)" >> '$CGD/hook.log
run "cert get: obtained by jarld, downloaded here" 0 $J $CG cert get mail -d mail.adbraun.test --dir $CGD/mail --hook "$HOOK" --config-dir $CGD/conf
contains "mail .*new files"
run "the files" 0 ls $CGD/mail
contains "^chain.pem$"
contains "^fullchain.pem$"
contains "^jarl-cert.json$"
run "the key is mode 600" 0 stat -c %a $CGD/mail/privkey.pem
contains "^600$"
run "the key matches" 0 sh -c "openssl x509 -in $CGD/mail/cert.pem -noout -pubkey | cmp - \$(openssl pkey -in $CGD/mail/privkey.pem -pubout > $CGD/pub.pem; echo $CGD/pub.pem)"
run "the hook ran" 0 cat $CGD/hook.log
contains "^mail 0 privkey.pem$"
run "the settings are saved, readable only by their owner" 0 stat -c %a $CGD/conf/mail.json
contains "^600$"
run "cert get again: up to date, no hook" 0 $J cert get --config-dir $CGD/conf
contains "mail .*up to date"
if [ "$(wc -l < $CGD/hook.log)" = 1 ]; then ok "the hook didn't run again"; else bad "hook ran again: $(cat $CGD/hook.log)"; fi
run "cert saved" 0 $J cert saved --config-dir $CGD/conf
contains "mail *mail.adbraun.test .*$CGD/mail"
run "the owner's cert list: for jarl cert get, downloaded today" 0 $J $CG cert list
contains "^mail *acme *get *mail.adbraun.test *[0-9-]* *$(date +%Y-%m-%d) *ok"
run "cert get --combined: written from this host's files" 0 $J cert get mail --combined $CGD/haproxy/mail.pem --config-dir $CGD/conf
contains "mail .*combined file written, combined $CGD/haproxy/mail.pem"
run "the combined file is mode 600" 0 stat -c %a $CGD/haproxy/mail.pem
contains "^600$"
run "it holds the key" 0 sh -c "openssl pkey -in $CGD/haproxy/mail.pem -pubout | cmp - $CGD/pub.pem"
run "and the certificate" 0 sh -c "openssl x509 -in $CGD/haproxy/mail.pem -noout -serial | cmp - \$(openssl x509 -in $CGD/mail/cert.pem -noout -serial > $CGD/serial; echo $CGD/serial)"
if [ "$(wc -l < $CGD/hook.log)" = 2 ]; then ok "the hook ran for the combined file"; else bad "hook runs: $(cat $CGD/hook.log)"; fi
run "cert saved NAME shows every setting" 0 $J cert saved mail --config-dir $CGD/conf
contains "^Combined: *$CGD/haproxy/mail.pem$"
contains "^Hook: *echo"
run "a combined path inside the files' directory is refused" 1 $J cert get mail --combined $CGD/mail/fullchain.pem --config-dir $CGD/conf
contains "would overwrite mail's fullchain.pem"
run "cert get --local: the hook runs from this host's files" 0 $J cert get mail --local --config-dir $CGD/conf
contains "mail .*this host's files"
if [ "$(wc -l < $CGD/hook.log)" = 3 ]; then ok "the hook ran with --local"; else bad "hook runs: $(cat $CGD/hook.log)"; fi
run "another user can't take the name (exit 5)" 5 $J --token @$E/hosts2.token cert get mail -d mail.adbraun.test --dir $CGD/x --no-save --config-dir $CGD/conf2
contains "taken"
run "an ACME token can't get certificates (exit 3)" 3 $J --token @$E/nocert.token cert get x -d x.adbraun.test --dir $CGD/x --no-save --config-dir $CGD/conf2
run "a name outside the grant (exit 3)" 3 $J $CG cert get other -d mail.example.org --dir $CGD/other --no-save --config-dir $CGD/conf
run "the admin sees the owner and who downloaded it" 0 $J cert show mail
contains "Owner: *hosts"
contains "Pulled by: *token mx (hosts)"
run "jarld renews it" 0 $J cert issue mail
run "cert get: the renewed one, hook told" 0 $J cert get --config-dir $CGD/conf
contains "mail .*new files"
run "the hook ran for the renewal" 0 tail -1 $CGD/hook.log
contains "^mail 1 privkey.pem$"
run "the combined file has the renewed certificate" 0 sh -c "openssl x509 -in $CGD/haproxy/mail.pem -noout -serial | cmp - \$(openssl x509 -in $CGD/mail/cert.pem -noout -serial > $CGD/serial; echo $CGD/serial)"
run "the owner sees the download is current" 0 $J $CG cert list
if echo "$OUT" | grep -q "(old)"; then bad "owner's list says (old)"; else ok "no (old) after the download"; fi
run "cert get --combined '': the combined file is removed" 0 $J cert get mail --combined '' --config-dir $CGD/conf
if [ ! -e $CGD/haproxy/mail.pem ]; then ok "combined file removed"; else bad "combined file still there"; fi
run "a name added: obtained again" 0 $J cert get mail -d mail.adbraun.test -d smtp.adbraun.test --config-dir $CGD/conf
run "the new certificate has both names" 0 openssl x509 -in $CGD/mail/cert.pem -noout -ext subjectAltName
contains "smtp.adbraun.test"
run "cert schedule writes the units" 0 $J cert schedule --unit-dir $CGD --no-systemctl
run "the service runs cert get" 0 cat $CGD/jarl-cert-get.service
contains "^ExecStart=.* cert get --config-dir"
run "the timer runs daily" 0 cat $CGD/jarl-cert-get.timer
contains "^OnCalendar=daily$"
run "cert forget" 0 $J cert forget mail --config-dir $CGD/conf
run "nothing saved now" 0 $J cert saved --config-dir $CGD/conf
contains "No saved certificates"
run "the owner may delete it" 0 $J $CG cert delete mail --yes
# Encrypted DNS set up by Jarl (docs/encrypted-dns.md), next to the
# hand-written listener above.
EDOT=${EDOT_PORT:-25855}; EDOH=${EDOH_PORT:-25856}