contrib/lego/jarl-lego.sh turns lego's "present|cleanup FQDN VALUE" calls into jarl acme set|unset --exact, so lego (and CNAME delegation to a dedicated ACME zone) works with Jarl. docs/acme.md explains the setup. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
166 lines
7.5 KiB
Markdown
166 lines
7.5 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. lego
|
|
|
|
lego's `exec` provider runs a program to publish and remove the record.
|
|
Jarl ships one, `contrib/lego/jarl-lego.sh`, which calls the `jarl` CLI
|
|
(so jarl must be installed on the same host):
|
|
|
|
```sh
|
|
sudo install -m 0755 contrib/lego/jarl-lego.sh /usr/local/bin/jarl-lego # or from /usr/local/share/doc/jarl/contrib/
|
|
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
|
|
```
|
|
|
|
Renew with the same variables and `lego ... renew` in place of `run`.
|
|
|
|
* 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
|
|
full record name (`_acme-challenge.adbraun.lol.`), so `--exact` stops
|
|
jarl from adding `_acme-challenge.` a second time.
|
|
* `JARL_TOKEN` may be the token itself or `@FILE`. `JARL_VIEW` limits the
|
|
record to one view, and `JARL_BIN` points at jarl if it isn't in `PATH`.
|
|
When `JARL_SERVER` and `JARL_TOKEN` are unset, jarl's configuration file
|
|
is used.
|
|
* 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.
|
|
|
|
## 6. 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.
|
|
* **lego** follows the CNAME itself (unless `LEGO_DISABLE_CNAME_SUPPORT`
|
|
is set) and passes the target to `jarl-lego`, which writes there with
|
|
`--exact`. The `acme` role allows only names that start with
|
|
`_acme-challenge`, so the token needs the grant above, not the role. lego
|
|
uses the host's resolvers for the lookup; `--dns.resolvers
|
|
ns1.adbraun.lol:53` asks the Jarl server instead.
|
|
|
|
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.
|