Files
steward/AGENTS.md
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

5.0 KiB

Working on Steward

Notes for coding agents (and people) changing this repository. Read this first, then docs/design.md (how it fits together and what 0.1 leaves out) and 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

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.