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>
This commit is contained in:
adamandClaude Opus 5.5 committed 2026-10-18 16:58:40 +01:00
1 parent b4bb99b482
commit 9db76a113c
29 files changed
+1568 -40

No files matched your search

+2 -1
View File
@@ -604,6 +604,7 @@ Administrators only. See [certificates.md](certificates.md#api).
| 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`,
@@ -631,7 +632,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 |
+75 -5
View File
@@ -231,13 +231,83 @@ 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). Only administrators can export, and every export
of the key is in the audit log (`certificate.export_key`).
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 example from a daily
cron job. A program on this server can instead read the files Jarl writes
for named (use `dns`), which Jarl keeps current.
renewal (`jarl cert show` says when that is). For another host, `jarl
cert get` (below) does that for you. A program on this server can instead
read the files Jarl writes for named (use `dns`), which Jarl keeps current.
## Certificates for other hosts
`jarl cert get` gets a certificate onto another host (a mail server, a
reverse proxy) with one command: jarld obtains it from the CA and renews
it, answering the challenges in its own zones as for its own certificates;
the host asks for it, waits until it is there, downloads it and runs a
hook. The host needs only a token, not an ACME client or access to DNS.
**On Jarl**, as an administrator, give the hosts a user and a token each:
```sh
jarl user create hosts --no-password --grant "certificates:write names=*.adbraun.lol"
jarl token create mx --user hosts # one token per host
```
The `certificates` grant says which names the user may have certificates
for. It is never part of `*`, so `operator` and `readonly` users can't
get certificates (or download keys) unless you add one. A certificate
belongs to the user that asked for it: its tokens can download it, other
users can't take its name or see it, and the certificates Jarl uses
itself are the administrators'. Hosts that should share certificates use
the same user (each with its own token, so you can revoke one host
alone); hosts that shouldn't, use different users. A user may own
`certificates.max_per_user` certificates (10 by default), or what `jarl
user update NAME --cert-limit N` (or the user's form) sets.
**On the host:**
```sh
export JARL_SERVER=https://jarl.adbraun.lol:8053 JARL_TOKEN=@/root/jarl.token
jarl cert get mail -d mail.adbraun.lol -d smtp.adbraun.lol --hook 'systemctl reload postfix dovecot'
sudo jarl cert schedule # a daily systemd timer runs 'jarl cert get'
jarl cert saved # what this host has, and when it expires
```
* **Asking again** with the same names returns the certificate jarld has:
nothing is obtained, nothing is downloaded until jarld renews it.
**Adding or removing a name** gets a new certificate. A different
`--key-type` alone doesn't; delete the certificate for that.
* **Files**, in `--dir` (default `/etc/jarl/certs/NAME` as root, `./NAME`
otherwise): `cert.pem`, `chain.pem` (the intermediates),
`fullchain.pem`, `privkey.pem` (mode 600) and `jarl-cert.json` (serial,
expiry, when it was fetched). Each file is replaced at once, never
half written. The key is checked against the certificate before
anything is written.
* **Saved settings**: the names, `--dir`, `--hook`, the server and the
token are saved in `/etc/jarl/cert-get/NAME.json` (as root;
`~/.config/jarl/cert-get` otherwise), readable only by their owner.
`jarl cert get` without a name does every saved certificate. A token
given as `@FILE` is saved as that reference, not copied. `jarl cert
forget NAME` stops getting one (`--delete` deletes it on jarld too).
* **The hook** runs with `sh` after new files are written, with
`JARL_CERT_NAME`, `JARL_CERT_DIR`, `JARL_CERT_CERT`, `JARL_CERT_CHAIN`,
`JARL_CERT_FULLCHAIN`, `JARL_CERT_KEY`, `JARL_CERT_DOMAINS`,
`JARL_CERT_SERIAL`, `JARL_CERT_NOT_AFTER` and `JARL_CERT_RENEWED` (1 when
it replaced an older certificate). If it fails, `jarl cert get` exits
non-zero and runs it again next time. It has 5 minutes.
* **`jarl cert schedule`** (Linux with systemd, as root) writes
`/etc/systemd/system/jarl-cert-get.service` and `.timer`, which run
`jarl cert get` daily at a random time within the hour after midnight,
and catch up after the host was off. `--remove` takes them away. On
other systems, run `jarl cert get` daily with their scheduler.
**On the Certificates page**, the administrators see each certificate's
owner and who downloaded it: the token, from where, when, and whether
that host has the current certificate or hasn't fetched the renewed one
yet. `jarl cert show NAME` says the same.
## Security notes
+5
View File
@@ -448,6 +448,11 @@ 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 saved # this host's certificates and their expiry
jarl cert forget mail [--delete] # stop getting it here
sudo jarl cert schedule [--remove] # daily systemd timer for jarl cert get
jarl cert delete ns1
jarl cert account # the ACME account (set in jarl.yaml)
```
+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)
+7 -2
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,6 +161,7 @@ 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