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:
1 parent
d0ee2c8844
commit
7b3f0f764c
32 files changed
+2544
-27
No files matched your search
+13
@@ -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
|
||||
|
||||
| | |
|
||||
|
||||
@@ -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
@@ -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 | |
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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:
|
||||
|
||||
@@ -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 |
|
||||
Reference in new issue
Block a user