Jarl keeps a BIND 9.20 server's whole configuration (views, ACLs, TSIG keys, zones of every type, records, named.conf options) in SQLite and renders named.conf, keys.conf and zone files from it. Every change goes through one changeset pipeline: whole-configuration validation, named-checkconf -z and named-checkzone in a staging directory, atomic install, rndc reconfig/reload, serial verification with rndc zonestatus, and rollback if named does not load the change. Changes apply one at a time by default or are bundled and applied together. Applied changesets can be reverted. - jarld: the daemon with the REST API and an embedded web UI, plus import, adopt, check, resync, backup and local user recovery commands - jarl: the CLI, including ACME DNS-01 hooks for certbot and acme.sh - users with argon2id passwords and CSRF-protected sessions; hashed API tokens with granular grants and roles; an audit log - systemd unit, sysusers.d/tmpfiles.d files, Makefile - unit tests, integration tests against a real named, scripts/e2e.sh - docs/ guides, README and CHANGELOG Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
131 lines
5.7 KiB
Markdown
131 lines
5.7 KiB
Markdown
# Let's Encrypt (ACME DNS-01) with Jarl
|
|
|
|
The DNS-01 challenge proves that you control a domain by publishing a TXT
|
|
record at `_acme-challenge.<domain>`. Jarl does this through its API, so no
|
|
dynamic updates (`allow-update`, nsupdate) are needed. That matters because
|
|
Jarl regenerates zone files and doesn't allow dynamic zones.
|
|
|
|
**When a `jarl acme` command returns, named is already serving the record.**
|
|
Jarl reloads the zone and checks the new serial with `rndc zonestatus`
|
|
before it reports success, so hooks need no "sleep and hope" delay. If you
|
|
also run secondary servers, they pick the change up through NOTIFY within
|
|
seconds. Add a short `--dns-propagation-seconds`/`--dnssleep` only if your
|
|
CA checks them too.
|
|
|
|
## 1. A token that can only publish challenges
|
|
|
|
```sh
|
|
jarl user create certbot --no-password --role acme --comment "Let's Encrypt"
|
|
jarl token create certbot --user certbot --role acme -q > certbot.token
|
|
sudo install -m 0600 certbot.token /etc/jarl/certbot.token && rm certbot.token
|
|
```
|
|
|
|
The `acme` role is `records:write names=_acme-challenge.* types=TXT`. The
|
|
token can add and remove challenge TXT records in any zone and nothing
|
|
else. To restrict it to your zones, use an explicit grant instead:
|
|
|
|
```sh
|
|
jarl user create certbot --no-password --grant "records:write zones=adbraun.lol names=_acme-challenge.* types=TXT"
|
|
```
|
|
|
|
## 2. What `jarl acme` does
|
|
|
|
```sh
|
|
jarl acme set DOMAIN VALUE... # publish
|
|
jarl acme unset DOMAIN [VALUE...] # remove these values, or all of them
|
|
```
|
|
|
|
* `DOMAIN` is the name on the certificate. `_acme-challenge.` is added for
|
|
you, and a leading `*.` is dropped, since a wildcard and its apex share one
|
|
challenge name. A name that already starts with `_acme-challenge.` is used
|
|
as it is.
|
|
* 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.
|
|
* 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.
|
|
* The record TTL is 60 seconds (`--ttl`).
|
|
|
|
## 3. certbot
|
|
|
|
```sh
|
|
sudo certbot certonly --manual --preferred-challenges dns \
|
|
--manual-auth-hook 'jarl --token @/etc/jarl/certbot.token acme certbot-auth' \
|
|
--manual-cleanup-hook 'jarl --token @/etc/jarl/certbot.token acme certbot-cleanup' \
|
|
-d adbraun.lol -d '*.adbraun.lol'
|
|
```
|
|
|
|
The hooks read `CERTBOT_DOMAIN` and `CERTBOT_VALIDATION` from certbot. Add
|
|
`--server https://ns1.adbraun.lol:8053` when certbot runs on another host,
|
|
or `--server http://127.0.0.1:8053` on the DNS server itself (root's jarl
|
|
CLI has no configuration file). certbot stores the hooks in the renewal
|
|
configuration, so `certbot renew` works unattended.
|
|
|
|
## 4. acme.sh
|
|
|
|
Install the DNS API hook that comes with Jarl:
|
|
|
|
```sh
|
|
cp contrib/acme.sh/dns_jarl.sh ~/.acme.sh/dnsapi/ # or from /usr/local/share/doc/jarl/contrib/
|
|
export JARL_SERVER=https://ns1.adbraun.lol:8053
|
|
export JARL_TOKEN=$(cat /etc/jarl/certbot.token)
|
|
acme.sh --issue --dns dns_jarl -d adbraun.lol -d '*.adbraun.lol'
|
|
```
|
|
|
|
The hook calls the `jarl` CLI, which must be installed on the same host. It
|
|
remembers `JARL_SERVER`, `JARL_TOKEN`, `JARL_VIEW` (optional) and `JARL_BIN`
|
|
(path to jarl, optional) in acme.sh's account configuration for renewals.
|
|
When they are unset, jarl's own configuration file is used.
|
|
|
|
## 5. A dedicated ACME zone (CNAME delegation)
|
|
|
|
Instead of letting a token write into your main zone, point each challenge
|
|
name at a small zone that exists only for ACME. The token may then write
|
|
only there:
|
|
|
|
```sh
|
|
jarl zone create acme.adbraun.lol --ns ns1.adbraun.lol.
|
|
jarl record add adbraun.lol acme NS ns1.adbraun.lol. # delegate it
|
|
jarl record add adbraun.lol _acme-challenge CNAME apex.acme.adbraun.lol.
|
|
jarl record add adbraun.lol _acme-challenge.www CNAME www.acme.adbraun.lol.
|
|
|
|
jarl user create acme --no-password --grant "records:write zones=acme.adbraun.lol types=TXT"
|
|
jarl token create acme --user acme -q > acme.token
|
|
```
|
|
|
|
The CA follows the CNAME, so the TXT record has to be written at its
|
|
target:
|
|
|
|
* **certbot**: add `--follow-cname` to both hooks, e.g.
|
|
`jarl --token @/etc/jarl/acme.token acme certbot-auth --follow-cname`.
|
|
jarl looks up `_acme-challenge.<domain>` and writes the TXT record at the
|
|
target (`www.acme.adbraun.lol`). The lookup uses the host's resolvers.
|
|
Add `--resolver 127.0.0.1` to ask the Jarl server directly, for example
|
|
when the host's resolver sees a different view.
|
|
* **acme.sh** does the lookup itself:
|
|
* `--challenge-alias acme.adbraun.lol` puts every TXT record at
|
|
`_acme-challenge.acme.adbraun.lol`. Point all the CNAMEs there
|
|
(`_acme-challenge.www CNAME _acme-challenge.acme.adbraun.lol.`).
|
|
* `--domain-alias www.acme.adbraun.lol` writes at exactly that name.
|
|
|
|
The hook passes acme.sh's full record name to `jarl acme --exact`, so both
|
|
modes work.
|
|
|
|
A leaked ACME token can then only touch TXT records in the ACME zone, and
|
|
the main zone is never modified by automation.
|
|
|
|
## Troubleshooting
|
|
|
|
* `no primary zone that you can see contains ...`: the token's grants
|
|
don't cover the zone, or the zone isn't a primary zone in Jarl.
|
|
* `permission denied: this needs records:write ...` (exit code 3): the
|
|
record name or type isn't allowed for the token. Compare with
|
|
`jarl --token @FILE whoami`.
|
|
* The CA says the TXT record is missing: check from outside with
|
|
`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.
|
|
* `jarl audit --user certbot` shows every attempt.
|