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
+137
@@ -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
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
@@ -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
@@ -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.
|
||||
@@ -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
@@ -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 |
|
||||
@@ -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
@@ -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)).
|
||||
@@ -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 |
|
||||
Reference in new issue
Block a user