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

+5
View File
@@ -19,6 +19,11 @@ When in doubt, do it the way Jarl does. Go module `steward`,
result, saves the objects and a history entry in one transaction, then
hands the new state to the DHCP server. Never write subnets,
reservations or settings to the store from a handler.
* **The web UI's certificate** (`internal/certs`, with `internal/jarl` for
Jarl's API) is obtained through lego, with Jarl publishing the
DNS-01 challenge. A preflight check runs before every order. Test it
against Jarl's e2e environment (`KEEP=1 PEBBLE=0 scripts/e2e.sh` in
`../jarl`) with Pebble pointed at its named (docs/development.md).
* **Leases are not configuration.** `dhcpd` owns them: kept in memory, and
written to the store (`PutLease`) before the ACK is sent. They are not
in the history.
+19
View File
@@ -83,6 +83,25 @@ and a CLI, built the way Jarl is.
- **`stewardd`** commands: `init` (asks for the administrator's name),
`user add|password|token`, `check`, `backup` (with pruning), `restore`,
`config --example`.
- **The web UI's certificate from Let's Encrypt, through Jarl**
(docs/certificates.md, docs/jarl.md):
- `tls.acme` in `steward.yaml` names the certificate, and `jarl:` the
Jarl server and a token that may only write the challenge record.
- Steward gets the certificate with the DNS-01 challenge, writing the
record through Jarl's API into every view that has the zone.
- It renews with a third of the lifetime left, and swaps the new
certificate in without a restart.
- A check runs before every order, and on demand: Jarl, the zone, the
token's permission, a test record, the zone's public name servers and
public resolvers, clean-up. Each step says what to fix.
- It starts with Let's Encrypt's staging service by default.
- `steward cert`, `cert check`, `cert renew`; Server → Certificate in the
web UI; `GET /certificate`, `POST /certificate/check|renew`.
- Dashboard problems: `certificate_failed`, `certificate_expiring`,
`certificate_staging`.
- **HTTPS and plain HTTP on one port:** with a certificate, plain HTTP is
redirected to HTTPS, except from the server itself (also for
`tls.cert_file`).
- **Token rotation** (`steward token rotate`, `POST /tokens/{id}/rotate`,
Rotate in the web UI): a new secret for a token, with an optional grace
period for the old one, as in Jarl.
+7
View File
@@ -47,6 +47,11 @@ network: it greets it, gives it an address, and tells it the way around
administrator, or `config`, `leases` and `audit` access, read or write.
On the server itself, root needs no setup: the CLI uses stewardd's local
socket.
* **HTTPS on its own.** Give it a name and a Jarl token, and Steward gets its
web UI's certificate from Let's Encrypt and renews it, with Jarl
publishing the DNS-01 challenge. It checks the whole path first, so a
broken setup never wastes Let's Encrypt's rate limits
([docs/certificates.md](docs/certificates.md)).
* **One small service.** `stewardd` is one static binary, run by a
sandboxed systemd unit whose only privilege is binding port 67. Debian
packages and a daily backup timer are included.
@@ -89,6 +94,8 @@ sudo systemctl disable --now kea-dhcp4-server
* [subnets.md](docs/subnets.md): subnets, interfaces, pools, reservations, options, leases, and how addresses are chosen
* [options.md](docs/options.md): the options Steward knows and how to write their values
* [kea.md](docs/kea.md): moving from Kea
* [certificates.md](docs/certificates.md): HTTPS with a certificate from Let's Encrypt, through Jarl
* [jarl.md](docs/jarl.md): connecting Steward to Jarl, and what its token may do
* [cli.md](docs/cli.md): the `steward` command line client
* [api.md](docs/api.md): the REST API
* [configuration.md](docs/configuration.md): `/etc/steward/steward.yaml`
+158
View File
@@ -0,0 +1,158 @@
package main
import (
"fmt"
"io"
"os"
"strings"
"time"
"github.com/spf13/cobra"
"steward/internal/api"
)
func certCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "cert",
Aliases: []string{"certificate"},
Short: "The web UI's certificate, obtained through Jarl (administrators)",
Long: `Shows the certificate stewardd obtains itself for its web UI (tls.acme in
steward.yaml, with Jarl publishing the DNS-01 challenge; docs/certificates.md).
'check' tests the whole path without asking the CA; 'renew' gets a new
certificate now.`,
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error { return certStatus() },
}
cmd.AddCommand(&cobra.Command{Use: "status", Short: "Show the certificate", Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error { return certStatus() }})
cmd.AddCommand(&cobra.Command{
Use: "check",
Short: "Check that the challenge can be published and seen, without the CA",
Long: `Runs the check stewardd runs before every certificate order: Jarl answers
and knows the token, a zone holds _acme-challenge.DOMAIN, the token may
write it, a test record is written, the zone's name servers and the public
resolvers serve it, and it is removed again. It can take a minute or two.`,
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
c.HTTP.Timeout = 5 * time.Minute
if !flagQuiet && flagOutput == "table" {
fmt.Fprintln(os.Stderr, "checking (this can take a minute or two)…")
}
var ch api.CertCheck
if err := c.Do(ctx(), "POST", "/certificate/check", nil, nil, &ch); err != nil {
return err
}
if err := output(ch, func(w io.Writer) { printCheck(w, &ch) }); err != nil {
return err
}
if !ch.OK {
return fmt.Errorf("the check failed")
}
return nil
},
})
var wait bool
renew := &cobra.Command{
Use: "renew",
Short: "Get a new certificate now (the check runs first)",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
var r api.CertificateResponse
if err := c.Do(ctx(), "POST", "/certificate/renew", nil, nil, &r); err != nil {
return err
}
if !wait {
fmt.Println("renewing in the background; follow it with 'steward cert'")
return nil
}
start := time.Now()
for {
time.Sleep(3 * time.Second)
if err := c.Get(ctx(), "/certificate", nil, &r); err != nil {
return err
}
st := r.Status
if st.State != "working" && st.LastAttempt != nil && !st.LastAttempt.Before(start.Add(-time.Second)) {
break
}
}
if err := certStatus(); err != nil {
return err
}
if r.Status.LastError != "" {
return fmt.Errorf("renewing failed")
}
return nil
},
}
renew.Flags().BoolVarP(&wait, "wait", "w", false, "wait until it is done")
cmd.AddCommand(renew)
return cmd
}
func certStatus() error {
c, err := newClient()
if err != nil {
return err
}
var r api.CertificateResponse
if err := c.Get(ctx(), "/certificate", nil, &r); err != nil {
return err
}
return output(r, func(w io.Writer) {
switch r.Source {
case "file":
fmt.Fprintln(w, "Source:\ttls.cert_file (renewed by you)")
return
case "none":
fmt.Fprintln(w, "Source:\tnone: the web UI is plain HTTP (set tls.acme or tls.cert_file in steward.yaml; docs/certificates.md)")
return
}
st := r.Status
fmt.Fprintf(w, "Domain:\t%s\n", st.Domain)
ca := st.Directory
if st.Staging {
ca += " (staging: browsers don't trust it)"
}
fmt.Fprintf(w, "CA:\t%s\n", ca)
fmt.Fprintf(w, "State:\t%s\n", st.State)
if st.NotAfter != nil {
fmt.Fprintf(w, "Valid:\t%s to %s, from %s\n", st.NotBefore.Local().Format("2006-01-02"), st.NotAfter.Local().Format("2006-01-02 15:04"), st.Issuer)
fmt.Fprintf(w, "Renews:\t%s\n", st.RenewAt.Local().Format("2006-01-02"))
}
if st.LastAttempt != nil {
fmt.Fprintf(w, "Last attempt:\t%s\n", st.LastAttempt.Local().Format("2006-01-02 15:04"))
}
if st.NextAttempt != nil && st.State != "working" {
fmt.Fprintf(w, "Next check:\t%s\n", st.NextAttempt.Local().Format("2006-01-02 15:04"))
}
if st.LastError != "" {
fmt.Fprintf(w, "Error:\t%s\n", st.LastError)
}
if st.Check != nil {
fmt.Fprintf(w, "\nLast check, %s:\n", st.Check.Time.Local().Format("2006-01-02 15:04"))
printCheck(w, st.Check)
}
})
}
func printCheck(w io.Writer, c *api.CertCheck) {
for _, s := range c.Steps {
mark := map[string]string{"ok": "ok ", "fail": "FAIL", "warn": "warn", "skip": "skip"}[s.Status]
fmt.Fprintf(w, " %s\t%s\t%s\n", mark, s.Name, s.Detail)
if s.Status == "fail" && len(s.Queries) > 0 {
for _, q := range s.Queries {
fmt.Fprintf(w, " \t\t%s\n", strings.TrimSpace(q))
}
}
}
}
+1 -1
View File
@@ -175,7 +175,7 @@ Exit codes: 0 ok, 1 error, 3 not authenticated or not allowed, 4 not found,
}
add("dhcp", statusCmd(), subnetCmd(), reservationCmd(), leaseCmd(), activityCmd(), settingsCmd(), optionCmd(), interfaceCmd())
add("changes", historyCmd(), importCmd())
add("admin", whoamiCmd(), userCmd(), tokenCmd(), passwordCmd(), auditCmd(), configCmd())
add("admin", whoamiCmd(), userCmd(), tokenCmd(), passwordCmd(), certCmd(), auditCmd(), configCmd())
return root
}
+16 -1
View File
@@ -23,9 +23,11 @@ import (
"golang.org/x/term"
"steward/internal/auth"
"steward/internal/certs"
"steward/internal/config"
"steward/internal/dhcpd"
"steward/internal/engine"
"steward/internal/jarl"
"steward/internal/model"
"steward/internal/server"
"steward/internal/store"
@@ -153,10 +155,23 @@ func serveCmd() *cobra.Command {
for _, p := range eng.Problems() {
a.log.Warn("configuration problem: "+p.Message, "object", p.Object, "code", p.Code)
}
var acme *certs.Manager
if a.cfg.TLS.ACME.Domain != "" {
tok, err := a.cfg.Jarl.ReadToken()
if err != nil {
return err
}
jc, err := jarl.New(a.cfg.Jarl.Server, tok, a.cfg.Jarl.CACert, a.cfg.Jarl.Insecure)
if err != nil {
return err
}
acme = certs.New(ctx, certs.Config{ACME: a.cfg.TLS.ACME, Version: version.Short()}, jc, a.store, a.log)
go acme.Run(ctx)
}
srv := server.New(server.Config{
Listen: a.cfg.Listen, TLSCertFile: a.cfg.TLS.CertFile, TLSKeyFile: a.cfg.TLS.KeyFile, TrustedProxies: a.cfg.Proxies(),
SessionLifetime: time.Duration(a.cfg.Session.Lifetime), SessionIdle: time.Duration(a.cfg.Session.IdleTimeout),
CookieSecure: a.cfg.Session.CookieSecure, Socket: a.cfg.Socket, PingProblem: pinger.Problem,
CookieSecure: a.cfg.Session.CookieSecure, Socket: a.cfg.Socket, PingProblem: pinger.Problem, ACME: acme,
}, eng, d, host, a.log, web.FS())
go d.Run(ctx)
hup := make(chan os.Signal, 1)
+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 |
+14 -6
View File
@@ -3,21 +3,29 @@ module steward
go 1.27.1
require (
github.com/go-acme/lego/v4 v4.35.2
github.com/miekg/dns v1.1.73
github.com/spf13/cobra v1.10.2
go.yaml.in/yaml/v3 v3.0.5
golang.org/x/crypto v0.57.0
golang.org/x/net v0.58.0
golang.org/x/term v0.46.0
modernc.org/sqlite v1.60.1
)
require (
github.com/cenkalti/backoff/v5 v5.0.3 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/go-jose/go-jose/v4 v4.1.4 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/mattn/go-isatty v0.0.24 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/spf13/cobra v1.10.2 // indirect
github.com/spf13/pflag v1.0.9 // indirect
go.yaml.in/yaml/v3 v3.0.5 // indirect
golang.org/x/crypto v0.57.0 // indirect
golang.org/x/net v0.58.0 // indirect
golang.org/x/sys v0.48.0 // indirect
golang.org/x/term v0.46.0 // indirect
golang.org/x/text v0.42.0 // indirect
modernc.org/libc v1.77.1 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.12.1 // indirect
modernc.org/sqlite v1.60.1 // indirect
)
+44
View File
@@ -1,12 +1,26 @@
github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM=
github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/go-acme/lego/v4 v4.35.2 h1:uVQg+KC/yj9R2g7Q9W5wDqhvQvxV5SMu5eqFVoN5xZU=
github.com/go-acme/lego/v4 v4.35.2/go.mod h1:pX2jN5n8OphMGY1IaMjYm5DAEzguBaKRt8AvJAgJXpc=
github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA=
github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo=
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
github.com/miekg/dns v1.1.73 h1:uhT8nJxmTrPJYClxVxTCX+CVn6qnzSiybRk72Z6DgrE=
github.com/miekg/dns v1.1.73/go.mod h1:RW2Obtfd5NZHvOFe3zYG0W8koWOQtAzyHaLo8vASBuQ=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
@@ -16,23 +30,53 @@ github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M=
golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA=
golang.org/x/mod v0.41.0 h1:qJmnOUb4YB+FsEuM3HcWucdZASCPGhsX6uljO6pog0c=
golang.org/x/mod v0.41.0/go.mod h1:Ek9pY8RKWXwsWvd3rQiHYtMqkjSUV+s1Rj7j4H5Ur6o=
golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To=
golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU=
golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk=
golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
golang.org/x/term v0.46.0 h1:3+OXuTbaKDgwk8jTi3aSLHRlmWqHEUDUtxnbFigO4YE=
golang.org/x/term v0.46.0/go.mod h1:+K02xbkittuwc0Am4abfA3Fc+XRGXkvBXNO88NCXPoc=
golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI=
golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E=
golang.org/x/tools v0.50.0 h1:c2ifzfcuY7L90lZ2aKd8S4K2NpASF08SZx9ZuJkHmSU=
golang.org/x/tools v0.50.0/go.mod h1:7ulVMw3831Mwi5EZD6RomGyffr4VFjuNYXf2BbCEAV0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
modernc.org/cc/v4 v4.29.7 h1:q+NXGJ0bK3b4TXFYQQVr9pYETGnmwFWkrUzJnMya/Tg=
modernc.org/cc/v4 v4.29.7/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
modernc.org/ccgo/v4 v4.36.1 h1:ZNIUZAryN0UgnJwtyxrdEzcFc3yD4Cu4AzjfPXsLsIE=
modernc.org/ccgo/v4 v4.36.1/go.mod h1:rrtGc2QkS239nYb/mQNuBMyjq3/y3ZXWbBjPoV3wqzA=
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/gc/v3 v3.1.5 h1:21ldfPfRYE31Tb7B3mwAK8gy1AxP4+dKjrOQPfqakoc=
modernc.org/gc/v3 v3.1.5/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.77.1 h1:Ct8j47QtiZ1Enj2DtFXQtUqrPCAjdCmPjtCuvrYQ0Hs=
modernc.org/libc v1.77.1/go.mod h1:87/pZ4L6nD1zqW4nItuS12YO7hN1igAah34xjnQo/W0=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.12.1 h1:nFMiWrpStgZczNl6XI9GnIk/rWhYIyHGUaR04pGbp9g=
modernc.org/memory v1.12.1/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.60.1 h1:/blz53O951KWFOso4QQvEs/Fq6cDBKLtMVrYNSeJVKw=
modernc.org/sqlite v1.60.1/go.mod h1:1dIoEagfDE72QytD5scH1lxARtaUgKgHC/NuApA27r0=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
+46
View File
@@ -255,3 +255,49 @@ type ImportResult struct {
Notes []string `json:"notes"`
Skipped []string `json:"skipped"`
}
// CertStep is one step of the preflight check.
type CertStep struct {
Name string `json:"name"`
Status string `json:"status"` // ok, fail, warn, skip
Detail string `json:"detail"`
// Queries are the DNS questions asked, as dig commands.
Queries []string `json:"queries,omitempty"`
}
// CertCheck is the result of a preflight check: can Steward publish its
// challenge through Jarl, and does the internet see it?
type CertCheck struct {
Time time.Time `json:"time"`
Domain string `json:"domain"`
OK bool `json:"ok"`
Steps []CertStep `json:"steps"`
}
// CertStatus describes the certificate, for the API and the dashboard.
type CertStatus struct {
Domain string `json:"domain"`
Directory string `json:"directory"`
Staging bool `json:"staging"`
State string `json:"state"` // none, ok, working, error
NotBefore *time.Time `json:"not_before,omitempty"`
NotAfter *time.Time `json:"not_after,omitempty"`
Issuer string `json:"issuer,omitempty"`
// IssuedBy is the directory the current certificate came from (it may
// differ from Directory until the next renewal).
IssuedBy string `json:"issued_by,omitempty"`
RenewAt *time.Time `json:"renew_at,omitempty"`
LastError string `json:"last_error,omitempty"`
LastAttempt *time.Time `json:"last_attempt,omitempty"`
NextAttempt *time.Time `json:"next_attempt,omitempty"`
Check *CertCheck `json:"check,omitempty"`
}
// CertificateResponse is GET /certificate.
type CertificateResponse struct {
// Configured is false when tls.acme is off.
Configured bool `json:"configured"`
// Source is acme, file or none.
Source string `json:"source"`
Status *CertStatus `json:"status,omitempty"`
}
+379
View File
@@ -0,0 +1,379 @@
package certs
import (
"context"
"errors"
"fmt"
"net"
"sort"
"strings"
"time"
"github.com/miekg/dns"
"steward/internal/api"
"steward/internal/auth"
"steward/internal/jarl"
)
// Step and Check are api.CertStep and api.CertCheck.
type (
Step = api.CertStep
Check = api.CertCheck
)
// note records a query and its answer, replacing an earlier answer to
// the same question (the checks ask again until the record shows up).
func note(s *Step, q string) {
cmd, _, _ := strings.Cut(q, " → ")
for i, old := range s.Queries {
if prev, _, _ := strings.Cut(old, " → "); prev == cmd {
s.Queries[i] = q
return
}
}
s.Queries = append(s.Queries, q)
}
func add(c *Check, name, status, format string, args ...any) *Step {
c.Steps = append(c.Steps, Step{Name: name, Status: status, Detail: fmt.Sprintf(format, args...)})
return &c.Steps[len(c.Steps)-1]
}
// How long the check waits for the record to show up, and the name
// servers' port (tests change them).
var (
propagationWait = 90 * time.Second
pollEvery = 2 * time.Second
nsPort = "53"
)
// Preflight checks, without the CA, that a certificate can be obtained:
// Jarl answers and knows the token, a zone holds the challenge name, the
// token may write it, a test record can be written, the zone's name
// servers and public resolvers serve it, and it can be removed again.
func (m *Manager) Preflight(ctx context.Context) *Check {
c := &Check{Time: time.Now().UTC(), Domain: m.cfg.ACME.Domain}
defer func() {
c.OK = true
for _, s := range c.Steps {
if s.Status == "fail" {
c.OK = false
}
}
m.mu.Lock()
m.lastCheck = c
m.mu.Unlock()
}()
name := jarl.ChallengeName(m.cfg.ACME.Domain)
// A CNAME at the challenge name sends the challenge elsewhere (into a
// zone kept for challenges); the CA and lego follow it, so the check
// does too.
if target := m.cname(ctx, name); target != "" {
name = target
}
shown := strings.TrimSuffix(name, ".")
who, err := m.jarl.Whoami(ctx)
if err != nil {
add(c, "Jarl", "fail", "%s", explainJarl(err))
return c
}
add(c, "Jarl", "ok", "the token is %s's, on Jarl %s", who.User.Username, who.Build.Label)
all, err := m.jarl.Zones(ctx)
if err != nil {
add(c, "Zone", "fail", "listing the zones: %s", explainJarl(err))
return c
}
zones := jarl.ZonesFor(name, all, "")
if len(zones) == 0 {
add(c, "Zone", "fail", "no primary zone the token can see holds %s: add the zone to Jarl, or give the token's user access to it (zones=...)", shown)
return c
}
zone := strings.TrimSuffix(zones[0].Name, ".")
// Every view with the zone gets the record (linked zones through
// their link), so name them all.
var inViews []jarl.Zone
for _, z := range all {
if strings.EqualFold(z.Name, zones[0].Name) {
inViews = append(inViews, z)
}
}
add(c, "Zone", "ok", "%s%s", zone, viewsText(inViews))
value := "steward-check-" + auth.RandomString(8)
if err := m.jarl.SetTXT(ctx, name, value, zones, true, true, "Steward's certificate check"); err != nil {
add(c, "Permission", "fail", "%s", explainWrite(err, zone, shown))
return c
}
add(c, "Permission", "ok", "the token may write TXT records at %s", shown)
if err := m.jarl.SetTXT(ctx, name, value, zones, true, false, "Steward's certificate check"); err != nil {
add(c, "Write", "fail", "writing a test record: %s", explainWrite(err, zone, shown))
return c
}
add(c, "Write", "ok", "wrote TXT %q at %s; Jarl has it in named", value, shown)
defer func() {
clean, cancel := context.WithTimeout(context.WithoutCancel(ctx), time.Minute)
defer cancel()
if err := m.jarl.SetTXT(clean, name, value, zones, false, false, "Steward's certificate check"); err != nil {
add(c, "Clean up", "fail", "removing the test record: %s (remove it in Jarl by hand)", explainJarl(err))
return
}
add(c, "Clean up", "ok", "removed the test record")
}()
authOK := true
if m.cfg.ACME.CheckPropagation {
authOK = m.checkNameServers(ctx, c, zone, name, value)
} else {
add(c, "Name servers", "skip", "tls.acme.check_propagation is off")
}
m.checkResolvers(ctx, c, name, value, authOK && m.cfg.ACME.CheckPropagation)
return c
}
func viewsText(zs []jarl.Zone) string {
var views []string
for _, z := range zs {
if z.View != "" {
views = append(views, z.View)
}
}
switch len(views) {
case 0:
return ""
case 1:
return " in view " + views[0]
}
return " in views " + strings.Join(views, ", ")
}
func explainJarl(err error) string {
var je *jarl.Error
if errors.As(err, &je) && je.Status == 401 {
return "Jarl refused the token (" + je.Message + "): check jarl.token or jarl.token_file"
}
return err.Error()
}
func explainWrite(err error, zone, name string) string {
var je *jarl.Error
if errors.As(err, &je) && je.Status == 403 {
return fmt.Sprintf("the token may not write the challenge record (%s); give it the grant: records:write zones=%s names=%s types=TXT", je.Message, zone, name)
}
return explainJarl(err)
}
// checkNameServers asks each of the zone's public name servers directly
// (as the CA's resolvers will) until all serve the value.
func (m *Manager) checkNameServers(ctx context.Context, c *Check, zone, name, value string) bool {
st := add(c, "Name servers", "fail", "")
ns, q, err := m.lookupNS(ctx, zone)
st.Queries = append(st.Queries, q...)
if err != nil || len(ns) == 0 {
st.Detail = fmt.Sprintf("the public resolvers know no name servers for %s, so the CA can't see it: is the zone delegated to your name servers? (%v)", zone, err)
return false
}
type server struct {
host string
addrs []string
}
var servers []server
for _, h := range ns {
addrs, q := m.lookupAddrs(ctx, h)
st.Queries = append(st.Queries, q...)
if len(addrs) == 0 {
st.Detail = fmt.Sprintf("name server %s has no address the public resolvers know", strings.TrimSuffix(h, "."))
return false
}
servers = append(servers, server{h, addrs})
}
// A server passes when any of its addresses serves the value (this
// machine may not reach its IPv6 address, for one).
deadline := time.Now().Add(propagationWait)
missing := servers
for {
var still []server
for _, s := range missing {
found := false
for _, a := range s.addrs {
ok, q := m.hasTXT(ctx, net.JoinHostPort(a, nsPort), name, value, false)
note(st, q)
if ok {
found = true
break
}
}
if !found {
still = append(still, s)
}
}
missing = still
if len(missing) == 0 {
st.Status, st.Detail = "ok", fmt.Sprintf("all %d name servers of %s serve it", len(servers), zone)
return true
}
if time.Now().After(deadline) || ctx.Err() != nil {
var names []string
for _, s := range missing {
names = append(names, strings.TrimSuffix(s.host, ".")+" ("+strings.Join(s.addrs, ", ")+")")
}
st.Detail = fmt.Sprintf("after %s, %s still don't serve it: a secondary that doesn't get the zone's updates (check NOTIFY and transfers), or an address that doesn't reach your name server", propagationWait, strings.Join(names, ", "))
return false
}
sleep(ctx, pollEvery)
}
}
// checkResolvers asks the public resolvers until each returns the value.
// When the name servers already serve it, a resolver that doesn't is only
// a warning: it may be holding an earlier answer.
func (m *Manager) checkResolvers(ctx context.Context, c *Check, name, value string, soft bool) {
st := add(c, "Public resolvers", "fail", "")
if len(m.cfg.ACME.Resolvers) == 0 {
st.Status, st.Detail = "skip", "no tls.acme.resolvers"
return
}
deadline := time.Now().Add(propagationWait)
missing := append([]string(nil), m.cfg.ACME.Resolvers...)
for {
var still []string
for _, r := range missing {
ok, q := m.hasTXT(ctx, r, name, value, true)
note(st, q)
if !ok {
still = append(still, r)
}
}
missing = still
if len(missing) == 0 {
st.Status, st.Detail = "ok", "every resolver sees it"
return
}
if time.Now().After(deadline) || ctx.Err() != nil {
st.Detail = fmt.Sprintf("after %s, %s don't see it", propagationWait, strings.Join(missing, ", "))
if soft {
st.Status = "warn"
st.Detail += "; the name servers do, so they are probably holding an earlier answer"
}
return
}
sleep(ctx, pollEvery)
}
}
func sleep(ctx context.Context, d time.Duration) {
select {
case <-ctx.Done():
case <-time.After(d):
}
}
// exchange asks server (HOST:PORT) one question.
func exchange(ctx context.Context, server, name string, qtype uint16, recurse bool) (*dns.Msg, string, error) {
if _, _, err := net.SplitHostPort(server); err != nil {
server = net.JoinHostPort(server, "53")
}
m := new(dns.Msg)
m.SetQuestion(dns.Fqdn(name), qtype)
m.RecursionDesired = recurse
host, port, _ := net.SplitHostPort(server)
q := fmt.Sprintf("dig @%s -p %s %s %s", host, port, dns.TypeToString[qtype], strings.TrimSuffix(name, "."))
if !recurse {
q += " +norec"
}
c, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
cl := &dns.Client{Timeout: 5 * time.Second}
r, _, err := cl.ExchangeContext(c, m, server)
if err == nil && r.Truncated {
cl.Net = "tcp"
r, _, err = cl.ExchangeContext(c, m, server)
}
return r, q, err
}
func (m *Manager) hasTXT(ctx context.Context, server, name, value string, recurse bool) (bool, string) {
r, q, err := exchange(ctx, server, name, dns.TypeTXT, recurse)
if err != nil {
return false, q + " → " + err.Error()
}
for _, rr := range r.Answer {
if t, ok := rr.(*dns.TXT); ok && strings.Join(t.Txt, "") == value {
return true, q + " → found"
}
}
return false, q + " → " + dns.RcodeToString[r.Rcode] + ", not there (yet)"
}
// cname returns the target of a CNAME at name ("" when there is none).
func (m *Manager) cname(ctx context.Context, name string) string {
for _, res := range m.cfg.ACME.Resolvers {
r, _, err := exchange(ctx, res, name, dns.TypeCNAME, true)
if err != nil {
continue
}
for _, rr := range r.Answer {
if c, ok := rr.(*dns.CNAME); ok {
return strings.ToLower(c.Target)
}
}
return ""
}
return ""
}
// lookupNS asks the resolvers for a zone's name servers.
func (m *Manager) lookupNS(ctx context.Context, zone string) ([]string, []string, error) {
var queries []string
var last error = errors.New("no resolvers")
for _, res := range m.cfg.ACME.Resolvers {
r, q, err := exchange(ctx, res, zone, dns.TypeNS, true)
queries = append(queries, q)
if err != nil {
last = err
continue
}
var out []string
for _, rr := range r.Answer {
if ns, ok := rr.(*dns.NS); ok {
out = append(out, strings.ToLower(ns.Ns))
}
}
if len(out) > 0 {
sort.Strings(out)
return out, queries, nil
}
last = fmt.Errorf("%s answered %s", res, dns.RcodeToString[r.Rcode])
}
return nil, queries, last
}
// lookupAddrs asks the resolvers for a host's addresses.
func (m *Manager) lookupAddrs(ctx context.Context, host string) ([]string, []string) {
var queries []string
for _, res := range m.cfg.ACME.Resolvers {
var out []string
for _, t := range []uint16{dns.TypeA, dns.TypeAAAA} {
r, q, err := exchange(ctx, res, host, t, true)
queries = append(queries, q)
if err != nil {
continue
}
for _, rr := range r.Answer {
switch a := rr.(type) {
case *dns.A:
out = append(out, a.A.String())
case *dns.AAAA:
out = append(out, a.AAAA.String())
}
}
}
if len(out) > 0 {
return out, queries
}
}
return nil, queries
}
+266
View File
@@ -0,0 +1,266 @@
package certs
import (
"context"
"encoding/json"
"net"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"time"
"github.com/miekg/dns"
"steward/internal/config"
"steward/internal/jarl"
)
// fakeJarl is Jarl's API as far as Steward uses it, and the DNS server
// that serves what it writes (both the "public resolver" and the zone's
// name server).
type fakeJarl struct {
mu sync.Mutex
txt map[string][]string // name -> values
canWrite bool
token string
served bool // the DNS server serves what is written
zones []jarl.Zone
writes int
dryRuns int
nsHasIPv6 bool
}
func (f *fakeJarl) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Authorization") != "Bearer "+f.token {
w.WriteHeader(401)
w.Write([]byte(`{"error":{"code":"unauthenticated","message":"invalid API token: unknown token"}}`))
return
}
switch r.URL.Path {
case "/api/v1/auth/whoami":
w.Write([]byte(`{"user":{"username":"steward"},"build":{"label":"v0.6.1"}}`))
case "/api/v1/zones":
json.NewEncoder(w).Encode(f.zones)
case "/api/v1/apply":
var b struct {
Ops []struct {
Op string `json:"op"`
Data map[string]any `json:"data"`
} `json:"ops"`
DryRun bool `json:"dry_run"`
}
json.NewDecoder(r.Body).Decode(&b)
if !f.canWrite {
w.WriteHeader(403)
w.Write([]byte(`{"error":{"code":"forbidden","message":"permission denied: this needs records:write"}}`))
return
}
f.mu.Lock()
defer f.mu.Unlock()
if b.DryRun {
f.dryRuns++
w.Write([]byte(`{}`))
return
}
f.writes++
for _, op := range b.Ops {
name, v := op.Data["name"].(string), op.Data["data"].(string)
var keep []string
for _, x := range f.txt[name] {
if x != v {
keep = append(keep, x)
}
}
if op.Op == "record.create" {
keep = append(keep, v)
}
f.txt[name] = keep
}
w.Write([]byte(`{}`))
default:
w.WriteHeader(404)
}
}
func (f *fakeJarl) dns(w dns.ResponseWriter, r *dns.Msg) {
m := new(dns.Msg)
m.SetReply(r)
q := r.Question[0]
hdr := dns.RR_Header{Name: q.Name, Class: dns.ClassINET, Ttl: 60}
switch q.Qtype {
case dns.TypeNS:
if q.Name == "adbraun.test." {
hdr.Rrtype = dns.TypeNS
m.Answer = append(m.Answer, &dns.NS{Hdr: hdr, Ns: "ns1.adbraun.test."})
} else {
m.Rcode = dns.RcodeNameError
}
case dns.TypeA:
hdr.Rrtype = dns.TypeA
m.Answer = append(m.Answer, &dns.A{Hdr: hdr, A: net.ParseIP("127.0.0.1")})
case dns.TypeAAAA:
// An IPv6 address nothing answers on (the check must not fail
// because of it).
if f.nsHasIPv6 {
hdr.Rrtype = dns.TypeAAAA
m.Answer = append(m.Answer, &dns.AAAA{Hdr: hdr, AAAA: net.ParseIP("100::1")})
}
case dns.TypeCNAME:
if q.Name == "_acme-challenge.dhcp.other.test." {
hdr.Rrtype = dns.TypeCNAME
m.Answer = append(m.Answer, &dns.CNAME{Hdr: hdr, Target: "dhcp.acme.adbraun.test."})
}
case dns.TypeTXT:
f.mu.Lock()
if f.served {
for _, v := range f.txt[q.Name] {
hdr.Rrtype = dns.TypeTXT
m.Answer = append(m.Answer, &dns.TXT{Hdr: hdr, Txt: []string{v}})
}
}
f.mu.Unlock()
}
w.WriteMsg(m)
}
func setup(t *testing.T, f *fakeJarl, edit func(a *config.ACME)) (*Manager, string) {
t.Helper()
pc, err := net.ListenPacket("udp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
srv := &dns.Server{PacketConn: pc, Handler: dns.HandlerFunc(f.dns)}
go srv.ActivateAndServe()
t.Cleanup(func() { srv.Shutdown() })
hs := httptest.NewServer(f)
t.Cleanup(hs.Close)
addr := pc.LocalAddr().String()
_, port, _ := net.SplitHostPort(addr)
oldPort, oldWait, oldPoll := nsPort, propagationWait, pollEvery
nsPort, propagationWait, pollEvery = port, 2*time.Second, 100*time.Millisecond
t.Cleanup(func() { nsPort, propagationWait, pollEvery = oldPort, oldWait, oldPoll })
a := config.ACME{Domain: "dhcp.adbraun.test", Resolvers: []string{addr}, CheckPropagation: true}
if edit != nil {
edit(&a)
}
jc, _ := jarl.New(hs.URL, "steward_good", "", false)
m := &Manager{cfg: Config{ACME: a}, jarl: jc, now: time.Now}
return m, addr
}
func steps(c *Check) string {
var out []string
for _, s := range c.Steps {
out = append(out, s.Name+"="+s.Status)
}
return strings.Join(out, " ")
}
func newFake() *fakeJarl {
return &fakeJarl{txt: map[string][]string{}, canWrite: true, token: "steward_good", served: true,
zones: []jarl.Zone{{Name: "adbraun.test.", View: "internal", LinkGroup: 7}, {Name: "adbraun.test.", View: "external", LinkGroup: 7}, {Name: "other.test.", View: "internal"}}}
}
func TestPreflightOK(t *testing.T) {
f := newFake()
f.nsHasIPv6 = true
m, _ := setup(t, f, nil)
c := m.Preflight(context.Background())
want := "Jarl=ok Zone=ok Permission=ok Write=ok Name servers=ok Public resolvers=ok Clean up=ok"
if !c.OK || steps(c) != want {
t.Fatalf("got %s\n%+v", steps(c), c.Steps)
}
if !strings.Contains(c.Steps[1].Detail, "views internal, external") {
t.Errorf("zone step: %s", c.Steps[1].Detail)
}
// Linked zones are written once; the test record is gone afterwards.
if f.dryRuns != 1 || f.writes != 2 || len(f.txt["_acme-challenge.dhcp.adbraun.test."]) != 0 {
t.Errorf("dry runs %d writes %d records %v", f.dryRuns, f.writes, f.txt)
}
}
func TestPreflightFailures(t *testing.T) {
cases := []struct {
name string
edit func(f *fakeJarl, a *config.ACME)
want string
text string
}{
{"bad token", func(f *fakeJarl, a *config.ACME) { f.token = "other" }, "Jarl=fail", "check jarl.token"},
{"no zone", func(f *fakeJarl, a *config.ACME) { a.Domain = "dhcp.example.net" }, "Jarl=ok Zone=fail", "no primary zone the token can see"},
{"no permission", func(f *fakeJarl, a *config.ACME) { f.canWrite = false }, "Jarl=ok Zone=ok Permission=fail",
"records:write zones=adbraun.test names=_acme-challenge.dhcp.adbraun.test types=TXT"},
{"not served", func(f *fakeJarl, a *config.ACME) { f.served = false },
"Jarl=ok Zone=ok Permission=ok Write=ok Name servers=fail Public resolvers=fail Clean up=ok", "still don't serve it"},
{"not delegated", func(f *fakeJarl, a *config.ACME) { f.zones[0].Name, f.zones[1].Name = "test.", "test." },
"Jarl=ok Zone=ok Permission=ok Write=ok Name servers=fail Public resolvers=ok Clean up=ok", "know no name servers for test"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
f := newFake()
var am *config.ACME
m, _ := setup(t, f, func(a *config.ACME) { am = a })
tc.edit(f, am)
m.cfg.ACME = *am
c := m.Preflight(context.Background())
if c.OK || steps(c) != tc.want {
t.Fatalf("got %s, want %s\n%+v", steps(c), tc.want, c.Steps)
}
all := ""
for _, s := range c.Steps {
all += s.Detail + "\n"
}
if !strings.Contains(all, tc.text) {
t.Errorf("details lack %q:\n%s", tc.text, all)
}
})
}
}
func TestResolversOnlyWarnWhenNameServersServe(t *testing.T) {
f := newFake()
m, _ := setup(t, f, nil)
// The "public resolver" is another server that never sees the record.
pc, _ := net.ListenPacket("udp", "127.0.0.1:0")
srv := &dns.Server{PacketConn: pc, Handler: dns.HandlerFunc(func(w dns.ResponseWriter, r *dns.Msg) {
mm := new(dns.Msg)
mm.SetReply(r)
if r.Question[0].Qtype == dns.TypeTXT {
mm.Rcode = dns.RcodeNameError
} else {
f.dns(w, r)
return
}
w.WriteMsg(mm)
})}
go srv.ActivateAndServe()
defer srv.Shutdown()
m.cfg.ACME.Resolvers = []string{pc.LocalAddr().String()}
c := m.Preflight(context.Background())
if !c.OK || !strings.Contains(steps(c), "Public resolvers=warn") {
t.Fatalf("got %s %+v", steps(c), c.Steps)
}
}
func TestPreflightFollowsCNAME(t *testing.T) {
f := newFake()
m, _ := setup(t, f, func(a *config.ACME) { a.Domain = "dhcp.other.test" })
c := m.Preflight(context.Background())
if !c.OK || !strings.Contains(c.Steps[2].Detail, "dhcp.acme.adbraun.test") {
t.Fatalf("got %s %+v", steps(c), c.Steps)
}
}
func TestZonesFor(t *testing.T) {
zs := []jarl.Zone{{Name: "lol.", View: "a"}, {Name: "adbraun.lol.", View: "internal", LinkGroup: 3}, {Name: "adbraun.lol.", View: "external", LinkGroup: 3},
{Name: "adbraun.lol.", View: "guest"}, {Name: "notadbraun.lol.", View: "internal"}}
got := jarl.ZonesFor("_acme-challenge.dhcp.adbraun.lol.", zs, "")
if len(got) != 2 || got[0].View != "internal" || got[1].View != "guest" {
t.Errorf("got %+v", got)
}
if n := jarl.ChallengeName("*.Dhcp.adbraun.lol."); n != "_acme-challenge.dhcp.adbraun.lol." {
t.Errorf("challenge name %q", n)
}
}
+484
View File
@@ -0,0 +1,484 @@
// Package certs obtains and renews the web UI's certificate from an ACME CA
// (Let's Encrypt), with Jarl publishing the DNS-01 challenge
// (docs/certificates.md). Before every order a preflight check proves the
// challenge can be published and seen, so a broken setup never spends the
// CA's rate limits.
package certs
import (
"context"
"crypto"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/tls"
"crypto/x509"
"encoding/json"
"encoding/pem"
"errors"
"fmt"
"log/slog"
"net/http"
"os"
"regexp"
"strings"
"sync"
"time"
"github.com/go-acme/lego/v4/certificate"
"github.com/go-acme/lego/v4/challenge/dns01"
"github.com/go-acme/lego/v4/lego"
legolog "github.com/go-acme/lego/v4/log"
"github.com/go-acme/lego/v4/registration"
"steward/internal/api"
"steward/internal/config"
"steward/internal/jarl"
)
// Store keeps the account and certificate (the database's meta table).
type Store interface {
GetMeta(ctx context.Context, key string) (string, error)
SetMeta(ctx context.Context, key, value string) error
}
const (
metaAccount = "acme.account"
metaCert = "acme.certificate"
)
// Config configures the manager.
type Config struct {
ACME config.ACME
Version string
}
// Status is api.CertStatus.
type Status = api.CertStatus
// Manager keeps the web UI's certificate.
type Manager struct {
cfg Config
jarl *jarl.Client
store Store
log *slog.Logger
now func() time.Time
mu sync.Mutex
cert *tls.Certificate
leaf *x509.Certificate
issuedBy string
working bool
lastErr string
lastAttempt time.Time
nextAttempt time.Time
lastCheck *Check
failures int
force bool // renew now, even if not due (Kick)
lego sync.Mutex // one ACME order at a time
kick chan struct{}
}
// New makes a manager and loads the stored certificate.
func New(ctx context.Context, cfg Config, j *jarl.Client, st Store, log *slog.Logger) *Manager {
if log == nil {
log = slog.Default()
}
// lego logs each step of an order; send it to the journal with the rest.
legolog.Logger = slog.NewLogLogger(log.With("component", "acme").Handler(), slog.LevelInfo)
m := &Manager{cfg: cfg, jarl: j, store: st, log: log, now: time.Now, kick: make(chan struct{}, 1)}
m.load(ctx)
return m
}
type stored struct {
Domain string `json:"domain"`
Directory string `json:"directory"`
CertPEM string `json:"cert_pem"`
KeyPEM string `json:"key_pem"`
Obtained time.Time `json:"obtained"`
}
func (m *Manager) load(ctx context.Context) {
raw, err := m.store.GetMeta(ctx, metaCert)
if err != nil || raw == "" {
return
}
var s stored
if json.Unmarshal([]byte(raw), &s) != nil {
return
}
if err := m.use(s); err != nil {
m.log.Warn("the stored certificate can't be used; getting a new one", "err", err)
}
}
// use makes a stored certificate the one served.
func (m *Manager) use(s stored) error {
c, err := tls.X509KeyPair([]byte(s.CertPEM), []byte(s.KeyPEM))
if err != nil {
return err
}
leaf, err := x509.ParseCertificate(c.Certificate[0])
if err != nil {
return err
}
c.Leaf = leaf
m.mu.Lock()
defer m.mu.Unlock()
// A certificate for another name isn't served.
if !strings.EqualFold(s.Domain, m.cfg.ACME.Domain) {
m.cert, m.leaf = nil, nil
return fmt.Errorf("it is for %s, not %s", s.Domain, m.cfg.ACME.Domain)
}
m.cert, m.leaf, m.issuedBy = &c, leaf, s.Directory
return nil
}
// Certificate is the certificate to serve (nil when there is none yet).
func (m *Manager) Certificate() *tls.Certificate {
m.mu.Lock()
defer m.mu.Unlock()
if m.cert == nil || m.now().After(m.leaf.NotAfter) {
return nil
}
return m.cert
}
// Status describes the certificate.
func (m *Manager) Status() Status {
m.mu.Lock()
defer m.mu.Unlock()
st := Status{Domain: m.cfg.ACME.Domain, Directory: m.cfg.ACME.DirectoryURL(), Staging: m.cfg.ACME.Staging(), State: "none",
IssuedBy: m.issuedBy, LastError: m.lastErr, Check: m.lastCheck}
if m.leaf != nil {
nb, na := m.leaf.NotBefore, m.leaf.NotAfter
ra := m.renewAt()
st.NotBefore, st.NotAfter, st.RenewAt, st.Issuer, st.State = &nb, &na, &ra, m.leaf.Issuer.CommonName, "ok"
}
if m.lastErr != "" {
st.State = "error"
}
if m.working {
st.State = "working"
}
if !m.lastAttempt.IsZero() {
t := m.lastAttempt
st.LastAttempt = &t
}
if !m.nextAttempt.IsZero() {
t := m.nextAttempt
st.NextAttempt = &t
}
return st
}
// renewAt is when the certificate is renewed: a third of its lifetime
// before it ends (30 days for Let's Encrypt's 90), m.mu held.
func (m *Manager) renewAt() time.Time {
life := m.leaf.NotAfter.Sub(m.leaf.NotBefore)
return m.leaf.NotAfter.Add(-life / 3).UTC().Truncate(time.Second)
}
// due reports whether a new certificate is needed: none yet, another
// directory configured, or renewal time.
func (m *Manager) due() bool {
m.mu.Lock()
defer m.mu.Unlock()
return m.force || m.leaf == nil || m.issuedBy != m.cfg.ACME.DirectoryURL() || !m.now().Before(m.renewAt())
}
// Run keeps the certificate: it gets one when needed, checks twice a day
// whether it is due, and retries failures with a growing wait.
func (m *Manager) Run(ctx context.Context) {
timer := time.NewTimer(5 * time.Second)
defer timer.Stop()
for {
select {
case <-ctx.Done():
return
case <-m.kick:
case <-timer.C:
}
wait := 12 * time.Hour
if m.due() {
if err := m.Renew(ctx); err != nil {
m.mu.Lock()
m.failures++
// 1h, 3h, 6h, then 12h.
waits := []time.Duration{time.Hour, 3 * time.Hour, 6 * time.Hour, 12 * time.Hour}
wait = waits[min(m.failures, len(waits))-1]
m.mu.Unlock()
m.log.Warn("getting the web UI's certificate failed; trying again later", "domain", m.cfg.ACME.Domain, "err", err, "retry_in", wait)
}
}
m.mu.Lock()
m.nextAttempt = m.now().Add(wait).UTC()
m.mu.Unlock()
if !timer.Stop() {
select {
case <-timer.C:
default:
}
}
timer.Reset(wait)
}
}
// Kick asks Run to renew now, even if the certificate isn't due.
func (m *Manager) Kick() {
m.mu.Lock()
m.force, m.failures = true, 0
m.mu.Unlock()
select {
case m.kick <- struct{}{}:
default:
}
}
// ErrBusy is returned while another order runs.
var ErrBusy = errors.New("a certificate order is already running")
// Renew gets a new certificate now: the preflight check, then the order.
func (m *Manager) Renew(ctx context.Context) error {
if !m.lego.TryLock() {
return ErrBusy
}
defer m.lego.Unlock()
m.mu.Lock()
m.working, m.lastAttempt = true, m.now().UTC()
m.mu.Unlock()
err := m.renew(ctx)
m.mu.Lock()
m.working, m.force = false, false
if err != nil {
m.lastErr = err.Error()
} else {
m.lastErr, m.failures = "", 0
}
m.mu.Unlock()
return err
}
func (m *Manager) renew(ctx context.Context) error {
c := m.Preflight(ctx)
if !c.OK {
for _, s := range c.Steps {
if s.Status == "fail" {
return fmt.Errorf("the check before asking the CA failed at %s: %s", strings.ToLower(s.Name), s.Detail)
}
}
}
m.log.Info("the check passed; asking the CA for a certificate", "domain", m.cfg.ACME.Domain, "directory", m.cfg.ACME.DirectoryURL())
res, err := m.obtain(ctx)
if err != nil {
return err
}
s := stored{Domain: m.cfg.ACME.Domain, Directory: m.cfg.ACME.DirectoryURL(), CertPEM: string(res.Certificate), KeyPEM: string(res.PrivateKey), Obtained: m.now().UTC()}
if err := m.use(s); err != nil {
return fmt.Errorf("the CA's certificate can't be used: %w", err)
}
b, _ := json.Marshal(s)
if err := m.store.SetMeta(ctx, metaCert, string(b)); err != nil {
return err
}
m.mu.Lock()
na := m.leaf.NotAfter
m.mu.Unlock()
m.log.Info("got a new certificate for the web UI", "domain", s.Domain, "until", na.Format(time.RFC3339), "staging", m.cfg.ACME.Staging())
return nil
}
// ---- ACME
type account struct {
Directory string `json:"directory"`
Email string `json:"email,omitempty"`
URI string `json:"uri,omitempty"`
KeyPEM string `json:"key_pem"`
}
type legoUser struct {
email string
reg *registration.Resource
key crypto.PrivateKey
}
func (u *legoUser) GetEmail() string { return u.email }
func (u *legoUser) GetRegistration() *registration.Resource { return u.reg }
func (u *legoUser) GetPrivateKey() crypto.PrivateKey { return u.key }
func (m *Manager) httpClient() (*http.Client, error) {
tr := http.DefaultTransport.(*http.Transport).Clone()
tr.TLSClientConfig = &tls.Config{MinVersion: tls.VersionTLS12}
if f := m.cfg.ACME.CABundle; f != "" {
pemData, err := os.ReadFile(f)
if err != nil {
return nil, fmt.Errorf("tls.acme.ca_bundle: %w", err)
}
pool, err := x509.SystemCertPool()
if err != nil || pool == nil {
pool = x509.NewCertPool()
}
if !pool.AppendCertsFromPEM(pemData) {
return nil, fmt.Errorf("tls.acme.ca_bundle: no certificates in %s", f)
}
tr.TLSClientConfig.RootCAs = pool
}
return &http.Client{Timeout: 2 * time.Minute, Transport: tr}, nil
}
// client returns a lego client for the account, registering it if needed.
func (m *Manager) client(ctx context.Context) (*lego.Client, error) {
dir := m.cfg.ACME.DirectoryURL()
hc, err := m.httpClient()
if err != nil {
return nil, err
}
user := &legoUser{email: m.cfg.ACME.Email}
var a account
if raw, _ := m.store.GetMeta(ctx, metaAccount); raw != "" && json.Unmarshal([]byte(raw), &a) == nil && a.Directory == dir && a.KeyPEM != "" {
b, _ := pem.Decode([]byte(a.KeyPEM))
if b == nil {
return nil, errors.New("the stored ACME account key isn't PEM")
}
if user.key, err = x509.ParsePKCS8PrivateKey(b.Bytes); err != nil {
return nil, fmt.Errorf("the stored ACME account key: %w", err)
}
if a.URI != "" {
user.reg = &registration.Resource{URI: a.URI}
}
} else {
if user.key, err = ecdsa.GenerateKey(elliptic.P256(), rand.Reader); err != nil {
return nil, err
}
}
cfg := lego.NewConfig(user)
cfg.CADirURL, cfg.HTTPClient, cfg.UserAgent = dir, hc, "steward/"+m.cfg.Version
cfg.Certificate.Timeout = 90 * time.Second
c, err := lego.NewClient(cfg)
if err != nil {
return nil, fmt.Errorf("the ACME server %s: %w", dir, err)
}
if user.reg != nil {
return c, nil
}
if !m.cfg.ACME.AgreeTOS {
tos := c.GetToSURL()
if tos == "" {
tos = "on the CA's website"
}
return nil, fmt.Errorf("Steward registers an account with %s only after you accept its terms of service (%s): set tls.acme.agree_tos: true in steward.yaml and restart stewardd", dir, tos)
}
reg, err := c.Registration.Register(registration.RegisterOptions{TermsOfServiceAgreed: true})
if err != nil {
return nil, fmt.Errorf("registering the ACME account: %w", err)
}
user.reg = reg
der, err := x509.MarshalPKCS8PrivateKey(user.key)
if err != nil {
return nil, err
}
b, _ := json.Marshal(account{Directory: dir, Email: user.email, URI: reg.URI,
KeyPEM: string(pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: der}))})
if err := m.store.SetMeta(ctx, metaAccount, string(b)); err != nil {
return nil, err
}
m.log.Info("ACME account registered", "directory", dir, "account", reg.URI)
return c, nil
}
// provider publishes the challenge through Jarl.
type provider struct {
m *Manager
ctx context.Context
}
func (p *provider) set(domain, keyAuth string, set bool) error {
info := dns01.GetChallengeInfo(domain, keyAuth)
ctx := p.ctx
if !set {
ctx = context.WithoutCancel(ctx)
}
all, err := p.m.jarl.Zones(ctx)
if err != nil {
return err
}
zones := jarl.ZonesFor(info.EffectiveFQDN, all, "")
if len(zones) == 0 {
return fmt.Errorf("no primary zone in Jarl holds %s", strings.TrimSuffix(info.EffectiveFQDN, "."))
}
return p.m.jarl.SetTXT(ctx, info.EffectiveFQDN, info.Value, zones, set, false, "Steward's certificate")
}
func (p *provider) Present(domain, token, keyAuth string) error { return p.set(domain, keyAuth, true) }
func (p *provider) CleanUp(domain, token, keyAuth string) error { return p.set(domain, keyAuth, false) }
// Timeout is how long lego waits for the record to reach the name servers.
func (p *provider) Timeout() (timeout, interval time.Duration) {
return 5 * time.Minute, 5 * time.Second
}
func (m *Manager) obtain(ctx context.Context) (*certificate.Resource, error) {
c, err := m.client(ctx)
if err != nil {
return nil, err
}
opts := []dns01.ChallengeOption{dns01.AddDNSTimeout(10 * time.Second)}
if len(m.cfg.ACME.Resolvers) > 0 {
opts = append(opts, dns01.AddRecursiveNameservers(m.cfg.ACME.Resolvers))
}
if !m.cfg.ACME.CheckPropagation {
opts = append(opts, dns01.DisableAuthoritativeNssPropagationRequirement())
}
if err := c.Challenge.SetDNS01Provider(&provider{m: m, ctx: ctx}, opts...); err != nil {
return nil, err
}
res, err := c.Certificate.Obtain(certificate.ObtainRequest{Domains: []string{m.cfg.ACME.Domain}, Bundle: true})
if err != nil {
return nil, cleanLegoError(err)
}
return res, nil
}
// cleanLegoError shortens lego's errors (as Jarl does): "[name] acme:
// error: 403 :: urn:ietf:params:acme:error:unauthorized :: MSG" becomes
// "name: the CA refused (unauthorized): MSG".
func cleanLegoError(err error) error {
msg := strings.TrimSpace(strings.Replace(err.Error(), "error: one or more domains had a problem:", "", 1))
var out []string
for _, line := range strings.Split(msg, "\n") {
line = strings.TrimSpace(line)
if line == "" {
continue
}
domain := ""
for strings.HasPrefix(line, "[") {
end := strings.Index(line, "] ")
if end < 0 {
break
}
if domain == "" {
domain = line[1:end]
}
line = line[end+2:]
}
for _, p := range []string{"acme: error presenting token: ", "acme: error: ", "acme: "} {
line = strings.TrimPrefix(line, p)
}
if mm := problemRe.FindStringSubmatch(line); mm != nil {
line = "the CA refused (" + mm[2] + "): " + mm[3]
}
if domain != "" {
line = domain + ": " + line
}
out = append(out, line)
}
if len(out) == 0 {
return err
}
return errors.New(strings.Join(out, "; "))
}
var problemRe = regexp.MustCompile(`^(\d+) :: urn:ietf:params:acme:error:(\w+) :: (.*)$`)
+147 -2
View File
@@ -6,7 +6,9 @@ package config
import (
"bytes"
"fmt"
"net"
"net/netip"
"net/url"
"os"
"path/filepath"
"strings"
@@ -31,12 +33,86 @@ type Config struct {
Log Log `yaml:"log"`
Session Session `yaml:"session"`
Backup Backup `yaml:"backup"`
// Jarl is the Jarl server Steward uses for DNS (docs/jarl.md).
Jarl Jarl `yaml:"jarl"`
}
// TLS is a certificate for HTTPS.
// TLS is the web UI's certificate: from files, or obtained through ACME
// with Jarl publishing the DNS-01 challenge (docs/certificates.md).
type TLS struct {
CertFile string `yaml:"cert_file"`
KeyFile string `yaml:"key_file"`
ACME ACME `yaml:"acme"`
}
// ACME obtains the web UI's certificate (docs/certificates.md).
type ACME struct {
// Domain is the certificate's name ("" turns ACME off).
Domain string `yaml:"domain"`
Email string `yaml:"email"`
// Directory is letsencrypt, letsencrypt-staging (the default, for a
// first certificate browsers don't trust) or an ACME directory URL.
Directory string `yaml:"directory"`
// AgreeTOS says you accept the CA's terms of service; no account is
// registered without it.
AgreeTOS bool `yaml:"agree_tos"`
// Resolvers are public recursive resolvers, to see the challenge as
// the CA would.
Resolvers []string `yaml:"resolvers"`
// CheckPropagation also asks each of the zone's public name servers
// directly before the CA is told to look.
CheckPropagation bool `yaml:"check_propagation"`
// CABundle is a PEM file of extra CAs to trust for the ACME server
// (a private CA such as step-ca, or Pebble in tests).
CABundle string `yaml:"ca_bundle"`
}
// Directory URLs for the names tls.acme.directory accepts.
const (
LetsEncrypt = "https://acme-v02.api.letsencrypt.org/directory"
LetsEncryptStaging = "https://acme-staging-v02.api.letsencrypt.org/directory"
)
// DirectoryURL is the ACME directory's URL.
func (a ACME) DirectoryURL() string {
switch a.Directory {
case "", "letsencrypt-staging":
return LetsEncryptStaging
case "letsencrypt":
return LetsEncrypt
}
return a.Directory
}
// Staging reports whether the directory is Let's Encrypt's staging one.
func (a ACME) Staging() bool { return a.DirectoryURL() == LetsEncryptStaging }
// Jarl is a Jarl server and an API token for it.
type Jarl struct {
// Server is Jarl's URL ("" when Steward doesn't use Jarl).
Server string `yaml:"server"`
// Token, or TokenFile holding it (better: the file can be readable by
// the steward user alone).
Token string `yaml:"token"`
TokenFile string `yaml:"token_file"`
// CACert is a PEM file with a CA to trust for Jarl's certificate.
CACert string `yaml:"ca_cert"`
Insecure bool `yaml:"insecure"`
}
// Enabled reports whether a Jarl server is configured.
func (j Jarl) Enabled() bool { return j.Server != "" }
// ReadToken returns the token, from the setting or the file.
func (j Jarl) ReadToken() (string, error) {
if j.TokenFile == "" {
return j.Token, nil
}
b, err := os.ReadFile(j.TokenFile)
if err != nil {
return "", fmt.Errorf("jarl.token_file: %w", err)
}
return strings.TrimSpace(string(b)), nil
}
// Log configures logging to stderr (the journal under systemd).
@@ -102,6 +178,7 @@ func Default() *Config {
Log: Log{Level: "info", Format: "text"},
Session: Session{Lifetime: Duration(12 * time.Hour), IdleTimeout: Duration(2 * time.Hour), CookieSecure: "auto"},
Backup: Backup{Dir: "/var/backups/steward", Retention: Duration(30 * 24 * time.Hour), KeepAtLeast: 7},
TLS: TLS{ACME: ACME{Directory: "letsencrypt-staging", Resolvers: []string{"1.1.1.1:53", "9.9.9.9:53", "8.8.8.8:53"}, CheckPropagation: true}},
}
}
@@ -140,6 +217,9 @@ func (c *Config) check() error {
if (c.TLS.CertFile == "") != (c.TLS.KeyFile == "") {
return fmt.Errorf("tls: set both cert_file and key_file")
}
if err := c.checkACME(); err != nil {
return err
}
for _, p := range c.TrustedProxies {
if _, err := ParsePrefix(p); err != nil {
return fmt.Errorf("trusted_proxies: %v", err)
@@ -169,6 +249,44 @@ func (c *Config) check() error {
return nil
}
func (c *Config) checkACME() error {
j := c.Jarl
if j.Server != "" {
if u, err := url.Parse(j.Server); err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" {
return fmt.Errorf("jarl.server %q: give Jarl's URL, e.g. https://dns.example.com:8053", j.Server)
}
if j.Token == "" && j.TokenFile == "" {
return fmt.Errorf("jarl: set token or token_file (a Jarl API token, docs/jarl.md)")
}
if j.Token != "" && j.TokenFile != "" {
return fmt.Errorf("jarl: set token or token_file, not both")
}
}
a := c.TLS.ACME
if a.Domain == "" {
return nil
}
if c.TLS.CertFile != "" {
return fmt.Errorf("tls: cert_file and acme can't both be set; choose one")
}
if !j.Enabled() {
return fmt.Errorf("tls.acme needs Jarl to publish the challenge: set jarl.server and its token (docs/certificates.md)")
}
d := strings.TrimSuffix(strings.ToLower(a.Domain), ".")
if strings.HasPrefix(d, "*.") || strings.Count(d, ".") < 1 || strings.ContainsAny(d, " /:") {
return fmt.Errorf("tls.acme.domain %q: give the full name the web UI is reached by, e.g. dhcp.example.com", a.Domain)
}
if u := a.DirectoryURL(); !strings.HasPrefix(u, "https://") {
return fmt.Errorf("tls.acme.directory %q: use letsencrypt, letsencrypt-staging or an https:// ACME directory URL", a.Directory)
}
for _, r := range a.Resolvers {
if _, _, err := net.SplitHostPort(r); err != nil {
return fmt.Errorf("tls.acme.resolvers: %q must be HOST:PORT, e.g. 1.1.1.1:53", r)
}
}
return nil
}
// ParsePrefix parses an address or a prefix.
func ParsePrefix(s string) (netip.Prefix, error) {
if strings.Contains(s, "/") {
@@ -204,10 +322,37 @@ const Example = `# Steward daemon configuration (/etc/steward/steward.yaml).
listen:
- "127.0.0.1:8067"
# HTTPS from a certificate you renew yourself (reread when it changes).
# HTTPS for the web UI and API: a certificate from files you renew yourself
# (reread when they change)...
# tls:
# cert_file: /etc/steward/tls/fullchain.pem
# key_file: /etc/steward/tls/privkey.pem
#
# ...or one Steward obtains and renews itself from Let's Encrypt, with Jarl
# publishing the DNS-01 challenge (needs jarl: below; docs/certificates.md).
# Start with the staging directory: its certificate isn't trusted by
# browsers, but it proves everything works without Let's Encrypt's rate
# limits. Then set directory: letsencrypt.
tls:
acme:
domain: "" # e.g. dhcp.example.com; "" is off
email: ""
directory: letsencrypt-staging # letsencrypt, or an ACME directory URL
agree_tos: false # you accept the CA's terms of service
# Public resolvers, to see the challenge as the CA does.
resolvers: ["1.1.1.1:53", "9.9.9.9:53", "8.8.8.8:53"]
# Also ask the zone's public name servers directly.
check_propagation: true
ca_bundle: "" # extra CA for a private ACME server
# Jarl, the BIND manager, for DNS records (docs/jarl.md). Give Steward a
# token that may only write its challenge record; keep it in a file only
# the steward user can read.
# jarl:
# server: https://dns.example.com:8053
# token_file: /etc/steward/jarl.token
# ca_cert: "" # a CA to trust for Jarl's certificate
# insecure: false
# Reverse proxies whose X-Forwarded-For and X-Forwarded-Proto are
# believed.
+12 -6
View File
@@ -22,12 +22,18 @@ func TestExampleIsTheDefaults(t *testing.T) {
func TestErrors(t *testing.T) {
cases := map[string]string{
"listen: []": "listen",
"database: relative.db": "database",
"session: {cookie_secure: x}": "cookie_secure",
"tls: {cert_file: /a}": "tls",
"no_such_setting: 1": "no_such_setting",
"backup: {retention: 3x}": "duration",
"listen: []": "listen",
"database: relative.db": "database",
"session: {cookie_secure: x}": "cookie_secure",
"tls: {cert_file: /a}": "tls",
"no_such_setting: 1": "no_such_setting",
"backup: {retention: 3x}": "duration",
"tls: {acme: {domain: dhcp.example.com}}": "needs Jarl",
"jarl: {server: https://dns.example.com}": "token or token_file",
"jarl: {server: dns.example.com, token: x}": "Jarl's URL",
"{tls: {cert_file: /a, key_file: /b, acme: {domain: d.example.com}}, jarl: {server: https://j, token: x}}": "can't both",
"{tls: {acme: {domain: '*.example.com'}}, jarl: {server: https://j, token: x}}": "full name",
"{tls: {acme: {domain: d.example.com, directory: http://x}}, jarl: {server: https://j, token: x}}": "https://",
}
for yml, want := range cases {
path := filepath.Join(t.TempDir(), "s.yaml")
+249
View File
@@ -0,0 +1,249 @@
// Package jarl talks to Jarl, the BIND manager, through its REST API: who
// the token belongs to, which zones it can see, and DNS-01 challenge
// records. It writes challenge records the way 'jarl acme' does: into the
// longest matching primary zone, in every view that has it (once per set
// of linked zones), so the CA and internal resolvers see the same thing.
package jarl
import (
"bytes"
"context"
"crypto/tls"
"crypto/x509"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"os"
"strings"
"time"
"steward/internal/version"
)
// ChallengeTTL is the TTL of challenge records (as in Jarl).
const ChallengeTTL = 60
// Comment marks the challenge records Steward writes.
const Comment = "ACME DNS-01 challenge (Steward)"
// Client is a Jarl server and a token.
type Client struct {
base string
token string
http *http.Client
}
// New makes a client. caCert is a PEM file of a CA to trust ("" for the
// system's).
func New(server, token, caCert string, insecure bool) (*Client, error) {
tc := &tls.Config{MinVersion: tls.VersionTLS12, InsecureSkipVerify: insecure} //nolint:gosec // the user's choice
if caCert != "" {
pem, err := os.ReadFile(caCert)
if err != nil {
return nil, fmt.Errorf("jarl.ca_cert: %w", err)
}
pool, err := x509.SystemCertPool()
if err != nil || pool == nil {
pool = x509.NewCertPool()
}
if !pool.AppendCertsFromPEM(pem) {
return nil, fmt.Errorf("jarl.ca_cert: no certificates in %s", caCert)
}
tc.RootCAs = pool
}
tr := http.DefaultTransport.(*http.Transport).Clone()
tr.TLSClientConfig = tc
return &Client{base: strings.TrimSuffix(server, "/") + "/api/v1", token: token, http: &http.Client{Transport: tr, Timeout: 2 * time.Minute}}, nil
}
// Error is an error answer from Jarl.
type Error struct {
Status int
Code string
Message string
}
func (e *Error) Error() string { return fmt.Sprintf("Jarl answered %d: %s", e.Status, e.Message) }
func (c *Client) do(ctx context.Context, method, path string, body, out any) error {
var rd io.Reader
if body != nil {
b, err := json.Marshal(body)
if err != nil {
return err
}
rd = bytes.NewReader(b)
}
req, err := http.NewRequestWithContext(ctx, method, c.base+path, rd)
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+c.token)
req.Header.Set("Accept", "application/json")
req.Header.Set("User-Agent", "steward/"+version.Short())
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
resp, err := c.http.Do(req)
if err != nil {
return fmt.Errorf("can't reach Jarl at %s: %w", strings.TrimSuffix(c.base, "/api/v1"), err)
}
defer resp.Body.Close()
b, err := io.ReadAll(resp.Body)
if err != nil {
return err
}
if resp.StatusCode >= 400 {
var eb struct {
Error struct {
Code string `json:"code"`
Message string `json:"message"`
} `json:"error"`
}
e := &Error{Status: resp.StatusCode, Message: strings.TrimSpace(string(b))}
if json.Unmarshal(b, &eb) == nil && eb.Error.Message != "" {
e.Code, e.Message = eb.Error.Code, eb.Error.Message
}
return e
}
if out != nil {
return json.Unmarshal(b, out)
}
return nil
}
// Whoami is who the token belongs to.
type Whoami struct {
User struct {
Username string `json:"username"`
} `json:"user"`
Admin bool `json:"admin"`
Method string `json:"method"`
Permissions []string `json:"permissions"`
Build struct {
Label string `json:"label"`
} `json:"build"`
}
// Whoami asks Jarl who the token is.
func (c *Client) Whoami(ctx context.Context) (*Whoami, error) {
var w Whoami
return &w, c.do(ctx, http.MethodGet, "/auth/whoami", nil, &w)
}
// Zone is a primary zone the token can see.
type Zone struct {
Name string `json:"name"` // "adbraun.lol."
View string `json:"view,omitempty"`
LinkGroup int64 `json:"link_group,omitempty"`
}
// Zones lists the primary zones the token can see.
func (c *Client) Zones(ctx context.Context) ([]Zone, error) {
var zs []Zone
return zs, c.do(ctx, http.MethodGet, "/zones?type=primary", nil, &zs)
}
func fqdn(n string) string {
n = strings.ToLower(strings.TrimSpace(n))
if !strings.HasSuffix(n, ".") {
n += "."
}
return n
}
// ChallengeName is the record name for a domain's DNS-01 challenge.
func ChallengeName(domain string) string {
d := strings.TrimPrefix(strings.ToLower(strings.TrimSuffix(domain, ".")), "*.")
if !strings.HasPrefix(d, "_acme-challenge.") {
d = "_acme-challenge." + d
}
return d + "."
}
// ZonesFor picks the zones a record at name goes into: the longest
// matching zone, in every view that has it (or only view), once per set of
// linked zones.
func ZonesFor(name string, zones []Zone, view string) []Zone {
name = fqdn(name)
best := ""
var out []Zone
for _, z := range zones {
if view != "" && !strings.EqualFold(z.View, view) {
continue
}
zn := fqdn(z.Name)
if name != zn && !strings.HasSuffix(name, "."+zn) && zn != "." {
continue
}
switch {
case len(zn) > len(best):
best, out = zn, []Zone{z}
case zn == best:
out = append(out, z)
}
}
var dedup []Zone
links := map[int64]bool{}
for _, z := range out {
if z.LinkGroup != 0 {
if links[z.LinkGroup] {
continue
}
links[z.LinkGroup] = true
}
dedup = append(dedup, z)
}
return dedup
}
type op struct {
Op string `json:"op"`
Data any `json:"data"`
}
type batch struct {
Description string `json:"description"`
Ops []op `json:"ops"`
DryRun bool `json:"dry_run,omitempty"`
}
// SetTXT publishes (set) or removes a TXT value at name in zones, as one
// change in Jarl. Setting first removes the same value, so a retry
// succeeds; removing a value that isn't there succeeds too. With dryRun
// Jarl only checks the change, including the token's permission.
func (c *Client) SetTXT(ctx context.Context, name, value string, zones []Zone, set, dryRun bool, why string) error {
if len(zones) == 0 {
return errors.New("no zone to write into")
}
var ops []op
for _, z := range zones {
base := map[string]any{"zone": z.Name, "name": fqdn(name), "type": "TXT", "data": value}
if z.View != "" {
base["view"] = z.View
}
del := map[string]any{"missing_ok": true}
for k, v := range base {
del[k] = v
}
ops = append(ops, op{Op: "record.delete", Data: del})
if set {
add := map[string]any{"ttl": ChallengeTTL, "comment": Comment}
for k, v := range base {
add[k] = v
}
ops = append(ops, op{Op: "record.create", Data: add})
}
}
verb := "remove"
if set {
verb = "set"
}
desc := "ACME: " + verb + " challenge for " + strings.TrimSuffix(name, ".")
if why != "" {
desc += " (" + why + ")"
}
return c.do(ctx, http.MethodPost, "/apply", batch{Description: desc, Ops: ops, DryRun: dryRun}, nil)
}
+94
View File
@@ -0,0 +1,94 @@
package server
import (
"context"
"fmt"
"net/http"
"time"
"steward/internal/api"
"steward/internal/auth"
"steward/internal/certs"
"steward/internal/validate"
)
func (s *Server) certificate(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
if err := requireAdmin(p); err != nil {
return err
}
out := api.CertificateResponse{Source: "none"}
switch {
case s.cfg.ACME != nil:
st := s.cfg.ACME.Status()
out.Configured, out.Source, out.Status = true, "acme", &st
case s.cfg.TLSCertFile != "":
out.Source = "file"
}
writeJSON(w, http.StatusOK, out)
return nil
}
func (s *Server) acmeManager() (*certs.Manager, error) {
if s.cfg.ACME == nil {
return nil, conflict("stewardd doesn't obtain its certificate: set tls.acme and jarl in steward.yaml (docs/certificates.md)")
}
return s.cfg.ACME, nil
}
// certificateCheck: POST /certificate/check runs the preflight check now
// (it takes up to a few minutes when the record is slow to appear).
func (s *Server) certificateCheck(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
if err := requireAdmin(p); err != nil {
return err
}
m, err := s.acmeManager()
if err != nil {
return err
}
ctx, cancel := context.WithTimeout(r.Context(), 4*time.Minute)
defer cancel()
c := m.Preflight(ctx)
s.eng.Audit(r.Context(), p, "certificate.check", m.Status().Domain, map[bool]string{true: "success", false: "failure"}[c.OK], "")
writeJSON(w, http.StatusOK, c)
return nil
}
// certificateRenew: POST /certificate/renew gets a new certificate now, in
// the background (GET /certificate follows it).
func (s *Server) certificateRenew(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
if err := requireAdmin(p); err != nil {
return err
}
m, err := s.acmeManager()
if err != nil {
return err
}
m.Kick()
s.eng.Audit(r.Context(), p, "certificate.renew", m.Status().Domain, "success", "")
st := m.Status()
writeJSON(w, http.StatusAccepted, api.CertificateResponse{Configured: true, Source: "acme", Status: &st})
return nil
}
// certificateProblems are the certificate's problems for /status.
func (s *Server) certificateProblems() []validate.Problem {
if s.cfg.ACME == nil {
return nil
}
st := s.cfg.ACME.Status()
obj := "certificate " + st.Domain
var out []validate.Problem
if st.LastError != "" {
out = append(out, validate.Problem{Severity: validate.Warning, Code: "certificate_failed", Object: obj,
Message: "getting the web UI's certificate failed: " + st.LastError})
}
if st.NotAfter != nil && time.Until(*st.NotAfter) < 14*24*time.Hour {
out = append(out, validate.Problem{Severity: validate.Warning, Code: "certificate_expiring", Object: obj,
Message: fmt.Sprintf("the certificate ends %s and hasn't been renewed", st.NotAfter.Local().Format("2006-01-02 15:04"))})
}
if st.State == "ok" && st.Staging {
out = append(out, validate.Problem{Severity: validate.Warning, Code: "certificate_staging", Object: obj,
Message: "the certificate is from Let's Encrypt's staging service, which browsers don't trust: once it works, set tls.acme.directory: letsencrypt and restart stewardd"})
}
return out
}
+3
View File
@@ -172,6 +172,9 @@ func (s *Server) status(w http.ResponseWriter, r *http.Request, p *auth.Principa
leases := s.dhcp.Leases()
out := api.Status{Build: version.Info(), Started: s.started, Interfaces: []api.InterfaceStatus{}, Subnets: []api.Subnet{},
Problems: s.eng.Problems(), Leases: map[string]int{}, Messages: s.dhcp.Counts(), Database: s.store.Path()}
if p.IsAdmin() {
out.Problems = append(out.Problems, s.certificateProblems()...)
}
if out.Problems == nil {
out.Problems = []validate.Problem{}
}
+3
View File
@@ -63,6 +63,9 @@ func (s *Server) routes() {
// Status and audit
m.HandleFunc("GET "+p+"/status", s.authed(s.status))
m.HandleFunc("GET "+p+"/audit", s.authed(s.listAudit))
m.HandleFunc("GET "+p+"/certificate", s.authed(s.certificate))
m.HandleFunc("POST "+p+"/certificate/check", s.authed(s.certificateCheck))
m.HandleFunc("POST "+p+"/certificate/renew", s.authed(s.certificateRenew))
m.HandleFunc(p+"/", func(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusNotFound, "not_found", "no such API endpoint: "+r.Method+" "+r.URL.Path)
})
+25 -7
View File
@@ -19,6 +19,7 @@ import (
"time"
"steward/internal/auth"
"steward/internal/certs"
"steward/internal/dhcpd"
"steward/internal/engine"
"steward/internal/store"
@@ -40,6 +41,9 @@ type Config struct {
Socket string
// PingProblem says why the ping check can't run (nil: always fine).
PingProblem func() string
// ACME keeps the certificate stewardd obtains itself (tls.acme; nil
// when off).
ACME *certs.Manager
}
// Interfaces lists the host's interfaces.
@@ -106,6 +110,9 @@ func (s *Server) Serve(ctx context.Context, ready func()) error {
if len(s.cfg.Listen) == 0 {
return errors.New("no listen addresses configured")
}
// With a certificate (tls.cert_file, or one stewardd obtains, which
// may turn up later), each listener serves HTTPS and plain HTTP side
// by side (sniff.go); plain HTTP is redirected once there is one.
var tlsCfg *tls.Config
if s.cfg.TLSCertFile != "" {
cr, err := newCertReloader(s.cfg.TLSCertFile, s.cfg.TLSKeyFile, s.log)
@@ -113,7 +120,9 @@ func (s *Server) Serve(ctx context.Context, ready func()) error {
return err
}
s.certs = cr
tlsCfg = &tls.Config{MinVersion: tls.VersionTLS12, GetCertificate: cr.get, NextProtos: []string{"h2", "http/1.1"}}
}
if s.certs != nil || s.cfg.ACME != nil {
tlsCfg = &tls.Config{MinVersion: tls.VersionTLS12, GetCertificate: s.webCertificate, NextProtos: []string{"h2", "http/1.1"}}
}
errc := make(chan error, len(s.cfg.Listen)+1)
var listeners []net.Listener
@@ -131,8 +140,12 @@ func (s *Server) Serve(ctx context.Context, ready func()) error {
listeners = append(listeners, ln)
}
for i, ln := range listeners {
handler := s.Handler()
if tlsCfg != nil {
handler = s.requireHTTPS(handler)
}
srv := &http.Server{
Handler: s.Handler(),
Handler: handler,
ReadHeaderTimeout: 10 * time.Second,
ReadTimeout: 2 * time.Minute,
WriteTimeout: 5 * time.Minute,
@@ -145,11 +158,16 @@ func (s *Server) Serve(ctx context.Context, ready func()) error {
s.mu.Unlock()
scheme := "http"
if tlsCfg != nil {
scheme = "https"
ln = tls.NewListener(ln, tlsCfg)
} else if host, _, err := net.SplitHostPort(s.cfg.Listen[i]); err == nil {
if ip, err := netip.ParseAddr(host); host == "" || err == nil && !ip.IsLoopback() {
s.log.Warn("serving plain HTTP on a non-loopback address: passwords and API tokens travel unencrypted; configure tls or a TLS reverse proxy", "addr", s.cfg.Listen[i])
if s.httpsAvailable() {
scheme = "https"
}
ln = newSniffListener(ln, tlsCfg)
}
if !s.httpsAvailable() && s.cfg.ACME == nil {
if host, _, err := net.SplitHostPort(s.cfg.Listen[i]); err == nil {
if ip, err := netip.ParseAddr(host); host == "" || err == nil && !ip.IsLoopback() {
s.log.Warn("serving plain HTTP on a non-loopback address: passwords and API tokens travel unencrypted; configure tls or a TLS reverse proxy", "addr", s.cfg.Listen[i])
}
}
}
s.log.Info("listening", "addr", s.cfg.Listen[i], "url", scheme+"://"+ln.Addr().String())
+147
View File
@@ -0,0 +1,147 @@
package server
import (
"bufio"
"crypto/tls"
"errors"
"net"
"net/http"
"net/netip"
"strings"
"sync"
"time"
)
// sniffListener serves TLS and plain HTTP on the same port: it looks at a
// connection's first byte and hands the server a *tls.Conn when it is a
// TLS handshake (0x16), the plain connection otherwise. That way stewardd
// can start serving HTTPS when a certificate turns up (one it obtains,
// docs/certificates.md) without a restart, and a plain-HTTP bookmark gets
// a redirect instead of an error.
type sniffListener struct {
net.Listener
cfg *tls.Config
ch chan net.Conn
errc chan error
done chan struct{}
once sync.Once
}
func newSniffListener(ln net.Listener, cfg *tls.Config) *sniffListener {
l := &sniffListener{Listener: ln, cfg: cfg, ch: make(chan net.Conn), errc: make(chan error, 1), done: make(chan struct{})}
go l.loop()
return l
}
func (l *sniffListener) loop() {
for {
c, err := l.Listener.Accept()
if err != nil {
var ne net.Error
if errors.As(err, &ne) && ne.Timeout() {
time.Sleep(50 * time.Millisecond)
continue
}
select {
case l.errc <- err:
default:
}
return
}
go l.classify(c)
}
}
func (l *sniffListener) classify(c net.Conn) {
c.SetReadDeadline(time.Now().Add(10 * time.Second))
br := bufio.NewReader(c)
b, err := br.Peek(1)
c.SetReadDeadline(time.Time{})
if err != nil {
c.Close()
return
}
var out net.Conn = &peekedConn{Conn: c, r: br}
if b[0] == 0x16 {
out = tls.Server(out, l.cfg)
}
select {
case l.ch <- out:
case <-l.done:
c.Close()
}
}
func (l *sniffListener) Accept() (net.Conn, error) {
select {
case c := <-l.ch:
return c, nil
case err := <-l.errc:
return nil, err
case <-l.done:
return nil, net.ErrClosed
}
}
func (l *sniffListener) Close() error {
l.once.Do(func() { close(l.done) })
return l.Listener.Close()
}
// peekedConn reads through the buffer that holds the peeked byte.
type peekedConn struct {
net.Conn
r *bufio.Reader
}
func (c *peekedConn) Read(p []byte) (int, error) { return c.r.Read(p) }
// webCertificate is the certificate stewardd serves: tls.cert_file's, or
// the one it obtains itself (tls.acme).
func (s *Server) webCertificate(hello *tls.ClientHelloInfo) (*tls.Certificate, error) {
if s.certs != nil {
return s.certs.get(hello)
}
if s.cfg.ACME != nil {
if c := s.cfg.ACME.Certificate(); c != nil {
return c, nil
}
}
return nil, errNoCertificate
}
var errNoCertificate = errors.New("no certificate for HTTPS yet")
// httpsAvailable reports whether stewardd has a certificate to serve.
func (s *Server) httpsAvailable() bool {
return s.certs != nil || s.cfg.ACME != nil && s.cfg.ACME.Certificate() != nil
}
// requireHTTPS sends plain-HTTP requests to HTTPS once there is a
// certificate: browsers (GET and HEAD) get a redirect, anything else an
// error, so tokens and passwords aren't sent in the clear. Requests from
// loopback (tools on this server) and from trusted proxies (which
// terminate TLS themselves) are served as they are.
func (s *Server) requireHTTPS(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.TLS != nil || !s.httpsAvailable() {
next.ServeHTTP(w, r)
return
}
host, _, _ := net.SplitHostPort(r.RemoteAddr)
if a, err := netip.ParseAddr(host); err == nil && (a.Unmap().IsLoopback() || s.trusted(a.Unmap())) {
next.ServeHTTP(w, r)
return
}
target := "https://" + r.Host + r.URL.RequestURI()
if r.Method == http.MethodGet || r.Method == http.MethodHead {
http.Redirect(w, r, target, http.StatusPermanentRedirect)
return
}
msg := "stewardd serves HTTPS on this port; use " + strings.TrimSuffix(target, r.URL.RequestURI())
if r.Header.Get("Authorization") != "" {
msg += ". This request's token crossed the network unencrypted; if that network isn't trusted, rotate the token"
}
writeError(w, http.StatusBadRequest, "https_required", msg)
})
}
+58
View File
@@ -0,0 +1,58 @@
package server
import (
"context"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"io"
"math/big"
"net"
"net/http"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
// TestHTTPSAndPlainOnOnePort serves a certificate file: the same port
// answers HTTPS, and plain HTTP from loopback.
func TestHTTPSAndPlainOnOnePort(t *testing.T) {
dir := t.TempDir()
key, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
tmpl := &x509.Certificate{SerialNumber: big.NewInt(1), Subject: pkix.Name{CommonName: "dhcp.test"}, DNSNames: []string{"dhcp.test"},
NotBefore: time.Now().Add(-time.Hour), NotAfter: time.Now().Add(time.Hour)}
der, _ := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key)
kd, _ := x509.MarshalPKCS8PrivateKey(key)
os.WriteFile(filepath.Join(dir, "c.pem"), pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}), 0o600)
os.WriteFile(filepath.Join(dir, "k.pem"), pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: kd}), 0o600)
ln, _ := net.Listen("tcp", "127.0.0.1:0")
addr := ln.Addr().String()
ln.Close()
e := newEnv(t)
e.srv.cfg.Listen, e.srv.cfg.TLSCertFile, e.srv.cfg.TLSKeyFile = []string{addr}, filepath.Join(dir, "c.pem"), filepath.Join(dir, "k.pem")
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
ready := make(chan struct{})
go e.srv.Serve(ctx, func() { close(ready) })
<-ready
pool := x509.NewCertPool()
pool.AddCert(func() *x509.Certificate { c, _ := x509.ParseCertificate(der); return c }())
hc := &http.Client{Timeout: 5 * time.Second, Transport: &http.Transport{TLSClientConfig: &tls.Config{RootCAs: pool, ServerName: "dhcp.test"}}}
for _, url := range []string{"https://" + addr + "/healthz", "http://" + addr + "/healthz"} {
resp, err := hc.Get(url)
if err != nil {
t.Fatalf("%s: %v", url, err)
}
b, _ := io.ReadAll(resp.Body)
resp.Body.Close()
if resp.StatusCode != 200 || !strings.Contains(string(b), `"ok":true`) {
t.Errorf("%s: %d %s", url, resp.StatusCode, b)
}
}
}
+4 -1
View File
@@ -13,6 +13,7 @@ import * as history from "./pages/history.js";
import * as audit from "./pages/audit.js";
import * as users from "./pages/users.js";
import * as account from "./pages/account.js";
import * as certificate from "./pages/certificate.js";
const routes = [
{ pattern: /^$/, page: dashboard, nav: "dashboard" },
@@ -27,6 +28,7 @@ const routes = [
{ pattern: /^audit$/, page: audit, nav: "audit" },
{ pattern: /^users$/, page: users, nav: "users" },
{ pattern: /^account$/, page: account, nav: "account" },
{ pattern: /^certificate$/, page: certificate, nav: "certificate" },
];
// The top bar. ["key", "#/href", "Label", permission?]
@@ -39,6 +41,7 @@ const navItems = [
["history", "#/history", "History", "config"],
{ menu: "Server", items: [
["settings", "#/settings", "Settings and options", "config"],
["certificate", "#/certificate", "Certificate", "admin"],
["audit", "#/audit", "Audit log", "audit"],
] },
];
@@ -116,7 +119,7 @@ function footer(b) {
}
function renderNav() {
const allowed = (n) => !n[3] || can(n[3], false);
const allowed = (n) => !n[3] || (n[3] === "admin" ? state.me.admin : can(n[3], false));
const link = ([key, href, label]) => h("a", { href, class: current.nav === key ? "active" : "" }, label);
mount(navEl, navItems.map((n) => {
if (!n.menu) return allowed(n) ? link(n) : null;
@@ -0,0 +1,86 @@
// The web UI's certificate: obtained through Jarl (tls.acme), its state,
// the last check, and buttons to test and renew.
import { h, mount, table, time, fmtTime, badge, errorBox, toast } from "../ui.js";
import { get, api } from "../api.js";
import { onLeave, rerender } from "../app.js";
const stateBadge = {
ok: ["valid", "ok"], working: ["working…", "accent"], error: ["failing", "err"], none: ["none yet", ""],
};
export async function render(el) {
const r = await get("/certificate");
if (r.source !== "acme") {
mount(el, h("div", { class: "page-head" }, h("div", { class: "grow" }, h("h1", null, "Certificate"))),
h("div", { class: "panel" },
r.source === "file"
? h("p", null, "The web UI uses the certificate in tls.cert_file, which you renew yourself.")
: h("p", null, "The web UI is plain HTTP. Steward can get its own certificate from Let's Encrypt, with Jarl publishing the challenge: set tls.acme and jarl in steward.yaml (see docs/certificates.md)."),
));
return;
}
const st = r.status;
const out = h("div");
const checkBox = h("div");
const busy = st.state === "working";
const testBtn = h("button", { disabled: busy, onclick: async () => {
testBtn.disabled = true;
mount(checkBox, h("div", { class: "msg info" }, h("span", { class: "spinner" }), "Checking: writing a test record through Jarl and waiting until it is seen (up to a few minutes)…"));
try {
const c = await api("POST", "/certificate/check");
mount(checkBox, checkView(c));
} catch (e) { mount(checkBox, errorBox(e)); }
testBtn.disabled = false;
} }, "Test");
const renewBtn = h("button", { class: "primary", disabled: busy, onclick: async () => {
try {
await api("POST", "/certificate/renew");
toast("Getting a new certificate…");
setTimeout(rerender, 1500);
} catch (e) { toast(e.message, "err", 8000); }
} }, st.not_after ? "Renew now" : "Get the certificate now");
const [label, kind] = stateBadge[st.state] || [st.state, ""];
mount(out,
h("div", { class: "panel" },
h("dl", { class: "kv" },
h("dt", null, "Name"), h("dd", null, h("strong", null, st.domain), " ", badge(label, kind)),
h("dt", null, "From"), h("dd", null, st.staging ? ["Let's Encrypt staging ", badge("not trusted by browsers", "warn")] : st.directory),
st.not_after ? [h("dt", null, "Valid until"), h("dd", null, fmtTime(st.not_after), " (", days(st.not_after), "), issued by ", st.issuer)] : null,
st.renew_at ? [h("dt", null, "Renews"), h("dd", null, fmtTime(st.renew_at))] : null,
st.last_attempt ? [h("dt", null, "Last attempt"), h("dd", null, time(st.last_attempt))] : null,
st.next_attempt && !busy ? [h("dt", null, "Next check"), h("dd", null, time(st.next_attempt))] : null),
st.last_error ? h("div", { class: "msg err" }, st.last_error) : null,
st.staging && st.state === "ok" ? h("div", { class: "msg warn" }, "This certificate is from Let's Encrypt's staging service, to prove everything works. Browsers warn about it. Set tls.acme.directory: letsencrypt in steward.yaml and restart stewardd for a trusted one.") : null,
h("div", { class: "actions mt" }, testBtn, renewBtn,
h("span", { class: "muted small" }, "Test runs the check without asking Let's Encrypt; a certificate order always runs it first."))),
checkBox);
if (st.check) mount(checkBox, checkView(st.check));
mount(el, h("div", { class: "page-head" }, h("div", { class: "grow" }, h("h1", null, "Certificate"),
h("div", { class: "muted" }, "The web UI's certificate, obtained from the CA with Jarl publishing the DNS-01 challenge."))), out);
if (busy) {
const t = setTimeout(rerender, 3000);
onLeave(() => clearTimeout(t));
}
}
// days says how long until a time: "in 89 days", "in 5 hours".
function days(iso) {
const ms = new Date(iso) - Date.now();
if (ms <= 0) return "ended";
const d = Math.floor(ms / 86400000);
return d >= 2 ? "in " + d + " days" : "in " + Math.max(1, Math.round(ms / 3600000)) + " hours";
}
function checkView(c) {
const mark = { ok: ["ok", "ok"], fail: ["failed", "err"], warn: ["warning", "warn"], skip: ["skipped", ""] };
return h("div", { class: "panel" },
h("h2", null, "Check, ", time(c.time), " ", c.ok ? badge("passed", "ok") : badge("failed", "err")),
table([
{ label: "Step", render: (s) => h("strong", null, s.name) },
{ label: "", render: (s) => badge(...(mark[s.status] || [s.status, ""])) },
{ label: "What happened", render: (s) => [s.detail,
(s.queries || []).length ? h("details", { class: "small", open: s.status === "fail" },
h("summary", null, "DNS questions (" + s.queries.length + ")"),
h("pre", { class: "wrap" }, s.queries.join("\n"))) : null] },
], c.steps));
}
+1
View File
@@ -355,3 +355,4 @@ td.mono, span.mono { font-family: var(--mono); font-size: 13px; }
input:disabled, select:disabled, textarea:disabled { background: var(--info-soft); color: var(--muted); cursor: not-allowed; opacity: 1; }
.panel-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 8px; }
.panel-head h2 { margin: 0; }
pre.wrap { white-space: pre-wrap; word-break: break-all; font-size: 12px; }