The web UI's certificate from Let's Encrypt, through Jarl

tls.acme names the certificate and jarl: the Jarl server and a token that
may only write the challenge record. Steward gets the certificate with
DNS-01 (lego), writing the record through Jarl's API into every view that
has the zone, renews it with a third of its lifetime left and swaps it
in without a restart. A check runs before every order: Jarl, the zone,
the token's permission, a test record, the zone's name servers, public
resolvers, clean-up; each step says what to fix. Staging by default.

With a certificate, each listener serves HTTPS and plain HTTP on one port
(redirecting plain HTTP, except from the server itself).

steward cert / cert check / cert renew, Server -> Certificate in the web
UI, GET /certificate, POST /certificate/check|renew. docs/certificates.md
and docs/jarl.md (the Jarl user and the token's grant).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
abrown38andClaude Opus 5.5 committed 2026-10-14 15:46:36 +01:00
1 parent d0ee2c8844
commit 7b3f0f764c
32 files changed
+2544 -27

No files matched your search

+13
View File
@@ -124,6 +124,19 @@ settings'.
| `POST /history/{id}/revert` | undo it; 409 if an object it touched has changed since, or it was undone already |
| `POST /import/kea` | `{"config": TEXT, "leases": [TEXT, ...], "interfaces": {"PREFIX": "IFACE"}, "replace": false}` ([kea.md](kea.md)); answers with `leases`, `notes`, `skipped` too |
### Certificate
Administrators only ([certificates.md](certificates.md)).
| | |
|---|---|
| `GET /certificate` | `{"configured", "source": "acme"\|"file"\|"none", "status": {"domain", "directory", "staging", "state": "none"\|"ok"\|"working"\|"error", "not_before", "not_after", "issuer", "renew_at", "last_error", "last_attempt", "next_attempt", "check"}}` |
| `POST /certificate/check` | run the check now (up to a few minutes); answers `{"time", "domain", "ok", "steps": [{"name", "status", "detail", "queries"}]}` |
| `POST /certificate/renew` | get a new certificate now, in the background (202); follow it with `GET /certificate` |
`GET /status` lists the certificate's problems (`certificate_failed`,
`certificate_expiring`, `certificate_staging`) for administrators.
### Users, tokens, audit
| | |
+120
View File
@@ -0,0 +1,120 @@
# The web UI's certificate
Steward can serve its web UI and API over HTTPS in two ways:
* **A certificate you manage**: `tls.cert_file` and `tls.key_file` in
`steward.yaml`. stewardd rereads them when they change
([configuration.md](configuration.md#https)).
* **A certificate it gets itself** from Let's Encrypt (or another ACME CA),
with Jarl publishing the DNS-01 challenge. That's this page.
With DNS-01 the web UI doesn't need to be reachable from the internet.
`dhcp.adbraun.lol` can point at a private address and the certificate
still works on your network. Only the challenge record has to be visible
on the internet, and Jarl puts it in every view that has your zone.
## Setting it up
1. **Connect Steward to Jarl** with a user and a token that may only write
Steward's own challenge record: [jarl.md](jarl.md).
2. **Give your web UI a name in DNS**, such as `dhcp.adbraun.lol` with the
Steward server's address, in Jarl. It is the name you will type in the
browser.
3. **Configure the certificate** in `/etc/steward/steward.yaml`:
```yaml
listen: ["10.0.10.2:8067"] # an address your browser can reach
tls:
acme:
domain: dhcp.adbraun.lol
email: you@adbraun.lol # Let's Encrypt writes here if something is wrong
directory: letsencrypt-staging # first; see below
agree_tos: true # you accept Let's Encrypt's terms of service
jarl:
server: https://dns.adbraun.lol:8053
token_file: /etc/steward/jarl.token
```
4. **Restart stewardd** (`sudo systemctl restart steward`), and check:
```sh
sudo steward cert check
```
5. **Watch it get the certificate:** `sudo steward cert`, or Server →
Certificate in the web UI. Then open `https://dhcp.adbraun.lol:8067/`.
6. **Switch to the real CA.** The staging certificate works, but browsers
warn about it, because Let's Encrypt's staging service isn't trusted.
It's there to prove everything works without touching Let's Encrypt's
rate limits. Once it does, set `directory: letsencrypt`, restart, and
Steward gets a trusted certificate right away.
Until the first certificate arrives, the web UI is served over plain
HTTP. stewardd switches to HTTPS on the same port without a restart. From
then on, plain HTTP is redirected to HTTPS, except from the server itself
(`127.0.0.1`), so tools there keep working.
## Checking
Before every certificate order, and whenever you ask (`steward cert
check`, or Test in the web UI), Steward checks the whole path without
asking the CA. Let's Encrypt allows only 5 failed validations per name
per hour, so a broken setup shouldn't find out the hard way.
| Step | What it checks |
|---|---|
| Jarl | Jarl answers at `jarl.server`, and the token is valid (it says whose it is) |
| Zone | a primary zone the token can see holds `_acme-challenge.<domain>`, and in which views |
| Permission | the token may write the TXT record there (Jarl checks it as a dry run) |
| Write | a test record with a random value is written through Jarl, which loads it into named |
| Name servers | each of the zone's public name servers serves it, asked directly as the CA's resolvers will. A name server passes if any of its addresses does (`tls.acme.check_propagation`) |
| Public resolvers | the resolvers in `tls.acme.resolvers` (1.1.1.1, 9.9.9.9, 8.8.8.8) see it. If the name servers do but a resolver doesn't, it is only a warning: it may be holding an earlier answer |
| Clean up | the test record is removed |
Each step says what went wrong and what to do. For example:
```
ok Jarl the token is steward's, on Jarl v0.6.1
ok Zone adbraun.lol in views internal, external
FAIL Permission the token may not write the challenge record (...); give it the grant:
records:write zones=adbraun.lol names=_acme-challenge.dhcp.adbraun.lol types=TXT
```
The checks wait up to 90 seconds for the record to appear. A failing step
lists the DNS questions it asked, as `dig` commands, so you can ask them
yourself.
If the challenge name is a CNAME into another zone, the check follows it,
as Let's Encrypt does.
## Renewal
A certificate is renewed when a third of its lifetime is left: 30 days
before the end for Let's Encrypt's 90-day certificates, 2 days for 6-day
ones. stewardd looks twice a day. The check runs first every time, and
the new certificate is used at once, without a restart.
If renewing fails, the old certificate keeps being served, and stewardd
tries again after 1 hour, then 3, 6 and every 12. The dashboard shows a
problem (`certificate_failed`), and `certificate_expiring` once less than
14 days are left. `steward cert renew` (Renew now in the web UI) tries
again at once.
The ACME account and the certificate, with its key, are kept in Steward's
database, so backups include them.
## Settings
All under `tls.acme` in `steward.yaml` ([configuration.md](configuration.md)):
| Setting | Default | |
|---|---|---|
| `domain` | none (off) | the name on the certificate |
| `email` | none | the account's contact address |
| `directory` | `letsencrypt-staging` | `letsencrypt`, `letsencrypt-staging`, or an ACME directory URL (a private CA such as step-ca) |
| `agree_tos` | `false` | accept the CA's terms of service; no account is made without it |
| `resolvers` | `1.1.1.1:53`, `9.9.9.9:53`, `8.8.8.8:53` | public resolvers, to see the challenge as the CA does |
| `check_propagation` | `true` | also ask each of the zone's name servers directly |
| `ca_bundle` | none | extra CAs to trust for the ACME server itself |
`tls.cert_file` and `tls.acme` can't both be set.
+10
View File
@@ -129,6 +129,16 @@ sent.
`steward import kea CONFIG [--leases FILE]... [--interface PREFIX=IFACE]... [--replace]`:
see [kea.md](kea.md).
### cert
Administrators only ([certificates.md](certificates.md)).
| Command | |
|---|---|
| `cert` | the certificate: name, CA, validity, renewal, the last check |
| `cert check` | check that the challenge can be published through Jarl and seen, without the CA (fails with exit code 1 if it can't) |
| `cert renew [-w]` | get a new certificate now; `-w` waits and shows the result |
### users, tokens, audit
| Command | |
+8 -3
View File
@@ -12,6 +12,8 @@ the file when it starts.
|---|---|---|
| `listen` | `["127.0.0.1:8067"]` | addresses of the web UI and API (`host:port`). Keep them on localhost or a management network |
| `tls.cert_file`, `tls.key_file` | none | serve HTTPS with this certificate; stewardd rereads it when the file changes, or on `systemctl reload steward` |
| `tls.acme.*` | off | get the certificate from Let's Encrypt, with Jarl publishing the challenge ([certificates.md](certificates.md#settings)) |
| `jarl.server`, `jarl.token_file` (or `jarl.token`), `jarl.ca_cert`, `jarl.insecure` | none | the Jarl server Steward uses for DNS, and its token ([jarl.md](jarl.md)) |
| `trusted_proxies` | `[]` | addresses or prefixes of reverse proxies whose `X-Forwarded-For` and `X-Forwarded-Proto` are believed |
| `socket` | `/run/steward/steward.sock` | the local API socket; `""` turns it off |
| `database` | `/var/lib/steward/steward.db` | the SQLite database: configuration, leases, history, users, audit log |
@@ -29,8 +31,9 @@ Durations are written `15s`, `12h` or `30d`.
## HTTPS
Over plain HTTP, passwords and tokens cross the network in the clear;
stewardd warns when it listens on a non-loopback address without TLS. Give
it a certificate:
stewardd warns when it listens on a non-loopback address without TLS. Let
it get its own certificate through Jarl ([certificates.md](certificates.md)),
or give it one:
```yaml
listen: ["10.0.10.2:8067"]
@@ -40,5 +43,7 @@ tls:
```
Or keep it on `127.0.0.1` behind a reverse proxy that terminates TLS, and
list the proxy in `trusted_proxies`. Ports below 1024 work too: the unit
list the proxy in `trusted_proxies`. With a certificate, the same port
answers HTTPS and redirects plain HTTP to it, except from the server
itself. Ports below 1024 work too: the unit
grants `CAP_NET_BIND_SERVICE` for DHCP's port 67.
+27
View File
@@ -71,6 +71,33 @@ sandbox is only tested by installing the package in a container that
runs systemd (`--privileged /sbin/init`) and pointing a client container
at it.
## Certificates
`internal/certs` has unit tests with a fake Jarl and an in-process DNS
server, covering every failure the check reports. To test a real order:
1. Run Jarl's e2e and keep its jarld and named:
`KEEP=1 PEBBLE=0 JARL_BIND_ROOT=... WORK=DIR scripts/e2e.sh` in `../jarl`.
jarld is then on `127.0.0.1:28053` and named on `127.0.0.1:25353`, with
the zone `adbraun.test` in two views.
2. Make the restricted user and token there, as docs/jarl.md says, with
`DIR/jarl --config DIR/cli.yaml`.
3. Start Pebble against that named:
`docker run -d --network host ghcr.io/letsencrypt/pebble -config test/config/pebble-config.json -dnsserver 127.0.0.1:25353`,
and copy its `/test/certs/pebble.minica.pem` out.
4. Point a scratch stewardd at both:
```yaml
tls:
acme: {domain: dhcp.adbraun.test, email: t@adbraun.test, agree_tos: true,
directory: https://localhost:14000/dir, ca_bundle: pebble.minica.pem,
resolvers: ["127.0.0.1:25353"], check_propagation: false}
jarl: {server: http://127.0.0.1:28053, token_file: jarl.token}
```
`check_propagation` has to be off here: the test named isn't on port 53.
The unit tests cover that step.
## The web UI
Plain JavaScript modules under `internal/web/static/`, built with the
+85
View File
@@ -0,0 +1,85 @@
# Connecting Steward to Jarl
Steward uses [Jarl](https://gitea.adbraun.lol/adam/jarl), the BIND
manager, for its DNS records. In this version that means one thing: the
DNS-01 challenge for the web UI's certificate ([certificates.md](certificates.md)).
Registering clients' names is planned to use the same connection.
Steward talks to Jarl's REST API with an API token. Give it a **user of its
own**, with **only the permission it needs**.
## 1. A user for Steward, in Jarl
On the Jarl server, make a user without a password. It can't log in to
Jarl's web UI, only use its tokens. Grant it the challenge record of
Steward's own name, and nothing else:
```sh
jarl user create steward --no-password --comment "Steward on dhcp01" \
--grant "records:write zones=adbraun.lol names=_acme-challenge.dhcp.adbraun.lol types=TXT"
```
Use your zone for `adbraun.lol`, and the name Steward's web UI is reached
by for `dhcp.adbraun.lol` (`tls.acme.domain`). The grant lets the token add
and remove TXT records at exactly `_acme-challenge.dhcp.adbraun.lol`:
* nothing else in the zone (`names=`);
* no other record type (`types=TXT`);
* no other zone (`zones=`).
It can see that the zone exists, which it needs to find it, but it can't
read the zone's other records.
Why not something wider:
* **Jarl's `acme` role** (`records:write names=_acme-challenge.* types=TXT`)
would let the token publish challenges for *any* name in *any* zone, and
so obtain certificates for all of them. Steward needs only its own name.
* **Your own account's token** would carry all your permissions, and
Steward's certificate would stop renewing the day your account changes.
A user per program keeps the two apart, and the audit log shows
`steward/...` for what Steward did.
If the challenge name is a CNAME into a zone kept for challenges, such as
`_acme-challenge.dhcp.adbraun.lol CNAME dhcp.acme.adbraun.lol.`, grant the
target instead: `zones=acme.adbraun.lol names=dhcp.acme.adbraun.lol`.
## 2. The token
Make the token, and copy it to the Steward server into a file only the
`steward` user can read:
```sh
jarl token create dhcp01 --user steward -q > steward.token # on the Jarl server, or with the jarl CLI anywhere
scp steward.token dhcp01:
ssh dhcp01 'sudo install -m 0600 -o steward -g steward steward.token /etc/steward/jarl.token && rm steward.token'
rm steward.token
```
A token file is better than putting the token in `steward.yaml`: the
configuration file is readable by the `steward` group, and gets copied
around more readily.
You can give the token an expiry (`--expires 365d`). Rotate it in Jarl with
`jarl token rotate ID --grace 1d`, then put the new secret in the file and
restart stewardd within the grace period. Since the certificate is renewed
every couple of months, nothing breaks while you do.
## 3. Steward's settings
In `/etc/steward/steward.yaml`:
```yaml
jarl:
server: https://dns.adbraun.lol:8053
token_file: /etc/steward/jarl.token
# ca_cert: /etc/steward/jarl-ca.pem # if Jarl's certificate isn't from a public CA
```
Then restart stewardd (`systemctl restart steward`). Steward needs to
reach Jarl's API over the network: Jarl's `listen` must include an
address Steward can reach, ideally over HTTPS.
`steward cert check` ([certificates.md](certificates.md#checking)) tests
the connection, the token's permission, and that the record reaches the
internet.
+2
View File
@@ -10,6 +10,8 @@ else gets **grants**, `resource:access`:
| `config` | settings, subnets, reservations, history, interfaces (the web UI shows the forms locked) | change them, undo, import |
| `leases` | leases and DHCP activity | delete a lease (making a reservation from one needs `config:write` too) |
| `audit` | the audit log | – |
The certificate (`steward cert`, Server → Certificate) is for administrators only.
| `*` | all of the above | |
**Roles** are ready-made sets of grants:
+11
View File
@@ -63,3 +63,14 @@ These apply to options anywhere (settings, subnets, reservations).
| `reservation_client_taken` | error | the client has a reservation in this subnet already (one per subnet; a device on two VLANs may have one in each) |
| `reservation_hostname` | error | not a host name (letters, digits and `-`, labels up to 63 characters) |
| `reservation_server_address` | warning, host | the server's own address |
## Certificate
Not configuration problems, but shown with them on the dashboard and in
`steward status`, to administrators ([certificates.md](certificates.md)):
| Code | | When |
|---|---|---|
| `certificate_failed` | warning | the last attempt to get the web UI's certificate failed (the message says where) |
| `certificate_expiring` | warning | the certificate ends in less than 14 days and hasn't been renewed |
| `certificate_staging` | warning | the certificate is from Let's Encrypt's staging service, which browsers don't trust |