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:
abrown38andClaude Opus 5.5 committed 2026-10-14 12:14:07 +01:00
commit 3c0808591f
111 files changed
+17851

No files matched your search

+137
View File
@@ -0,0 +1,137 @@
# The REST API
Everything the web UI and the CLI do goes through this API, under
`/api/v1`. Requests and answers are JSON.
## Authentication
* **API tokens**: `Authorization: Bearer steward_...`. Make one in the web
UI (your account), with `steward token create NAME`, or with `stewardd
user token USER NAME` on the server.
* **Sessions**: `POST /auth/login` with `{"username", "password"}` sets a
cookie and returns `csrf_token`. Requests other than GET must send it as
`X-CSRF-Token`.
* **The local socket** (`/run/steward/steward.sock`): root and stewardd's
own user need nothing; others send a token.
Failed logins and token attempts are rate limited per address and per
user name. [permissions.md](permissions.md) has what each call needs.
## Errors
```json
{"error": {"code": "validation", "message": "subnet iot (10.0.20.0/24): it overlaps subnet office (10.0.10.0/24)",
"problems": [{"severity": "error", "code": "subnet_overlap", "message": "...", "object": "subnet iot (10.0.20.0/24)"}]}}
```
| Status | Code | |
|---|---|---|
| 400 | `bad_request` | the request can't be read (an unknown option name, a bad address) |
| 401 | `unauthenticated`, `session_expired`, `login_failed` | |
| 403 | `forbidden`, `csrf` | |
| 404 | `not_found` | |
| 409 | `conflict` | e.g. undoing a change after the objects changed again |
| 422 | `validation` | the change would make the configuration invalid; `problems` lists why |
| 429 | `rate_limited` | |
## Changes
Calls that change the configuration answer:
```json
{"applied": true, "history_id": 7,
"changes": [{"kind": "subnet", "id": 12, "label": "iot (10.0.20.0/24)", "before": {...}, "after": {...}}],
"warnings": [{"severity": "warning", "code": "interface_unknown", "message": "...", "object": "..."}],
"object": {...}}
```
* `?dry_run=1` checks the change and returns what it would do, with
`"applied": false, "dry_run": true`.
* `before` is missing for a create and `after` for a delete.
* `warnings` are the problems the change brings; older ones are in
`/status`.
* `object` is the created or changed object.
Creating answers 201, other changes 200.
## Endpoints
### Status
| | |
|---|---|
| `GET /healthz` | `{"ok": true, "version"}`, without authentication |
| `GET /status` | build, start time, `interfaces` (`listening`, `error`, `addresses`, `subnets`), `subnets` with usage, `problems`, `leases` (counts by state), `messages` (counts by type), `ping_problem` |
| `GET /interfaces` | the host's interfaces, their addresses and subnets |
| `GET /catalog` | the option catalog, value types, reserved codes, roles |
### Settings
`GET /settings`, `PATCH /settings` with any of:
```json
{"lease_time": 43200, "ping_check": true, "ping_timeout_ms": 500, "decline_hold": 86400,
"not_authoritative": false, "options": [{"option": "domain-name-servers", "value": "10.0.10.2"}]}
```
`options` replaces the list. Each option is `{"option": NAME or NUMBER,
"value": TEXT}`, with `"type"` for numbers the catalog doesn't know
([options.md](options.md)).
### Subnets
| | |
|---|---|
| `GET /subnets` | every subnet, with `usage` (`pool_size`, `leased`, `declined`, `reservations`, `reservations_leased`) |
| `POST /subnets` | create; needs `prefix` and `interface` |
| `GET /subnets/{ref}` | by ID, name, or prefix with `_` for `/` (`10.0.10.0_24`) |
| `PATCH /subnets/{ref}` | change the given fields |
| `DELETE /subnets/{ref}` | delete, with its reservations |
```json
{"name": "office", "interface": "eth0", "prefix": "10.0.10.0/24",
"pools": ["10.0.10.100-10.0.10.199", "10.0.10.224/28"], "lease_time": 0,
"options": [{"option": "routers", "value": "10.0.10.1"}], "disabled": false, "comment": ""}
```
`pools` and `options` replace the lists; `lease_time` 0 means the
settings'.
### Reservations
| | |
|---|---|
| `GET /reservations[?subnet=REF]` | each with `subnet_id`, `subnet` and its current `lease` |
| `POST /reservations` | `{"ip", "mac", "client_id", "hostname", "options", "comment"}`; `ip` and `mac` or `client_id` needed |
| `GET`, `PATCH`, `DELETE /reservations/{ref}` | by ID, address, or MAC address (when it has one reservation) |
### Leases and activity
| | |
|---|---|
| `GET /leases[?subnet=&state=&interface=&q=]` | every lease, with its state now (`active`, `expired`, `released`, `declined`), `subnet` and its reservation; `q` searches address, MAC and host name |
| `GET /leases/{ip}` | one |
| `DELETE /leases/{ip}` | forget it (audited) |
| `POST /leases/{ip}/reserve` | make it a reservation; optional `{"hostname", "comment"}` |
| `GET /events[?mac=&interface=&limit=]` | the latest DHCP messages, newest first (the last 1000 are kept in memory) |
### History and import
| | |
|---|---|
| `GET /history[?limit=&before=]` | changes, newest first |
| `GET /history/{id}` | one, with each object before and after |
| `POST /history/{id}/revert` | undo it; 409 if an object it touched has changed since, or it was undone already |
| `POST /import/kea` | `{"config": TEXT, "leases": [TEXT, ...], "interfaces": {"PREFIX": "IFACE"}, "replace": false}` ([kea.md](kea.md)); answers with `leases`, `notes`, `skipped` too |
### Users, tokens, audit
| | |
|---|---|
| `POST /auth/login`, `POST /auth/logout`, `GET /auth/whoami` | |
| `POST /me/password` | `{"current", "password"}` |
| `GET /me/tokens`, `POST /me/tokens` | `{"name", "role", "grants", "expires_in"}`; the answer's `secret` is shown once |
| `GET /tokens`, `DELETE /tokens/{id}` | all tokens (administrators), revoke |
| `GET`, `POST /users`; `PATCH`, `DELETE /users/{id}` | administrators; `{"username", "password", "admin", "role", "grants", "disabled", "comment"}` |
| `GET`, `POST /users/{id}/tokens` | |
| `GET /audit[?user=&action=&result=&q=&before=&limit=]` | |
+160
View File
@@ -0,0 +1,160 @@
# The steward CLI
`steward` talks to stewardd's REST API. `steward help COMMAND` describes
each command and its flags.
## Connecting
* **On the server**, with nothing configured, the CLI uses stewardd's
local socket, `/run/steward/steward.sock`. Root and the `steward` user
are administrators there without a token: `sudo steward status`.
* **From elsewhere**, give the server's URL and an API token:
```sh
steward config set server https://dhcp.example.com:8067
steward config set token # asks for it
```
The settings are in `~/.config/steward/config.yaml` (on macOS
`~/Library/Application Support/steward/config.yaml`, on Windows
`%AppData%\steward\config.yaml`).
| Flag | Environment | |
|---|---|---|
| `-s, --server URL` | `STEWARD_SERVER` | the server; `local` for the local socket |
| `--token TOKEN` or `@FILE` | `STEWARD_TOKEN` | the API token |
| `--config FILE` | `STEWARD_CLI_CONFIG` | the CLI's configuration file |
| | `STEWARD_SOCKET` | the local socket's path |
| `--insecure`, `--ca-cert FILE` | | TLS: skip the check, or trust another CA |
| `-n, --dry-run` | | check and show a change without making it |
| `-o, --output FORMAT` | | `table` (default), `json`, `yaml` or `id` |
| `-q, --quiet` | | print only errors (and the IDs of new objects) |
Exit codes: 0 ok, 1 error, 3 not authenticated or not allowed, 4 not
found, 5 conflict, 6 invalid or refused by validation, 7 server error.
## Changes
Every change is checked as a whole first. A refused change says why and
gives the problem's code ([validation.md](validation.md)). A saved one
says its history entry, and any new warnings:
```
$ steward subnet add 10.0.20.0/24 --interface eth0.20 --name iot --pool 10.0.20.100-10.0.20.200
created subnet iot (10.0.20.0/24)
saved (history 2; undo with 'steward history undo 2')
warning: subnet iot (10.0.20.0/24): this server has no interface eth0.20 (it has eth0, lo); the subnet is served once it appears [interface_unknown]
```
`-n` shows what would change without changing it.
## Commands
### status
`steward status`: the version, each interface and whether it is served
(and why not), each subnet's pool use, leases by state, DHCP messages
since the start, and problems.
### subnet
| Command | |
|---|---|
| `subnet list` | subnets, with interface, pools, use, reservations, lease time |
| `subnet show SUBNET` | one subnet; SUBNET is its ID, name or prefix |
| `subnet add PREFIX --interface IFACE [--name N] [--pool A-B]... [--lease-time 12h] [--option NAME=VALUE]... [--comment C]` | add one |
| `subnet update SUBNET [--name] [--interface] [--prefix] [--pool]... [--add-pool] [--remove-pool] [--lease-time 1h\|default] [--option NAME=VALUE]... [--unset-option NAME]... [--comment]` | change what is given |
| `subnet enable\|disable SUBNET` | answer its clients, or not |
| `subnet delete SUBNET [-y]` | delete it and its reservations |
`--pool` replaces the pools; `--add-pool` and `--remove-pool` change one.
`--option` sets an option and keeps the others; `--unset-option` removes
one. An option Steward doesn't know: `--option 224:string=hello`.
### reservation
| Command | |
|---|---|
| `reservation list [--subnet SUBNET]` | reservations, and whether each client is online |
| `reservation show RES` | RES is its ID, address or MAC address |
| `reservation add IP MAC [--hostname H] [--client-id HEX] [--option NAME=VALUE]... [--comment C]` | `-` for MAC with `--client-id` |
| `reservation update RES [--ip] [--mac] [--client-id] [--hostname] [--option]... [--unset-option]... [--comment]` | |
| `reservation delete RES` | |
### lease
| Command | |
|---|---|
| `lease list [--subnet S] [--state STATE] [--interface I] [--search TEXT] [-a]` | active and held-back leases; `-a` all |
| `lease show IP` | one lease |
| `lease reserve IP [--hostname H]` | make the address its client's reservation |
| `lease delete IP [-y]` | free the address at once |
### activity
`steward activity [--mac MAC] [--interface I] [--limit N] [-f]`: the latest
DHCP messages, received and sent, oldest first. `-f` keeps watching, which
is useful while a device tries to get an address:
```
11:02:13.120 eth0 DHCPDISCOVER 3c:22:fb:11:22:33 - adams-macbook
11:02:13.622 eth0 DHCPOFFER 3c:22:fb:11:22:33 10.0.10.150 adams-macbook
11:02:13.630 eth0 DHCPREQUEST 3c:22:fb:11:22:33 10.0.10.150 adams-macbook
11:02:13.631 eth0 DHCPACK 3c:22:fb:11:22:33 10.0.10.150 adams-macbook
```
`NO ADDRESS` means the pools on that interface are full, and `CONFLICT`
an address that answered the ping check. A `DHCPNAK` says why it was
sent.
### settings and options
| Command | |
|---|---|
| `settings show` | |
| `settings set [--lease-time 1d] [--ping-check on\|off] [--ping-timeout 500ms] [--decline-hold 24h] [--authoritative on\|off] [--option NAME=VALUE]... [--unset-option NAME]...` | |
| `option list` | the options Steward knows ([options.md](options.md)) |
| `interface list` | the server's interfaces, their addresses and subnets |
### history
| Command | |
|---|---|
| `history list [--limit N]` | changes, newest first |
| `history show ID` | each object's fields before and after |
| `history undo ID` | apply the change backwards (refused if something it touched has changed since) |
### import
`steward import kea CONFIG [--leases FILE]... [--interface PREFIX=IFACE]... [--replace]`:
see [kea.md](kea.md).
### users, tokens, audit
| Command | |
|---|---|
| `whoami` | who you are to the server, and what you may do |
| `user list\|add\|update\|delete` | users (administrators); `user add NAME --role operator` |
| `token list [--all]`, `token create NAME [--role R] [--grant G]... [--expires 90d] [--user U] [--save]`, `token revoke ID` | API tokens |
| `password` | change your password |
| `audit [--user U] [--action PREFIX] [--search TEXT]` | the audit log |
| `config show`, `config set server\|token\|insecure\|ca_cert [VALUE]` | this CLI's settings |
[permissions.md](permissions.md) explains roles and grants.
## stewardd
The daemon's own commands work on the database directly, as root or the
`steward` user (`sudo -u steward stewardd ...`):
| Command | |
|---|---|
| `stewardd serve` | run (the systemd unit does) |
| `stewardd init [-u NAME] [--token]` | create the database and the first administrator |
| `stewardd user add NAME [--admin]`, `user password NAME`, `user token NAME TOKEN-NAME` | when the API can't be used |
| `stewardd check` | check the configuration against this machine's interfaces |
| `stewardd backup [FILE]`, `stewardd restore FILE` | [operations.md](operations.md) |
| `stewardd config [--example]` | the settings in use, or a commented example |
| `stewardd version` | |
All take `-c FILE` for another configuration file.
+44
View File
@@ -0,0 +1,44 @@
# Configuration file
`/etc/steward/steward.yaml` says how stewardd runs. The DHCP
configuration (subnets, pools, reservations, options) isn't here; it is
in the database, changed with the CLI or the web UI.
`stewardd config --example` prints a commented file with the defaults.
Every setting is optional; unknown settings are an error. stewardd reads
the file when it starts.
| Setting | Default | |
|---|---|---|
| `listen` | `["127.0.0.1:8067"]` | addresses of the web UI and API (`host:port`). Keep them on localhost or a management network |
| `tls.cert_file`, `tls.key_file` | none | serve HTTPS with this certificate; stewardd rereads it when the file changes, or on `systemctl reload steward` |
| `trusted_proxies` | `[]` | addresses or prefixes of reverse proxies whose `X-Forwarded-For` and `X-Forwarded-Proto` are believed |
| `socket` | `/run/steward/steward.sock` | the local API socket; `""` turns it off |
| `database` | `/var/lib/steward/steward.db` | the SQLite database: configuration, leases, history, users, audit log |
| `log.level` | `info` | `debug` (every HTTP request), `info`, `warn` or `error` |
| `log.format` | `text` | `text` or `json` |
| `session.lifetime` | `12h` | how long a web UI login lasts |
| `session.idle_timeout` | `2h` | logged out after this long without use |
| `session.cookie_secure` | `auto` | `auto` (secure over HTTPS), `always` or `never` |
| `backup.dir` | `/var/backups/steward` | where `stewardd backup` writes |
| `backup.retention` | `30d` | backups older than this are deleted… |
| `backup.keep_at_least` | `7` | …keeping at least this many |
Durations are written `15s`, `12h` or `30d`.
## HTTPS
Over plain HTTP, passwords and tokens cross the network in the clear;
stewardd warns when it listens on a non-loopback address without TLS. Give
it a certificate:
```yaml
listen: ["10.0.10.2:8067"]
tls:
cert_file: /etc/steward/tls/fullchain.pem
key_file: /etc/steward/tls/privkey.pem
```
Or keep it on `127.0.0.1` behind a reverse proxy that terminates TLS, and
list the proxy in `trusted_proxies`. Ports below 1024 work too: the unit
grants `CAP_NET_BIND_SERVICE` for DHCP's port 67.
+90
View File
@@ -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.
+124
View File
@@ -0,0 +1,124 @@
# Developing Steward
## Layout
```
cmd/stewardd/ the daemon: serve, init, user, check, backup, restore, config
cmd/steward/ the CLI
internal/
dhcp4/ DHCPv4 messages (RFC 2131/2132/3396) and the option catalog; no I/O
model/ the configuration (State: settings, subnets, reservations), leases, users, history
validate/ whole-configuration checks with stable codes (docs/validation.md)
store/ SQLite (modernc.org/sqlite, no cgo): configuration, leases, history, users, tokens, sessions, audit
engine/ configuration changes: edit a copy, diff, validate, save with history, undo
dhcpd/ the DHCP server: sockets per interface, the protocol, address choice, leases, ping check, activity
server/ the HTTP API and the web UI, authentication, the local socket
api/, client/ JSON types and the Go client shared by server and CLI
kea/ the Kea configuration and lease file importer
auth/ passwords (argon2id), tokens, sessions, grants and roles
config/ stewardd's YAML configuration
version/ what a build reports
web/static/ the web UI: plain ES modules, no build step, embedded with go:embed
deploy/ systemd units, sysusers.d and tmpfiles.d files, Debian package scripts
scripts/e2e.sh end-to-end test with real DHCP clients in Docker
scripts/mkdeb.sh the Debian packages (make deb)
scripts/logo.py the web UI's logo from artwork/logo.svg
scripts/optionsdoc.py docs/options.md from the option catalog
artwork/logo.svg the logo's master
```
[design.md](design.md) describes how the pieces fit.
## Building and testing
```sh
make # bin/stewardd, bin/steward, bash completion
make test # go vet, gofmt check, go test ./...
make e2e # scripts/e2e.sh: needs Docker
make deb # dist/steward_<version>_<arch>.deb and steward-cli_...
```
**Unit tests** cover the protocol without sockets. `dhcpd.Server.Handle`
takes a parsed message and an interface name and returns the reply, with
fake interfaces, a fake pinger and a fake clock
(`internal/dhcpd/handle_test.go`). The API tests drive real DHCP exchanges
through `Handle` too.
**The e2e test** runs the real thing:
* stewardd in a `debian:trixie` container on two Docker bridge networks
(eth0 and eth1, like two VLANs);
* busybox `udhcpc` and `dhcpcd` clients in Alpine containers.
It checks:
* addresses and options per interface, and reservations;
* the ping check, against a container that squats on a pool address;
* init-reboot, renewal and release with dhcpcd;
* a client moved to another VLAN: NAK, then that VLAN's address;
* a full pool and a disabled subnet;
* history and undo, and leases across a restart;
* the API with tokens, and backups.
`KEEP=1` leaves the containers running; `DOCKER_HOST` points at another
Docker daemon. Root isn't needed on the host.
stewardd can't serve port 67 unprivileged, so a stewardd run by hand
outside a container shows `permission denied` for its interfaces. The
API and web UI still work, which is enough for UI work. The systemd
sandbox is only tested by installing the package in a container that
runs systemd (`--privileged /sbin/init`) and pointing a client container
at it.
## The web UI
Plain JavaScript modules under `internal/web/static/`, built with the
same helpers as Jarl's:
* All DOM goes through `h()` in `ui.js`, never `innerHTML`.
* The Content-Security-Policy allows only the app's own scripts and
styles, so no inline styles. Use a class in `style.css`, or set
`el.style.prop`.
* The files are embedded, so rebuild stewardd after changing them.
The logo's master is `artwork/logo.svg`; `scripts/logo.py` writes
`assets/logo.svg` (the favicon) and `assets/logo.js` (drawn inline by
`brand.js`). The STEWARD wordmark is drawn by `wordmark()` in `brand.js`,
in the letters of Jarl's wordmark.
## Conventions
* Every user-visible change goes into `CHANGELOG.md` under "Unreleased",
with the docs updated in the same change.
* Errors say what is wrong and how to fix it, naming the object.
* New validation rules get a stable code, a test in
`internal/validate/validate_test.go` and a row in
[validation.md](validation.md).
* Configuration changes go through `engine.Apply`, never straight to the
store, so they are validated, recorded and undoable.
* Schema changes append a migration to `internal/store/store.go`; never
edit a shipped one.
* `gofmt` and `go vet` clean (`make test`).
## Releasing
As for Jarl:
1. Move "Unreleased" in `CHANGELOG.md` under `## [<version>] - <date>`.
A release candidate may leave it there.
2. Put the version in `VERSION`, and update the README's status line.
3. Run `make test && make e2e`.
4. Commit as "Release X", tag it `git tag -a vX -m "Steward X"`, and push.
The tag starts `.gitea/workflows/release.yml`:
* it checks the tag against `VERSION` and the CHANGELOG;
* it runs `make test`;
* it builds the tarballs, the CLI for each OS and the `.deb`s (amd64,
arm64);
* `scripts/gitea-release.sh` publishes the release, and uploads the
`.deb`s to the registry: `stable`, or `testing` for a version with a
hyphen.
It needs the repository secret `RELEASE_TOKEN` (`write:repository`,
`write:package`).
+105
View File
@@ -0,0 +1,105 @@
# Installing Steward
Steward runs on Linux (stewardd uses Linux socket options). It is tested
on Debian 13 and Ubuntu 24.04. The CLI also runs on macOS and Windows.
## From the package registry
The release workflow publishes Debian packages to the Gitea registry,
`stable` for releases and `testing` for release candidates. Jarl's packages come from the same registry. If apt already has it
(`/etc/apt/sources.list.d/jarl.list`), `sudo apt install steward` is all
it takes. Otherwise add it:
```sh
sudo install -d /etc/apt/keyrings
sudo curl -fsSL https://gitea.adbraun.lol/api/packages/adam/debian/repository.key -o /etc/apt/keyrings/gitea-adam.asc
echo "deb [signed-by=/etc/apt/keyrings/gitea-adam.asc] https://gitea.adbraun.lol/api/packages/adam/debian stable main" \
| sudo tee /etc/apt/sources.list.d/gitea-adam.list
sudo apt update
sudo apt install steward
```
For release candidates too, add the `testing` distribution: the same
`deb` line with `testing` instead of `stable`.
There are two packages:
* **`steward`** is the server: `/usr/bin/stewardd`, the systemd units
`steward.service`, `steward-backup.service` and `steward-backup.timer`,
and the `steward` user and directories. It creates
`/etc/steward/steward.yaml` on the first install and never touches it
again. It depends on `steward-cli`.
* **`steward-cli`** is the CLI, `/usr/bin/steward`. It can be installed
alone on any machine to manage Steward remotely.
The release page also has `.deb` files, tarballs and the CLI for Linux,
macOS and Windows.
## From source
```sh
make # bin/stewardd, bin/steward (Go, as in go.mod)
sudo make install # /usr/local/bin, units in /etc/systemd/system
sudo systemd-sysusers steward.conf && sudo systemd-tmpfiles --create steward.conf
sudo chgrp steward /etc/steward /etc/steward/steward.yaml && sudo systemctl daemon-reload
```
`make deb` builds the packages (`DEB_ARCH=arm64` for another architecture).
## First steps
1. **Settings** are in `/etc/steward/steward.yaml`
([configuration.md](configuration.md)). By default the web UI and API
listen on `127.0.0.1:8067`. To reach them from your network, add an
address there, ideally with TLS or behind a TLS reverse proxy.
2. **Create the database and the first administrator:**
```sh
sudo -u steward stewardd init
```
3. **Stop any other DHCP server** on the interfaces Steward will serve
(Kea, isc-dhcp-server, dnsmasq): only one program can use an
interface's port 67. Moving from Kea: [kea.md](kea.md).
4. **Start Steward:**
```sh
sudo systemctl enable --now steward
```
5. **Add a subnet** for each network, on the interface its clients are
on, in the web UI or with the CLI. On the server, root needs no token:
```sh
sudo steward subnet add 10.0.10.0/24 --interface eth0 --name office \
--pool 10.0.10.100-10.0.10.199 --option routers=10.0.10.1
sudo steward settings set --option domain-name-servers=10.0.10.2 --option domain-name=lan
sudo steward status
```
The server needs an address in each subnet, on its interface: that is
where clients renew. For VLANs, set up a VLAN interface for each (for
example with systemd-networkd or `/etc/network/interfaces`), with an
address in its subnet.
**Firewall:** allow UDP port 67 in on the served interfaces. Replies go
out to UDP port 68. With nftables:
```
udp dport 67 iifname { "eth0", "eth0.20" } accept
```
## From another machine
Install `steward-cli`, make a token, and set the server:
```sh
sudo steward token create laptop # on the server; prints the token once
steward config set server https://dhcp.example.com:8067
steward config set token # paste it
steward status
```
## Upgrading
`apt upgrade` restarts a running stewardd, and stewardd upgrades its
database when it starts. Take a backup first if you like
(`sudo -u steward stewardd backup`); the daily timer keeps 30 days of them.
+111
View File
@@ -0,0 +1,111 @@
# Moving from Kea
`steward import kea` reads a Kea DHCPv4 configuration (`kea-dhcp4.conf`)
and Kea's lease file, and makes them Steward's: clients keep their
addresses, and reservations and options carry over.
## Steps
1. Install Steward and start it ([install.md](install.md)). While Kea
serves the network, Steward serves nothing: it has no subnets yet.
2. See what the import would do:
```sh
sudo steward import kea /etc/kea/kea-dhcp4.conf \
--leases /var/lib/kea/kea-leases4.csv -n
```
It lists the subnets and reservations it would create, how it mapped
things (`note:`), and what it can't bring over (`not imported:`).
3. Import:
```sh
sudo steward import kea /etc/kea/kea-dhcp4.conf --leases /var/lib/kea/kea-leases4.csv
```
Steward now wants to serve the subnets. Its sockets can't open while
Kea holds port 67: `steward status` says so, and stewardd retries
every 30 seconds.
4. Stop Kea:
```sh
sudo systemctl disable --now kea-dhcp4-server
```
Within 30 seconds Steward serves the interfaces (`steward status`).
Clients renew with it as their leases come due. They keep their
addresses, because their leases were imported.
Going back is the same in reverse: stop Steward
(`systemctl disable --now steward`) and start Kea. Kea still has its own
lease file. Leases Steward handed out in between aren't in it, but Kea
pings before it offers an address, and clients renew or rebind.
## What is imported
| Kea | Steward |
|---|---|
| `valid-lifetime` (global, shared network, subnet) | the settings' lease time, and subnets' own where they differ |
| `option-data` (global, shared network, subnet, reservation) | options at the same level; a subnet gets its shared network's |
| `subnet4`: `subnet`, `pools`, `interface` | subnets with their pools and interface |
| `shared-networks` | their subnets, all on the shared network's interface |
| `reservations` with `hw-address` or `client-id` and `ip-address`, `hostname` | reservations |
| `decline-probation-period` | the decline hold |
| `user-context.comment` or `comment` of a subnet | the subnet's comment |
| the lease file: active and declined leases, and released ones | leases (the last line of each address wins) |
Options are matched by Kea's names, which Steward uses too, or by code.
Hex data (`"csv-format": false`) is read as hex.
**Interfaces.** Every Steward subnet needs its interface. The import
takes, in order:
1. the subnet's (or its shared network's) `interface`;
2. the interface on this server with an address in the subnet;
3. the only interface in Kea's `interfaces-config`, when there is just
one.
When none of these applies, the import says which subnets need one. Give
it with `--interface PREFIX=IFACE`, once per subnet:
```sh
sudo steward import kea kea-dhcp4.conf --interface 10.0.20.0/24=eth0.20
```
**Lease files.** Kea's memfile is a log, and its lease file cleanup moves
older lines to `kea-leases4.csv.2`. Give both, older first:
`--leases /var/lib/kea/kea-leases4.csv.2 --leases /var/lib/kea/kea-leases4.csv`.
Leases outside the imported subnets are left out (the import says how
many). Leases in a database (MySQL, PostgreSQL) can't be read. Export them
with the lease commands hook (`lease4-get-all`) into a memfile first, or
let clients get new leases.
## What isn't imported
The import lists these as `not imported:` instead of dropping them
silently:
* client classes, and pools or options tied to them;
* options of a pool (Steward sets options per subnet) and options in other
spaces (vendor option spaces);
* reservations by `duid`, `circuit-id` or `flex-id`, and reservations
without an `ip-address`;
* relays (`relay`), `next-server`, `boot-file-name` and other BOOTP fields;
* hooks, DHCP-DDNS, and any other section (`Dhcp6`, `Logging`, `Control-agent`).
`<?include ...?>` isn't followed: paste the included files in first.
Kea is **not authoritative** by default; Steward is. The import notes this.
Steward answers a client asking for an address that doesn't belong on
its interface with a NAK, so the client gets a correct one at once. If
another DHCP server still shares one of the networks, turn this off:
`steward settings set --authoritative off`.
## Importing again
Importing over an existing configuration needs `--replace`. A subnet
with the same network, and a reservation with the same address, keep
their IDs, and subnets keep the names given in Steward, since Kea has
none. So importing the same file again changes nothing, and after an edit
in Kea only the differences show. The import is one history entry: `steward
history undo ID` takes it back, except for the imported leases.
+76
View File
@@ -0,0 +1,76 @@
# Running Steward
## The service
```sh
systemctl status steward # or stewardd
journalctl -u steward -f
systemctl reload steward # reread the TLS certificate
```
stewardd logs each configuration change, interfaces it starts or stops
serving (and why it can't), addresses that answered the ping check or were
declined, and full pools. DHCP messages themselves aren't logged: see
them with `steward activity` or on the Activity page (the last 1000 are
kept in memory).
## When a device gets no address
1. `steward status`: is its interface served? It may say:
* `there is no interface eth0.20 on this server (yet)`: the VLAN
interface doesn't exist. Steward serves it within 30 seconds of it
appearing.
* `address already in use`: another DHCP server holds port 67 on it.
Stop it.
* `permission denied`: stewardd lacks `CAP_NET_BIND_SERVICE`. Check the
installed unit (`systemctl cat steward`).
2. `steward activity --mac MAC -f`, then reconnect the device:
* no DHCPDISCOVER at all: the request doesn't reach the server (a
firewall, the wrong VLAN, or a relay, which 0.1 doesn't serve);
* `NO ADDRESS`: the pools on that interface are full
(`steward subnet list` shows how full);
* `DHCPNAK` with a reason, then a new DISCOVER: the device asked for an
address that doesn't belong there. That is normal after it moved
networks.
3. `steward lease list --search NAME` and `steward reservation show MAC`.
## Backups
`steward-backup.timer` runs `stewardd backup` daily. It writes a
consistent copy of the database to `backup.dir`
(`/var/backups/steward/steward-<time>.db`), and deletes those older than
`backup.retention` (30 days), keeping at least `backup.keep_at_least` (7).
A backup holds everything: configuration, leases, history, users, tokens
(hashed) and the audit log.
```sh
sudo -u steward stewardd backup # now
sudo -u steward stewardd backup /tmp/s.db # to a file of your choice
sudo systemctl disable --now steward-backup.timer # no daily backups
```
To **restore**, stop stewardd first; restore refuses to replace the
database under it:
```sh
sudo systemctl stop steward
sudo -u steward stewardd restore /var/backups/steward/steward-20261014T020000Z.db
sudo systemctl start steward
```
The old database is kept next to it as `steward.db.before-restore-<time>`.
Leases handed out after the backup are missing from the restored one:
the ping check stops those addresses from being handed out twice, and
clients renewing them get a NAK and ask again.
## Upgrades
`apt upgrade` restarts a running stewardd. A new version upgrades the
database when it starts, and an older one refuses a database a newer one
has upgraded: restore a backup to go back.
## Checks
`stewardd check` validates the configuration in the database against
this machine's interfaces, the same checks as `steward status` shows
([validation.md](validation.md)).
+103
View File
@@ -0,0 +1,103 @@
# DHCP options
Options are set by name, or by number with a type. Set them in the
settings (every subnet), on a subnet, or on a reservation; the most
specific wins ([subnets.md](subnets.md#options)).
```sh
steward settings set --option domain-name-servers=10.0.10.2,10.0.10.3
steward subnet update office --option routers=10.0.10.1 --unset-option ntp-servers
steward reservation update nas --option 224:string=hello # an option Steward doesn't know
```
`steward option list` prints the list below; the web UI offers it in each
options editor. (This page is written by scripts/optionsdoc.py from the
catalog in internal/dhcp4/catalog.go.)
## Options by name
| Code | Name | Type | What |
|---:|---|---|---|
| 2 | `time-offset` | int32 | the client's offset from UTC in seconds (obsolete; prefer tcode) |
| 3 | `routers` | ips | default gateways, in order of preference |
| 4 | `time-servers` | ips | RFC 868 time servers (rarely used; see ntp-servers) |
| 6 | `domain-name-servers` | ips | DNS servers, in order of preference |
| 7 | `log-servers` | ips | MIT-LCS UDP log servers |
| 15 | `domain-name` | string | the domain the client's host name belongs to |
| 19 | `ip-forwarding` | bool | whether the client should forward packets |
| 23 | `default-ip-ttl` | uint8 | the TTL for the client's outgoing packets |
| 26 | `interface-mtu` | uint16 | the MTU of the client's interface |
| 28 | `broadcast-address` | ip | the subnet's broadcast address |
| 31 | `perform-router-discovery` | bool | whether the client should solicit routers (RFC 1256) |
| 42 | `ntp-servers` | ips | NTP servers, in order of preference |
| 43 | `vendor-encapsulated-options` | hex | vendor-specific information |
| 44 | `netbios-name-servers` | ips | NetBIOS name servers (WINS) |
| 46 | `netbios-node-type` | uint8 | NetBIOS node type: 1 B, 2 P, 4 M, 8 H |
| 47 | `netbios-scope` | string | NetBIOS scope |
| 60 | `vendor-class-identifier` | string | the server's vendor class (PXE: PXEClient) |
| 66 | `tftp-server-name` | string | the TFTP server for network boot |
| 67 | `boot-file-name` | string | the file to boot from the TFTP server |
| 69 | `smtp-server` | ips | mail servers |
| 100 | `pcode` | string | the time zone as a POSIX TZ string (EST5EDT,M3.2.0,M11.1.0) |
| 101 | `tcode` | string | the time zone by name (Europe/London) |
| 114 | `v4-captive-portal` | string | the captive portal's API URL (RFC 8910) |
| 119 | `domain-search` | domains | domains to try when looking up short names |
| 121 | `classless-static-route` | routes | extra routes: "10.20.0.0/16 via 10.0.10.1" (clients that use it ignore routers, so add 0.0.0.0/0 too) |
| 150 | `tftp-server-address` | ips | TFTP servers by address (Cisco phones and others) |
| 252 | `wpad-url` | string | web proxy auto-discovery URL |
The names are Kea's, so a Kea configuration's options carry over.
## Value types
| Type | How to write it | Example |
|---|---|---|
| ip | an IPv4 address | `10.0.10.255` |
| ips | IPv4 addresses, separated by commas or spaces, in order of preference | `10.0.10.2, 10.0.10.3` |
| string | text, as it is | `lan` |
| uint8, uint16, uint32 | a whole number | `1500` |
| int32 | a whole number, maybe negative | `-3600` |
| bool | `true` or `false` (`yes`/`no`, `on`/`off`, `1`/`0` work too) | `false` |
| domains | domain names, separated by commas or spaces; sent in DNS wire format (RFC 3397) | `lan, home.arpa` |
| routes | `DESTINATION/LENGTH via ROUTER`, separated by commas (Kea's `DEST - ROUTER` works too); RFC 3442 | `10.20.0.0/16 via 10.0.10.1, 0.0.0.0/0 via 10.0.10.1` |
| hex | bytes in hex, with or without colons | `01:02:0a` |
Values are checked when they are set, and stored in a canonical form:
`10.0.10.2,10.0.10.3` becomes `10.0.10.2, 10.0.10.3`.
Clients that use `classless-static-route` ignore `routers`, so give the
default route in it as well (`0.0.0.0/0 via ROUTER`).
## Options by number
An option the list doesn't have is set by its number and type,
`CODE:TYPE=VALUE`; without a type the value is hex:
```sh
steward subnet update office --option 224:string=hello --option 225=01:02
```
In the API, give `{"option": "224", "type": "string", "value": "hello"}`.
## Options Steward sets itself
These can't be set; Steward fills them in (`option_reserved` if you try):
| Code | Why |
|---:|---|
| 0 | pad |
| 1 | the subnet mask comes from the subnet's prefix |
| 12 | set the reservation's host name instead |
| 50 | only clients send the requested address |
| 51 | set the lease time instead |
| 52 | the server handles option overload itself |
| 53 | the message type is set by the server |
| 54 | the server identifier is the interface's address |
| 55 | only clients send the parameter request list |
| 56 | the server writes its own messages |
| 57 | only clients send the maximum message size |
| 58 | the renewal time is half the lease time |
| 59 | the rebinding time is 7/8 of the lease time |
| 61 | only clients send a client identifier |
| 82 | relay agent information is only added by relays |
| 255 | end |
+56
View File
@@ -0,0 +1,56 @@
# Users, permissions and tokens
## Users
**Administrators** may do everything, including managing users. Everyone
else gets **grants**, `resource:access`:
| Resource | read | write |
|---|---|---|
| `config` | settings, subnets, reservations, history, interfaces | change them, undo, import |
| `leases` | leases and DHCP activity | delete a lease (making a reservation from one needs `config:write` too) |
| `audit` | the audit log | – |
| `*` | all of the above | |
**Roles** are ready-made sets of grants:
| Role | Grants |
|---|---|
| `viewer` | `config:read leases:read` |
| `operator` | `config:write leases:write audit:read` |
| `leases` | `leases:read`, e.g. for a monitoring script |
```sh
steward user add sam --role operator
steward user add monitor --no-password --role leases # tokens only
steward user update sam --grant config:read --grant leases:write
```
The last administrator can't be removed, demoted or disabled.
## API tokens
A token has its user's permissions, or fewer. A token made with `--role`
or `--grant` can do only that, even if its user can do more:
```sh
steward token create backup-script --role leases --expires 365d
```
The secret is shown once; only its hash is stored. A restricted token can
only create tokens with a subset of its own grants. `steward token list`
shows when each token was last used, and from where. `steward token revoke
ID` stops one at once.
## The local socket
On the server, `/run/steward/steward.sock` lets root and the `steward`
user act as administrator without a token. The audit log shows them by
their system user name. Both can already read the database, so this adds
convenience, not power. Anyone else on the socket needs a token.
## The audit log
Logins (and failed ones), users, tokens, every configuration change (with
its history entry), lease deletions and imports are recorded with who, from
where and how: `session`, `token` or `socket`.
+155
View File
@@ -0,0 +1,155 @@
# Subnets, pools, reservations and leases
## Subnets and interfaces
A subnet is an IPv4 network Steward hands addresses out in, served on
one network interface:
```sh
steward subnet add 10.0.10.0/24 --interface eth0 --name office \
--pool 10.0.10.100-10.0.10.199 --option routers=10.0.10.1
```
**The interface decides.** stewardd opens one socket on port 67 per
interface that has a subnet, bound to that interface. A request is
answered only from the subnets of the interface it came in on. A client on
`eth0.20` never gets an `eth0` address, even if it asks for one. It gets a
NAK, then an address of its own VLAN.
For VLANs, give the server an interface on each, such as `eth0.20`, or a
bridge such as `br-iot`, each with an address in its subnet. That address
is the server identifier clients see, and the one they renew their lease
with. `steward interface list` shows the server's interfaces with their
addresses and subnets.
An interface that doesn't exist yet is fine. The subnet gets a warning
(`interface_unknown`), and stewardd starts serving it within 30 seconds of
the interface appearing. `steward status` and the dashboard show each
interface's state; when one isn't served, they say why.
**Several subnets on one interface** form a shared network: two networks
on the same wire, the server with an address in each. Addresses come from
the first subnet's pools, then the next. Clients of every subnet are
answered from the interface's address in their own subnet, or its first
address.
A subnet can be **disabled**: its configuration and leases stay, but its
clients get no answers (`steward subnet disable NAME`).
## Pools
Pools are the ranges handed out to any client: `FIRST-LAST`, or a prefix,
`10.0.10.128/25`. A subnet may have several pools, or none if only clients
with a reservation should get addresses. Pools must be inside the subnet,
must not overlap, and can't include the network or broadcast address.
Pool sizes are shown as the addresses that can really be handed out.
Reserved addresses inside a pool don't count, since nobody else gets them.
## Reservations
A reservation fixes a client's address:
```sh
steward reservation add 10.0.10.20 aa:bb:cc:dd:ee:01 --hostname nas
steward lease reserve 10.0.10.57 # the address a client has now
```
* **The client** is known by its MAC address, or by its client identifier
(option 61) when the reservation has one (`--client-id`; give `-` as the
MAC). Some devices change their identifier but not their MAC address,
and others the other way round. When unsure, use the MAC address.
* **The address picks the subnet**: the subnet whose network holds it. It
may be inside a pool or outside one; either way, nobody else gets it.
* **The host name** is sent to the client (option 12).
* **Options** set on the reservation are for that client only, and win
over the subnet's and the settings'.
* **A device on two VLANs** can have a reservation in each subnet. Within
one subnet, a client has at most one.
A client with a reservation always gets that address, not a pool address.
If it asks for another one, for example after the reservation was added,
it gets a NAK that names its reserved address, and then the reservation.
## Options
Options are set at three levels. A reservation's win over its subnet's,
and a subnet's over the settings' (the options every subnet gets):
```sh
steward settings set --option domain-name-servers=10.0.10.2 --option domain-name=lan
steward subnet update office --option routers=10.0.10.1 --option ntp-servers=10.0.10.2
steward reservation update nas --option domain-name-servers=1.1.1.1
```
`--option NAME=VALUE` sets one and keeps the others; `--unset-option NAME`
removes one. [options.md](options.md) lists the names, and how to write
each kind of value. Any other option can be set by number, with its type:
`--option 224:string=hello`.
Every reply carries the subnet mask, the server identifier and, for a
lease, the lease time and the times to renew (half of it) and rebind (7/8
of it). With a parameter request list (most clients send one), the client
gets the options it asked for, in its order. The subnet mask, routers, DNS
servers, domain name and host name are sent even if it didn't ask. Without
a list, it gets every option set for it.
## Lease times
The settings have the default (12 hours, `steward settings set
--lease-time 1d`); a subnet can have its own (`--lease-time 1h`). Clients
renew halfway through. Short times let changes reach clients sooner;
long ones keep clients working longer if Steward is down.
## How an address is chosen
For a DISCOVER, Steward offers, in this order:
1. the client's reservation in a subnet of the interface;
2. the address the client had before, from its last lease, even if that
ended, as long as it is still free and in a pool;
3. the address the client asks for (option 50), if it is free and in a
pool;
4. a free pool address: one never handed out before, else the one that has
been free the longest.
An address is free when no other client has an active lease on it, it
isn't held back, and it isn't offered to someone else in the last minute.
The server's own addresses are never offered. A fresh pool address is
pinged first: if something answers, it is held back and the next one is
tried.
When the client confirms with a REQUEST, the lease is saved, and only
then is the ACK sent.
## Leases
| State | Meaning |
|---|---|
| active | the client holds the address until the lease ends |
| expired | the lease ran out; the client gets the address back if it returns while it is free |
| released | the client gave the address back |
| held back | a client declined the address because another device answered for it, or it answered Steward's ping. It isn't handed out until the hold ends (a day; `--decline-hold`) |
`steward lease list` shows active and held-back leases; `--all` shows
the others too. Leases that ended more than 30 days ago are deleted.
`steward lease delete IP` frees an address at once. A client still using
it gets a NAK when it next renews, then asks for a new address.
## Authority
Steward is **authoritative** for its interfaces. A client that asks to
keep an address that doesn't belong on its interface, or that someone
else holds, gets a NAK and starts over at once, rather than keeping a
wrong address until its lease runs out. Turn this off (`steward settings
set --authoritative off`) only when another DHCP server serves the same
network. Requests for addresses Steward knows nothing about then get no
answer.
## Not in 0.1
Relayed requests (with `giaddr`) are ignored and logged once an hour: a
subnet must be on one of the server's own interfaces. DHCPv6, failover,
client classes and DNS names through Jarl are planned
([design.md](design.md)).
+65
View File
@@ -0,0 +1,65 @@
# Validation
Every change is checked as a whole configuration before it is saved. An
**error** stops the change; a **warning** is reported with it, and stays
on the dashboard and in `steward status` until fixed. Each problem has a
stable code, shown by the CLI as `[code]` and in the API as
`problems[].code`. `stewardd check` runs the checks on the saved
configuration.
The warnings marked *host* compare the configuration with this machine's
interfaces. They come from stewardd, not from a configuration on its own.
## Settings
| Code | | When |
|---|---|---|
| `lease_time_range` | error | a lease time (settings or subnet) under 1 minute or over 365 days |
| `ping_timeout_range` | error | the ping timeout isn't 50 to 5000 ms |
| `decline_hold_range` | error | the decline hold isn't 1 minute to 30 days |
## Options
These apply to options anywhere (settings, subnets, reservations).
| Code | | When |
|---|---|---|
| `option_code` | error | a number outside 1 to 254 |
| `option_reserved` | error | an option Steward sets itself, such as the lease time or the subnet mask ([options.md](options.md#options-steward-sets-itself)) |
| `option_duplicate` | error | the same option twice in one place |
| `option_invalid` | error | a value that doesn't fit the option's type (`dns.example` for `domain-name-servers`) |
## Subnets
| Code | | When |
|---|---|---|
| `subnet_prefix` | error | not an IPv4 network, or host bits set (`10.0.10.5/24`; the message gives the network) |
| `subnet_size` | error | shorter than /8 or longer than /30 |
| `subnet_overlap` | error | it overlaps another subnet |
| `subnet_name` | error | the name has spaces, slashes or commas |
| `subnet_name_taken` | error | another subnet has the name (names are compared without case) |
| `subnet_interface_missing` | error | no interface given |
| `subnet_interface_name` | error | not an interface name (over 15 characters, or spaces, `/` or `:`) |
| `subnet_no_pools` | warning | no pools: only clients with a reservation get addresses |
| `pool_invalid` | error | a pool that ends before it starts |
| `pool_outside_subnet` | error | a pool not inside the subnet |
| `pool_network_address` | error | a pool with the network or broadcast address |
| `pool_overlap` | error | two pools overlap |
| `router_outside_subnet` | warning | a `routers` address outside the subnet: clients can't reach it |
| `router_in_pool` | warning | a `routers` address inside a pool. The ping check keeps it from being handed out while it answers, but take it out of the pool |
| `interface_unknown` | warning, host | the interface doesn't exist on this machine. It is served once it appears |
| `interface_no_address` | warning, host | the interface has no address in the subnet. Clients renew with that address, so give it one |
| `pool_includes_server` | warning, host | a pool has the server's own address. It is never handed out, but take it out of the pool |
## Reservations
| Code | | When |
|---|---|---|
| `reservation_no_client` | error | neither a MAC address nor a client identifier |
| `reservation_ip` | error | not an IPv4 address |
| `reservation_no_subnet` | error | the address is in no subnet; add the subnet first |
| `reservation_network_address` | error | the network or broadcast address of its subnet |
| `reservation_ip_taken` | error | the address is reserved already |
| `reservation_client_taken` | error | the client has a reservation in this subnet already (one per subnet; a device on two VLANs may have one in each) |
| `reservation_hostname` | error | not a host name (letters, digits and `-`, labels up to 63 characters) |
| `reservation_server_address` | warning, host | the server's own address |