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>
109 lines
5.0 KiB
Markdown
109 lines
5.0 KiB
Markdown
# Working on Steward
|
|
|
|
Notes for coding agents (and people) changing this repository. Read this
|
|
first, then [docs/design.md](docs/design.md) (how it fits together and
|
|
what 0.1 leaves out) and [docs/development.md](docs/development.md)
|
|
(layout, tests, releasing).
|
|
|
|
Steward is a DHCPv4 server: `stewardd` answers DHCP and serves a REST API
|
|
and a web UI; `steward` is the CLI; SQLite holds everything. It is the
|
|
companion of Jarl (the BIND manager, `../jarl`) and copies its ways:
|
|
the same auth, socket and web UI helpers, and the same release process.
|
|
When in doubt, do it the way Jarl does. Go module `steward`,
|
|
`CGO_ENABLED=0`, Linux only for stewardd.
|
|
|
|
## Ground rules
|
|
|
|
* **Configuration changes go through `engine.Apply`** (or `Revert`,
|
|
`Replace`). It edits a copy of the state, diffs it, validates the whole
|
|
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.
|
|
* **Subnets are per interface.** A request is only answered from the
|
|
subnets of the interface it came in on. Keep that true in every code
|
|
path.
|
|
* **Every user-visible change updates `CHANGELOG.md`** under
|
|
`## [Unreleased]`, and the matching `docs/` page in the same change.
|
|
New validation codes get a row in `docs/validation.md`; new options go
|
|
in `internal/dhcp4/catalog.go`; then run `python3 scripts/optionsdoc.py`
|
|
to regenerate `docs/options.md`.
|
|
* **Errors** say what is wrong and how to fix it, naming the object.
|
|
* **Schema changes** append a migration to `internal/store/store.go`;
|
|
never edit a shipped one.
|
|
* `make test` runs `go vet` and a `gofmt -l` check. `goimports` isn't
|
|
installed, so fix imports by hand.
|
|
|
|
## Testing
|
|
|
|
```sh
|
|
make test # unit tests: the protocol through dhcpd.Server.Handle with fakes, API via httptest
|
|
make e2e # real udhcpc and dhcpcd clients against stewardd in Docker (needs Docker)
|
|
```
|
|
|
|
* **Docker** on this machine needs `DOCKER_HOST=unix:///var/run/docker.sock`.
|
|
The e2e test prints `RESULT: N passed, 0 failed`; run it after any change
|
|
to `dhcpd`, `engine`, `server` or the CLI.
|
|
* **Port 67 needs `CAP_NET_BIND_SERVICE`.** A stewardd run by hand shows
|
|
its interfaces as "not serving: permission denied", but the API and web
|
|
UI work, which is fine for UI work. Use a scratch config with
|
|
`listen: ["127.0.0.1:28067"]` and `socket: /run/user/$UID/...`; socket
|
|
paths are limited to 107 bytes.
|
|
* **The systemd sandbox is only tested by a package test**: run `make
|
|
deb`, install it in a `debian:trixie` container running systemd
|
|
(`--privileged --cgroupns=host /sbin/init`) on a Docker network, and run
|
|
a client container on that network.
|
|
* **The web UI** has no JS tests. Bundle `assets/app.js` with esbuild to
|
|
catch syntax and import errors, and look at it in headless Chrome.
|
|
|
|
## Web UI rules
|
|
|
|
As in Jarl:
|
|
|
|
* Build DOM only with `h()` from `ui.js`, never `innerHTML`.
|
|
* The CSP is `self` only: no inline styles or scripts. Use a class, or set
|
|
`el.style.prop`.
|
|
* Rebuild stewardd after editing, since the files are embedded.
|
|
* Function over polish. Leave pick-list checkboxes unticked by default.
|
|
|
|
## DHCP facts worth knowing
|
|
|
|
* Replies to clients without an address are broadcast on the interface
|
|
(255.255.255.255:68 through the interface's socket). That works with
|
|
every client and needs no raw socket. Renewals (`ciaddr` set) are
|
|
unicast.
|
|
* `SO_BINDTODEVICE` needs no capability since Linux 5.7. The ping check
|
|
uses an unprivileged ICMP socket, allowed by
|
|
`net.ipv4.ping_group_range`.
|
|
* Renewal REQUESTs often have no host name: keep the lease's previous one.
|
|
* dhcpcd sends a DUID-based client identifier, busybox udhcpc none.
|
|
Leases match by client ID when both sides have one, else by MAC address.
|
|
* A container's interface names follow the order the networks were
|
|
attached in (eth0 first).
|
|
|
|
## Git and releases
|
|
|
|
The maintainer's rules:
|
|
|
|
* **Commit only when asked. Never push.** The maintainer pushes, to Gitea
|
|
at gitea.adbraun.lol.
|
|
* **Branches** are `dev-X.Y.Z`, never named like a tag.
|
|
* **A release** is a commit "Release X": it sets `VERSION`, updates the
|
|
README status line, and (for a final release) moves `[Unreleased]` to
|
|
`## [X] - YYYY-MM-DD`. The annotated tag is `vX` with message
|
|
"Steward X".
|
|
|
|
## Shell hygiene
|
|
|
|
* **Never kill processes by pattern** (`pkill -f`). The pattern matches
|
|
the shell running it. Find the PID with `ps` and kill that.
|
|
* **Run long-lived test servers in the background mode of your tool**, not
|
|
with `&` in a shell that exits.
|