Steward 0.1.0 (in development): a DHCPv4 server with a web UI, API and CLI
Subnets served per interface, pools, reservations, options at three levels, leases in SQLite saved before the ACK, ping and decline conflict checks, validated changes with history and undo, users and tokens, an audit log, a Kea importer, a sandboxed systemd unit and Debian packages. Tested with real udhcpc and dhcpcd clients in Docker (scripts/e2e.sh). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
3c0808591f
111 files changed
+17851
No files matched your search
@@ -0,0 +1,90 @@
|
||||
# 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.
|
||||
* **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.
|
||||
Reference in new issue
Block a user