Files
steward/AGENTS.md
T
abrown38andClaude Opus 5.5 7b3f0f764c 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>
2026-10-14 15:46:36 +01:00

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.