release / release (push) Successful in 7m7s
make deb now builds two packages: jarl-cli (the CLI, its completion and the ACME hooks, no scripts) and jarl (the server), which depends on jarl-cli of the same version. Pushing a v* tag runs .gitea/workflows/release.yml: it tests, builds the tarballs, the CLI for Linux, macOS and Windows (make cli-dist) and the .debs for amd64 and arm64, then scripts/gitea-release.sh creates the Gitea release and uploads the .debs to the Debian package registry, so servers install and upgrade with apt. The stats package's inode lookup is split by OS so the CLI builds for Windows. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
408 lines
17 KiB
Markdown
408 lines
17 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/share/doc/jarl-cli/contrib/ (jarl-cli package)
|
|
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/share/doc/jarl-cli/contrib/ (jarl-cli package)
|
|
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. lego and Traefik over HTTP (httpreq), without the jarl CLI
|
|
|
|
lego's `httpreq` provider publishes the challenge with an HTTP request
|
|
instead of running a program. jarld can answer those requests itself, so
|
|
the machine running lego needs nothing from Jarl: no `jarl` binary, no
|
|
script, only a few environment variables. That suits Traefik (which
|
|
uses lego inside) and lego in a container.
|
|
|
|
| | `exec` + `jarl-lego` (section 5) | `httpreq` (this section) |
|
|
|--|--|--|
|
|
| Needs on lego's host | the `jarl` CLI and the script | nothing |
|
|
| Turned on in jarld | always available | `acme.httpreq.enabled: true` |
|
|
| How the token travels | Bearer header, from the jarl CLI | HTTP Basic password, so HTTPS is required |
|
|
| Works in Traefik | no | yes |
|
|
|
|
Both end up doing exactly the same thing in Jarl (the shared code is in
|
|
`internal/acme`): `jarl acme set|unset --exact FQDN VALUE`.
|
|
|
|
### How it works
|
|
|
|
lego is given a base URL, a username and a password. For every name on
|
|
the certificate it sends
|
|
|
|
```http
|
|
POST /api/v1/acme/httpreq/present HTTP/1.1
|
|
Authorization: Basic base64(lego:jarl_...)
|
|
Content-Type: application/json
|
|
|
|
{"fqdn": "_acme-challenge.adbraun.lol.", "value": "LHDhK3oGRvkiefQnx7OOczTY5Tic_xZ6HcMOc_gmtoM"}
|
|
```
|
|
|
|
and, once the CA has checked the record, the same body to
|
|
`/api/v1/acme/httpreq/cleanup`. lego treats any 2xx answer as success.
|
|
`fqdn` is the full record name, after lego has followed any CNAME (so CNAME
|
|
delegation to an ACME zone, section 7, just works).
|
|
|
|
For each request jarld:
|
|
|
|
1. refuses it unless the endpoint is enabled (`404 disabled`);
|
|
2. refuses it unless it came over HTTPS (`403 https_required`, see
|
|
[HTTPS and reverse proxies](#https-and-reverse-proxies));
|
|
3. checks the credentials: the password must be a valid API token and the
|
|
username the user that owns it (`401`). Failures count towards the
|
|
same per-IP lockout as other token failures and appear in the audit log
|
|
as `auth.token_failed`;
|
|
4. checks the body: `fqdn` must be a domain name and `value` a DNS-01
|
|
challenge value (letters, digits, `-` and `_`) (`400`);
|
|
5. finds the longest primary zone that contains `fqdn` among the zones the
|
|
token can see, in every view that has it (`404` if there is none);
|
|
6. sets or removes the TXT record (TTL 60) in one changeset, checked by the
|
|
token's grants like any other change (`403` if they don't allow it),
|
|
and answers `200` once named serves the change.
|
|
|
|
```json
|
|
{"ok": true, "fqdn": "_acme-challenge.adbraun.lol.", "changeset_id": 412, "zones": ["adbraun.lol (view external)", "adbraun.lol (view internal)"]}
|
|
```
|
|
|
|
Setting a value that is already there, and removing one that isn't,
|
|
both succeed, so lego can retry safely. The changeset description ends in
|
|
"(lego httpreq)", so `jarl history` and the audit log show where a change
|
|
came from.
|
|
|
|
HTTP Basic is accepted on these routes only. Everywhere else the API
|
|
still takes only Bearer tokens and web sessions.
|
|
|
|
### 1. Enable the endpoint
|
|
|
|
In `/etc/jarl/jarl.yaml`:
|
|
|
|
```yaml
|
|
acme:
|
|
httpreq:
|
|
enabled: true
|
|
require_https: true # the default; see below
|
|
allow_loopback_http: false # the default
|
|
```
|
|
|
|
Then `sudo systemctl restart jarl`. jarld's log says at startup whether
|
|
the endpoint can work, for example `acme.httpreq is enabled but jarld has
|
|
no tls and no trusted_proxies, so every request will be refused`.
|
|
|
|
### HTTPS and reverse proxies
|
|
|
|
The token is in every request, so by default jarld only accepts requests
|
|
that reached it over HTTPS. Everything else in Jarl (the web UI, the CLI,
|
|
the rest of the API) works over plain HTTP exactly as before; only this
|
|
endpoint checks. jarld decides like this:
|
|
|
|
| The request came... | Counts as HTTPS when |
|
|
|--|--|
|
|
| straight to jarld with `tls` configured | always |
|
|
| from an address in `trusted_proxies` | that proxy says the client used HTTPS: `X-Forwarded-Proto: https`, or if that header is missing, `Forwarded: proto=https` (RFC 7239) |
|
|
| from any other address without TLS | never. `X-Forwarded-Proto` and `Forwarded` are ignored, since anyone can send them |
|
|
|
|
Proxies append to these headers, so when a header holds a list
|
|
(`X-Forwarded-Proto: https, http`) jarld uses the **last** value: the one
|
|
the nearest proxy, the one it trusts, added. A value a client made up comes
|
|
earlier in the list and is ignored. The same rule decides whether the web
|
|
UI's session cookie gets the Secure flag (`session.cookie_secure: auto`).
|
|
|
|
**Setup A: jarld does TLS itself.** Set `tls.cert_file` and
|
|
`tls.key_file` ([configuration.md](configuration.md)). Nothing else is
|
|
needed.
|
|
|
|
**Setup B: behind a reverse proxy that terminates TLS.** Add the proxy's
|
|
address (as jarld sees it) to `trusted_proxies`, and make sure the proxy
|
|
sets `X-Forwarded-Proto`:
|
|
|
|
```yaml
|
|
listen: ["127.0.0.1:8053"]
|
|
trusted_proxies: [127.0.0.1] # the proxy runs on this host
|
|
```
|
|
|
|
* **nginx** doesn't set it by default:
|
|
```nginx
|
|
location / {
|
|
proxy_pass http://127.0.0.1:8053;
|
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
proxy_set_header X-Forwarded-Proto $scheme;
|
|
}
|
|
```
|
|
* **Caddy** (`reverse_proxy 127.0.0.1:8053`) and **Traefik** set
|
|
`X-Forwarded-Proto` themselves, and replace the value a client sent.
|
|
* **HAProxy**: `http-request set-header X-Forwarded-Proto https if { ssl_fc }`.
|
|
|
|
lego then uses the proxy's HTTPS URL as its endpoint.
|
|
|
|
**Setup C: lego runs on the DNS server itself** and talks to
|
|
`http://127.0.0.1:8053`. Traffic over loopback never leaves the machine,
|
|
so it can be allowed:
|
|
|
|
```yaml
|
|
acme:
|
|
httpreq:
|
|
enabled: true
|
|
allow_loopback_http: true
|
|
```
|
|
|
|
This accepts plain HTTP from `127.0.0.1` and `::1` only, and only when
|
|
that address is **not** in `trusted_proxies`. A trusted proxy on the same
|
|
host is judged by its headers instead, otherwise everything it forwarded
|
|
from the internet would look local. Don't use `allow_loopback_http` if a
|
|
reverse proxy runs on the DNS server but isn't in `trusted_proxies`.
|
|
|
|
**Turning the check off** (`require_https: false`) accepts plain HTTP
|
|
from anywhere. jarld logs a warning at startup. Only do this on a network
|
|
where you'd also send passwords in the clear.
|
|
|
|
When a request is refused for not being HTTPS, the answer explains why,
|
|
for example: `this request came over plain HTTP from 192.0.2.9, which sent
|
|
X-Forwarded-Proto or Forwarded but isn't in trusted_proxies, so the header
|
|
was ignored`. jarld also logs a warning and an `acme.httpreq_refused` audit
|
|
event. The token in such a request may have crossed the network
|
|
unencrypted; if it did, revoke it (`jarl token revoke ID`) and make a new
|
|
one.
|
|
|
|
**Check a setup** without a token. The status endpoint answers without
|
|
authentication and says how jarld sees the request:
|
|
|
|
```sh
|
|
curl https://ns1.adbraun.lol/api/v1/acme/httpreq
|
|
{"accepted": true, "enabled": true, "https": true, "transport": "HTTPS to trusted proxy 127.0.0.1 (X-Forwarded-Proto: https)"}
|
|
```
|
|
|
|
Run it from where lego runs, against the URL lego will use.
|
|
|
|
### 2. A user and token for lego
|
|
|
|
```sh
|
|
jarl user create lego --no-password --role acme --comment "lego / Traefik"
|
|
jarl token create lego --user lego --role acme -q > lego.token
|
|
```
|
|
|
|
The username lego sends must be the token's user, `lego` here. For CNAME
|
|
delegation into an ACME zone, use the zone grant from section 7 instead
|
|
of the `acme` role.
|
|
|
|
### 3. lego
|
|
|
|
```sh
|
|
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
|
|
```
|
|
|
|
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`.
|
|
|
|
### 4. Traefik
|
|
|
|
Traefik passes the same variables to lego. Static configuration:
|
|
|
|
```yaml
|
|
certificatesResolvers:
|
|
letsencrypt:
|
|
acme:
|
|
email: you@adbraun.lol
|
|
storage: /letsencrypt/acme.json
|
|
dnsChallenge:
|
|
provider: httpreq
|
|
```
|
|
|
|
and in the Traefik container's environment:
|
|
|
|
```yaml
|
|
environment:
|
|
HTTPREQ_ENDPOINT: https://ns1.adbraun.lol:8053/api/v1/acme/httpreq
|
|
HTTPREQ_USERNAME: lego
|
|
HTTPREQ_PASSWORD_FILE: /run/secrets/jarl_token
|
|
```
|
|
|
|
### Trying it by hand
|
|
|
|
```sh
|
|
TOKEN=$(cat lego.token)
|
|
curl -u "lego:$TOKEN" -H 'Content-Type: application/json' \
|
|
-d '{"fqdn":"_acme-challenge.adbraun.lol.","value":"test-value"}' \
|
|
https://ns1.adbraun.lol:8053/api/v1/acme/httpreq/present
|
|
dig @ns1.adbraun.lol _acme-challenge.adbraun.lol TXT +short
|
|
curl -u "lego:$TOKEN" -H 'Content-Type: application/json' \
|
|
-d '{"fqdn":"_acme-challenge.adbraun.lol.","value":"test-value"}' \
|
|
https://ns1.adbraun.lol:8053/api/v1/acme/httpreq/cleanup
|
|
```
|
|
|
|
### Answers and what they mean
|
|
|
|
| Status and code | Meaning |
|
|
|--|--|
|
|
| `200` | Done; named serves the change. |
|
|
| `400 bad_request` | The body isn't `{"fqdn", "value"}` (or is lego's `RAW` mode), or `fqdn` / `value` is invalid. |
|
|
| `401 unauthenticated` | No credentials, an invalid, expired or revoked token, or a username that isn't the token's user. |
|
|
| `403 https_required` | Not HTTPS, see above. If the token crossed a network unencrypted, revoke it. |
|
|
| `403 forbidden` | The token's grants don't allow this TXT record. |
|
|
| `404 disabled` | `acme.httpreq.enabled` is off. |
|
|
| `404 not_found` | No primary zone that the token can see contains `fqdn` (in that view). |
|
|
| `422` and others | The change failed Jarl's validation or named's check, as with any change. |
|
|
| `429 rate_limited` | Too many failed logins from this address; wait a few minutes. |
|
|
|
|
## 7. 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** (`exec` or `httpreq`) follows the CNAME itself (unless `LEGO_DISABLE_CNAME_SUPPORT`
|
|
is set) and passes the target to `jarl-lego` or jarld, which write there as
|
|
given. 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.
|