- interfaces.exclude in steward.yaml (names or shell patterns) leaves interfaces out of GET /interfaces, the web UI's picker and 'steward interface list'. Subnets on them are still served. - With the ping check on, active leases are pinged every minute; a client that answers gets a last-seen time (leases.last_seen, migration 4). Not answering never takes a client offline. - The activity log filters by subnet (--subnet, the Activity page, GET /events?subnet=). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
93 lines
4.7 KiB
Markdown
93 lines
4.7 KiB
Markdown
# Steward's design
|
|
|
|
Steward is a DHCPv4 server with the same shape as
|
|
[Jarl](https://gitea.adbraun.lol/adam/jarl): one daemon, `stewardd`, that
|
|
answers DHCP and serves a REST API and a web UI; one CLI, `steward`, that
|
|
talks to that API; SQLite for everything. Unlike Jarl, which manages
|
|
BIND, Steward *is* the server: there is no other program to configure,
|
|
check or reload.
|
|
|
|
## Scope of 0.1.0
|
|
|
|
* **IPv4 only.** DHCPv6 can come later in the same daemon.
|
|
* **Subnets per interface.** Every subnet names the network interface it is
|
|
served on (`eth0`, `eth0.10`, `br-lan`). A request is only answered from
|
|
the subnets of the interface it arrived on, so a VLAN's clients never get
|
|
another VLAN's addresses. An interface may carry several subnets (a
|
|
shared network): addresses are then handed out from them in turn.
|
|
* **Pools**: address ranges in a subnet that are handed out to any client.
|
|
* **Reservations**: a fixed address (and optionally a host name and
|
|
options) for a MAC address or client identifier.
|
|
* **Options**: the common ones by name (`routers`, `domain-name-servers`,
|
|
`domain-name`, `domain-search`, `ntp-servers`, …, see
|
|
[options.md](options.md)), any other by number. Set globally, per subnet
|
|
and per reservation; the most specific wins.
|
|
* **Leases** in SQLite, written before the client is answered, so a crash
|
|
or restart never hands out an address twice.
|
|
* **Conflict checks**: an address is pinged before it is offered; one that
|
|
answers, or that a client declines, is held back for a while. Active
|
|
leases are pinged every minute too, for a "last seen" time that never
|
|
takes a client offline ([subnets.md](subnets.md#online-and-last-seen)).
|
|
* **The Jarl way of working**: an API used by both the CLI and the web UI;
|
|
users, sessions and API tokens; every configuration change validated as a
|
|
whole before it is saved and used; a history of changes that can be
|
|
undone; an audit log.
|
|
* **Moving from Kea**: `stewardd import-kea` reads a `kea-dhcp4.conf` and
|
|
its lease file, so switching doesn't renumber anything.
|
|
* A systemd unit and Debian packages, as for Jarl.
|
|
|
|
Not in 0.1.0: relays (requests with `giaddr`, answered from the relay's
|
|
subnet), DHCPv6, failover between two servers, client classes, and names
|
|
in DNS through Jarl (that needs dynamic records in Jarl first).
|
|
|
|
## How it fits together
|
|
|
|
```
|
|
cmd/stewardd the daemon: serve, init, import-kea, user, backup
|
|
cmd/steward the CLI
|
|
internal/dhcp4 DHCPv4 messages and the option catalog (no I/O)
|
|
internal/model the configuration (State: settings, subnets, reservations), leases, users
|
|
internal/validate whole-configuration checks, with stable codes
|
|
internal/store SQLite: configuration, leases, history, users, tokens, sessions, audit
|
|
internal/engine configuration changes: validate, save, record history, undo
|
|
internal/dhcpd the DHCP server: sockets per interface, the protocol, address allocation
|
|
internal/server the HTTP API and the web UI
|
|
internal/web the web UI's files (plain ES modules, embedded)
|
|
internal/kea the Kea importer
|
|
```
|
|
|
|
### Configuration changes
|
|
|
|
The configuration is small, so it is held in memory as a `model.State`
|
|
and changed as a whole. An API call becomes a list of changes, each one
|
|
"object X was A and becomes B" (B empty for a delete, A empty for a
|
|
create). The engine applies them to a copy of the state, validates the
|
|
result, and only if that passes saves the objects and a history entry in
|
|
one transaction, then hands the new state to the DHCP server. Undoing a
|
|
history entry applies its changes backwards, after checking that the
|
|
objects are still as the entry left them.
|
|
|
|
### Answering DHCP
|
|
|
|
`dhcpd` opens one UDP socket on port 67 per interface that has a subnet,
|
|
bound to that interface (`SO_BINDTODEVICE`). The interface a request
|
|
arrived on picks the subnets. The server identifier is the interface's
|
|
own address in the subnet.
|
|
|
|
For a DISCOVER the address is, in order: the client's reservation; the
|
|
address it had before (its lease, even expired, so clients keep their
|
|
address); the address it asks for, if free; the first free address in the
|
|
pools (addresses never handed out first, then the one released longest
|
|
ago). Reserved addresses are never handed out to anyone else. The offer is
|
|
held for a minute.
|
|
|
|
A REQUEST is answered as RFC 2131 describes for each client state
|
|
(selecting, init-reboot, renewing, rebinding). The lease is saved before
|
|
the ACK is sent. Steward is authoritative for its subnets: a client asking
|
|
for an address that doesn't belong on its interface gets a NAK, so it
|
|
starts over at once instead of waiting for its old lease to run out.
|
|
|
|
Replies go to the client's address when it has one (renewals), and
|
|
otherwise as a broadcast on the interface. That works with every client and
|
|
needs no raw sockets.
|