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>
6.7 KiB
Developing Steward
Layout
cmd/stewardd/ the daemon: serve, init, user, check, backup, restore, config
cmd/steward/ the CLI
internal/
dhcp4/ DHCPv4 messages (RFC 2131/2132/3396) and the option catalog; no I/O
model/ the configuration (State: settings, subnets, reservations), leases, users, history
validate/ whole-configuration checks with stable codes (docs/validation.md)
store/ SQLite (modernc.org/sqlite, no cgo): configuration, leases, history, users, tokens, sessions, audit
engine/ configuration changes: edit a copy, diff, validate, save with history, undo
dhcpd/ the DHCP server: sockets per interface, the protocol, address choice, leases, ping check, activity
server/ the HTTP API and the web UI, authentication, the local socket
api/, client/ JSON types and the Go client shared by server and CLI
kea/ the Kea configuration and lease file importer
auth/ passwords (argon2id), tokens, sessions, grants and roles
config/ stewardd's YAML configuration
version/ what a build reports
web/static/ the web UI: plain ES modules, no build step, embedded with go:embed
deploy/ systemd units, sysusers.d and tmpfiles.d files, Debian package scripts
scripts/e2e.sh end-to-end test with real DHCP clients in Docker
scripts/mkdeb.sh the Debian packages (make deb)
scripts/logo.py the web UI's logo from artwork/logo.svg
scripts/optionsdoc.py docs/options.md from the option catalog
scripts/banner.py the README's banner from the web UI's logo and wordmark
artwork/logo.svg the logo's master; artwork/banner.svg the README's banner
design.md describes how the pieces fit.
Building and testing
make # bin/stewardd, bin/steward, bash completion
make test # go vet, gofmt check, go test ./...
make e2e # scripts/e2e.sh: needs Docker
make deb # dist/steward_<version>_<arch>.deb and steward-cli_...
Unit tests cover the protocol without sockets. dhcpd.Server.Handle
takes a parsed message and an interface name and returns the reply, with
fake interfaces, a fake pinger and a fake clock
(internal/dhcpd/handle_test.go). The API tests drive real DHCP exchanges
through Handle too.
The e2e test runs the real thing:
- stewardd in a
debian:trixiecontainer on two Docker bridge networks (eth0 and eth1, like two VLANs); - busybox
udhcpcanddhcpcdclients in Alpine containers.
It checks:
- addresses and options per interface, and reservations;
- the ping check, against a container that squats on a pool address;
- init-reboot, renewal and release with dhcpcd;
- a client moved to another VLAN: NAK, then that VLAN's address;
- a full pool and a disabled subnet;
- history and undo, and leases across a restart;
- the API with tokens, and backups.
KEEP=1 leaves the containers running; DOCKER_HOST points at another
Docker daemon. Root isn't needed on the host.
stewardd can't serve port 67 unprivileged, so a stewardd run by hand
outside a container shows permission denied for its interfaces. The
API and web UI still work, which is enough for UI work. The systemd
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:
-
Run Jarl's e2e and keep its jarld and named:
KEEP=1 PEBBLE=0 JARL_BIND_ROOT=... WORK=DIR scripts/e2e.shin../jarl. jarld is then on127.0.0.1:28053and named on127.0.0.1:25353, with the zoneadbraun.testin two views. -
Make the restricted user and token there, as docs/jarl.md says, with
DIR/jarl --config DIR/cli.yaml. -
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.pemout. -
Point a scratch stewardd at both:
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_propagationhas 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
same helpers as Jarl's:
- All DOM goes through
h()inui.js, neverinnerHTML. - The Content-Security-Policy allows only the app's own scripts and
styles, so no inline styles. Use a class in
style.css, or setel.style.prop. - The files are embedded, so rebuild stewardd after changing them.
The logo's master is artwork/logo.svg; scripts/logo.py writes
assets/logo.svg (the favicon) and assets/logo.js (drawn inline by
brand.js), and then runs scripts/banner.py. The STEWARD wordmark is
drawn by wordmark() in brand.js, in the letters of Jarl's wordmark.
artwork/banner.svg, the README's banner, is the login page's logo and
wordmark, generated by scripts/banner.py. It draws the rule with the same
numbers as brand.js, so change both together.
Conventions
- Every user-visible change goes into
CHANGELOG.mdunder "Unreleased", with the docs updated in the same change. - Errors say what is wrong and how to fix it, naming the object.
- New validation rules get a stable code, a test in
internal/validate/validate_test.goand a row in validation.md. - Configuration changes go through
engine.Apply, never straight to the store, so they are validated, recorded and undoable. - Schema changes append a migration to
internal/store/store.go; never edit a shipped one. gofmtandgo vetclean (make test).
Releasing
As for Jarl:
- Move "Unreleased" in
CHANGELOG.mdunder## [<version>] - <date>. A release candidate may leave it there. - Put the version in
VERSION, and update the README's status line. - Run
make test && make e2e. - Commit as "Release X", tag it
git tag -a vX -m "Steward X", and push.
The tag starts .gitea/workflows/release.yml:
- it checks the tag against
VERSIONand the CHANGELOG; - it runs
make test; - it builds the tarballs, the CLI for each OS and the
.debs (amd64, arm64); scripts/gitea-release.shpublishes the release, and uploads the.debs to the registry:stable, ortestingfor a version with a hyphen.
It needs the repository secret RELEASE_TOKEN (write:repository,
write:package).