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

+69
View File
@@ -0,0 +1,69 @@
# Builds and publishes a release when a version tag (v0.2.0, v1.0.0-rc.1)
# is pushed:
# * a Gitea release with the CHANGELOG notes, the linux tarballs, the CLI
# for Linux, macOS and Windows, the .debs and SHA256SUMS;
# * the .debs (amd64, arm64) in the owner's Debian package registry:
# distribution "stable", or "testing" for a pre-release (a version
# with a hyphen), which Gitea also marks as a pre-release.
#
# Needs a repository secret RELEASE_TOKEN: an access token of a user who
# can write to the repository, with the write:repository and write:package
# scopes. docs/development.md ("Releasing") has the steps.
name: release
on:
push:
tags: ["v*"]
jobs:
release:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
- name: Check the tag against VERSION and CHANGELOG.md
run: |
version=$(cat VERSION)
if [ "$GITHUB_REF_NAME" != "v$version" ]; then
echo "::error::tag $GITHUB_REF_NAME does not match VERSION ($version)"
exit 1
fi
# The release notes (a pre-release may use its final version's
# section or Unreleased; see scripts/gitea-release.sh).
if ! scripts/gitea-release.sh notes > /dev/null; then
echo "::error::no release notes for $version in CHANGELOG.md"
exit 1
fi
- name: Tools
run: |
missing=
for t in jq dpkg-deb curl; do command -v $t >/dev/null || missing="$missing $t"; done
if [ -n "$missing" ]; then
sudo=; [ "$(id -u)" = 0 ] || sudo=sudo
$sudo apt-get update -qq && $sudo apt-get install -y -qq jq dpkg curl
fi
- name: Test
run: make test
- name: Build
env:
# Checked against VERSION above; don't depend on the checkout
# having the tag.
RELEASE_TAG: ${{ github.ref_name }}
run: |
export DEB_MAINTAINER="$(git log -1 --format='%an <%ae>')"
make dist
make cli-dist
make deb DEB_ARCH=amd64
make deb DEB_ARCH=arm64
- name: Publish
env:
GITEA_TOKEN: ${{ secrets.RELEASE_TOKEN }}
run: scripts/gitea-release.sh
+7
View File
@@ -0,0 +1,7 @@
/bin/
/dist/
*.db
*.db-wal
*.db-shm
/steward
/stewardd
+103
View File
@@ -0,0 +1,103 @@
# Working on Steward
Notes for coding agents (and people) changing this repository. Read this
first, then [docs/design.md](docs/design.md) (how it fits together and
what 0.1 leaves out) and [docs/development.md](docs/development.md)
(layout, tests, releasing).
Steward is a DHCPv4 server: `stewardd` answers DHCP and serves a REST API
and a web UI; `steward` is the CLI; SQLite holds everything. It is the
companion of Jarl (the BIND manager, `../jarl`) and copies its ways:
the same auth, socket and web UI helpers, and the same release process.
When in doubt, do it the way Jarl does. Go module `steward`,
`CGO_ENABLED=0`, Linux only for stewardd.
## Ground rules
* **Configuration changes go through `engine.Apply`** (or `Revert`,
`Replace`). It edits a copy of the state, diffs it, validates the whole
result, saves the objects and a history entry in one transaction, then
hands the new state to the DHCP server. Never write subnets,
reservations or settings to the store from a handler.
* **Leases are not configuration.** `dhcpd` owns them: kept in memory, and
written to the store (`PutLease`) before the ACK is sent. They are not
in the history.
* **Subnets are per interface.** A request is only answered from the
subnets of the interface it came in on. Keep that true in every code
path.
* **Every user-visible change updates `CHANGELOG.md`** under
`## [Unreleased]`, and the matching `docs/` page in the same change.
New validation codes get a row in `docs/validation.md`; new options go
in `internal/dhcp4/catalog.go`; then run `python3 scripts/optionsdoc.py`
to regenerate `docs/options.md`.
* **Errors** say what is wrong and how to fix it, naming the object.
* **Schema changes** append a migration to `internal/store/store.go`;
never edit a shipped one.
* `make test` runs `go vet` and a `gofmt -l` check. `goimports` isn't
installed, so fix imports by hand.
## Testing
```sh
make test # unit tests: the protocol through dhcpd.Server.Handle with fakes, API via httptest
make e2e # real udhcpc and dhcpcd clients against stewardd in Docker (needs Docker)
```
* **Docker** on this machine needs `DOCKER_HOST=unix:///var/run/docker.sock`.
The e2e test prints `RESULT: N passed, 0 failed`; run it after any change
to `dhcpd`, `engine`, `server` or the CLI.
* **Port 67 needs `CAP_NET_BIND_SERVICE`.** A stewardd run by hand shows
its interfaces as "not serving: permission denied", but the API and web
UI work, which is fine for UI work. Use a scratch config with
`listen: ["127.0.0.1:28067"]` and `socket: /run/user/$UID/...`; socket
paths are limited to 107 bytes.
* **The systemd sandbox is only tested by a package test**: run `make
deb`, install it in a `debian:trixie` container running systemd
(`--privileged --cgroupns=host /sbin/init`) on a Docker network, and run
a client container on that network.
* **The web UI** has no JS tests. Bundle `assets/app.js` with esbuild to
catch syntax and import errors, and look at it in headless Chrome.
## Web UI rules
As in Jarl:
* Build DOM only with `h()` from `ui.js`, never `innerHTML`.
* The CSP is `self` only: no inline styles or scripts. Use a class, or set
`el.style.prop`.
* Rebuild stewardd after editing, since the files are embedded.
* Function over polish. Leave pick-list checkboxes unticked by default.
## DHCP facts worth knowing
* Replies to clients without an address are broadcast on the interface
(255.255.255.255:68 through the interface's socket). That works with
every client and needs no raw socket. Renewals (`ciaddr` set) are
unicast.
* `SO_BINDTODEVICE` needs no capability since Linux 5.7. The ping check
uses an unprivileged ICMP socket, allowed by
`net.ipv4.ping_group_range`.
* Renewal REQUESTs often have no host name: keep the lease's previous one.
* dhcpcd sends a DUID-based client identifier, busybox udhcpc none.
Leases match by client ID when both sides have one, else by MAC address.
* A container's interface names follow the order the networks were
attached in (eth0 first).
## Git and releases
The maintainer's rules:
* **Commit only when asked. Never push.** The maintainer pushes, to Gitea
at gitea.adbraun.lol.
* **Branches** are `dev-X.Y.Z`, never named like a tag.
* **A release** is a commit "Release X": it sets `VERSION`, updates the
README status line, and (for a final release) moves `[Unreleased]` to
`## [X] - YYYY-MM-DD`. The annotated tag is `vX` with message
"Steward X".
## Shell hygiene
* **Never kill processes by pattern** (`pkill -f`). The pattern matches
the shell running it. Find the PID with `ps` and kill that.
* **Run long-lived test servers in the background mode of your tool**, not
with `&` in a shell that exits.
+93
View File
@@ -0,0 +1,93 @@
# Changelog
All notable changes to Steward are documented here. The format follows
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and versions follow
[Semantic Versioning](https://semver.org/).
## [Unreleased]
The first version of Steward, a DHCPv4 server with a web UI, a REST API
and a CLI, built the way Jarl is.
### Added
- **DHCPv4 server** (`stewardd serve`):
- Answers DISCOVER, REQUEST (selecting, init-reboot, renewing,
rebinding), DECLINE, RELEASE and INFORM, as RFC 2131 describes.
- Serves each subnet on its own network interface, with a socket per
interface bound to it, so a VLAN's clients only get that VLAN's
addresses.
- Several subnets on one interface are served together, as a shared
network.
- Opens an interface's socket when the interface appears, such as a
VLAN that comes up later, and closes it when its last subnet goes.
- **Address choice**, in this order:
1. the client's reservation;
2. the address it had before;
3. the address it asks for;
4. a pool address never handed out, else the one free the longest.
- **Reserved addresses** are never handed out to anyone else, even inside
a pool.
- **Leases** are kept in SQLite and saved before the ACK is sent. Ended
ones stay 30 days, so returning clients get their address again.
- **Conflict checks:**
- An address is pinged before it is offered, and one that answers is
held back.
- An address a client declines is held back for a day.
- Both use the `decline_hold` setting.
- **Authority:** a request for an address that doesn't belong on the
interface gets a NAK, so the client starts over at once. This can be
turned off when another server shares a network.
- **Options:**
- 27 by name (`routers`, `domain-name-servers`, `domain-search`,
`ntp-servers`, `classless-static-route`, …), and any other by number
with a type.
- Set globally, per subnet and per reservation, the most specific
winning.
- When a client sends a parameter request list, it gets what it asked
for, in that order.
- **Configuration changes:**
- Validated as a whole, with stable problem codes (docs/validation.md).
- Saved with a history entry, and can be undone after a check that
nothing changed since.
- `?dry_run=1` (or `steward -n`) previews a change.
- **REST API** (docs/api.md):
- users, sessions with CSRF protection, and API tokens limited by role
or grant (`config`, `leases`, `audit`);
- an audit log;
- a local unix socket where root needs no token.
- **CLI** `steward` (docs/cli.md):
- commands: status, subnet, reservation, lease, activity, settings,
option, interface, history (with undo), import kea, user, token,
password, audit, config;
- output as tables, JSON, YAML or IDs.
- **Web UI:**
- a dashboard of interfaces, pool use, problems and recent messages;
- subnets with their leases, reservations and settings;
- leases, with reserve and delete;
- reservations;
- live DHCP activity;
- settings and global options;
- history with undo, the audit log, users and tokens.
- A new subnet suggests its network and a pool from the interface's
address.
- **Kea import:** `steward import kea` (`POST /import/kea`) reads a
`kea-dhcp4.conf` and its lease files (docs/kea.md):
- global, shared-network and subnet options, pools, lease times and
reservations;
- each subnet's interface: Kea's, else the host's interface with an
address in the subnet, else `--interface PREFIX=IFACE`.
- It lists what it doesn't support instead of dropping it silently.
- Importing again keeps subnets' and reservations' IDs and Steward's
subnet names.
- **`stewardd`** commands: `init`, `user add|password|token`, `check`,
`backup` (with pruning), `restore`, `config --example`.
- **Packaging:**
- a sandboxed systemd unit (`steward.service`, also `stewardd.service`)
whose only privilege is `CAP_NET_BIND_SERVICE`;
- a daily backup timer, and sysusers and tmpfiles files;
- Debian packages `steward` and `steward-cli`, and a Gitea release
workflow.
- **The Steward logo:** a key whose bow is a crown with Jarl's network
nodes, in Jarl's blue and white, with a STEWARD wordmark in Jarl's
letters.
+8
View File
@@ -0,0 +1,8 @@
@AGENTS.md
## Claude Code specifics
- End commit messages with a `Co-Authored-By:` trailer naming the model,
e.g. `Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>`.
- Keep temporary scripts, screenshots and test
servers' work directories in the session scratchpad, not in the repo.
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 abrown38
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+145
View File
@@ -0,0 +1,145 @@
# Steward build and install.
#
# make build bin/stewardd and bin/steward (and their bash completion)
# make test vet, gofmt check and unit tests
# make e2e end-to-end test with real DHCP clients in Docker (scripts/e2e.sh)
# sudo make install install binaries, bash completion, systemd units,
# sysusers/tmpfiles files and an example /etc/steward/steward.yaml
# make deb Debian packages in dist/ (DEB_ARCH=arm64 for another
# architecture): steward (the server) and steward-cli (the CLI,
# which steward depends on); 'sudo apt install ./dist/steward*_amd64.deb'
# make dist static binaries for linux/amd64 and linux/arm64 in dist/
# make cli-dist the CLI alone for Linux, macOS and Windows in dist/
# (steward-cli-<version>-<os>-<arch>[.exe])
VERSION ?= $(shell cat VERSION 2>/dev/null || echo 0.0.0-dev)
# RELEASE_TAG marks a build of a release (the web UI then shows and links
# the release rather than the commit): set when HEAD is tagged v$(VERSION)
# and no tracked file is changed.
RELEASE_TAG ?= $(shell t=v$(VERSION); git tag --points-at HEAD 2>/dev/null | grep -qx "$$t" && git diff --quiet HEAD 2>/dev/null && echo $$t)
# DESCRIBE places other builds relative to the last tag ("3 commits after
# v0.2.0-rc.1").
DESCRIBE ?= $(shell git describe --tags --long --match 'v[0-9]*' --abbrev=12 HEAD 2>/dev/null)
LDFLAGS := -s -w -X steward/internal/version.Version=$(VERSION) -X steward/internal/version.Tag=$(RELEASE_TAG) \
-X steward/internal/version.Describe=$(DESCRIBE)
GOFLAGS := -trimpath
export CGO_ENABLED := 0
PREFIX ?= /usr/local
BINDIR ?= $(PREFIX)/bin
DOCDIR ?= $(PREFIX)/share/doc/steward
# bash-completion also looks in /usr/local/share (XDG_DATA_DIRS).
BASHCOMPDIR ?= $(PREFIX)/share/bash-completion/completions
UNITDIR ?= /etc/systemd/system
SYSUSERSDIR ?= /usr/lib/sysusers.d
TMPFILESDIR ?= /usr/lib/tmpfiles.d
DESTDIR ?=
# The binaries install-files copies (make deb points these at other
# architectures' builds).
STEWARDD_BIN ?= bin/stewardd
STEWARD_BIN ?= bin/steward
# make deb: the package's architecture (Debian name) and version. A
# release is VERSION with ~ for - (0.2.0-rc.1 -> 0.2.0~rc.1, which apt
# sorts before 0.2.0). A development build is versioned after the last
# tag: 3 commits past v0.2.0-rc.1 is 0.2.0~rc.1+git3.gca0f148, which apt
# sorts after that RC and before 0.2.0, so installing it upgrades without
# --reinstall. Uncommitted changes add the build time (.m20261009153000),
# so each such build is newer than the last.
DEB_ARCH ?= $(shell dpkg --print-architecture 2>/dev/null || go env GOARCH)
DEB_DEV_VERSION := $(shell [ -n '$(DESCRIBE)' ] || exit 0; \
m=; git diff --quiet HEAD 2>/dev/null || m=.m$$(date -u +%Y%m%d%H%M%S); \
echo '$(DESCRIBE)' | sed -E 's/^v(.*)-([0-9]+)-g([0-9a-f]{7})[0-9a-f]*$$/\1+git\2.g\3/' | tr - '~' | sed "s/$$/$$m/")
DEB_VERSION ?= $(if $(RELEASE_TAG),$(subst -,~,$(VERSION)),$(or $(DEB_DEV_VERSION),$(subst -,~,$(VERSION))))
.PHONY: all build completion test vet e2e install install-files uninstall deb dist cli-dist clean
all: build
build:
go build $(GOFLAGS) -ldflags '$(LDFLAGS)' -o bin/stewardd ./cmd/stewardd
go build $(GOFLAGS) -ldflags '$(LDFLAGS)' -o bin/steward ./cmd/steward
$(MAKE) --no-print-directory completion
# Bash completion scripts, generated by the binaries themselves.
completion:
@mkdir -p bin/completion
bin/steward completion bash > bin/completion/steward
bin/stewardd completion bash > bin/completion/stewardd
vet:
go vet ./...
@test -z "$$(gofmt -l cmd internal)" || { echo "gofmt needed:"; gofmt -l cmd internal; exit 1; }
test: vet
go test ./...
e2e:
scripts/e2e.sh
install: build install-files
@if [ ! -e $(DESTDIR)/etc/steward/steward.yaml ]; then \
install -d -m 0750 $(DESTDIR)/etc/steward; \
bin/stewardd config --example > $(DESTDIR)/etc/steward/steward.yaml; \
chmod 0640 $(DESTDIR)/etc/steward/steward.yaml; \
echo "wrote $(DESTDIR)/etc/steward/steward.yaml"; \
fi
@echo
@echo "Next (as root): systemd-sysusers steward.conf && systemd-tmpfiles --create steward.conf"
@echo " chgrp steward /etc/steward /etc/steward/steward.yaml && systemctl daemon-reload"
@echo "Then follow docs/install.md (init, start, add subnets or import Kea), and turn on the daily"
@echo "database backup: systemctl enable --now steward-backup.timer"
# Everything but /etc/steward/steward.yaml (shared by install and deb).
install-files:
install -D -m 0755 $(STEWARDD_BIN) $(DESTDIR)$(BINDIR)/stewardd
install -D -m 0755 $(STEWARD_BIN) $(DESTDIR)$(BINDIR)/steward
install -D -m 0644 bin/completion/steward $(DESTDIR)$(BASHCOMPDIR)/steward
install -D -m 0644 bin/completion/stewardd $(DESTDIR)$(BASHCOMPDIR)/stewardd
for u in steward.service steward-backup.service steward-backup.timer; do \
install -D -m 0644 deploy/systemd/$$u $(DESTDIR)$(UNITDIR)/$$u && \
sed -i 's#/usr/local/bin/stewardd#$(BINDIR)/stewardd#; s#/usr/local/share/doc/steward#$(DOCDIR)#' $(DESTDIR)$(UNITDIR)/$$u || exit 1; \
done
install -D -m 0644 deploy/sysusers.d/steward.conf $(DESTDIR)$(SYSUSERSDIR)/steward.conf
install -D -m 0644 deploy/tmpfiles.d/steward.conf $(DESTDIR)$(TMPFILESDIR)/steward.conf
install -D -m 0644 README.md $(DESTDIR)$(DOCDIR)/README.md
install -D -m 0644 LICENSE $(DESTDIR)$(DOCDIR)/LICENSE
install -D -m 0644 CHANGELOG.md $(DESTDIR)$(DOCDIR)/CHANGELOG.md
install -d $(DESTDIR)$(DOCDIR)/docs
install -m 0644 docs/*.md $(DESTDIR)$(DOCDIR)/docs/
uninstall:
rm -f $(DESTDIR)$(BINDIR)/stewardd $(DESTDIR)$(BINDIR)/steward $(DESTDIR)$(UNITDIR)/steward.service \
$(DESTDIR)$(UNITDIR)/stewardd.service \
$(DESTDIR)$(UNITDIR)/steward-backup.service $(DESTDIR)$(UNITDIR)/steward-backup.timer \
$(DESTDIR)$(BASHCOMPDIR)/steward $(DESTDIR)$(BASHCOMPDIR)/stewardd \
$(DESTDIR)$(SYSUSERSDIR)/steward.conf $(DESTDIR)$(TMPFILESDIR)/steward.conf
rm -rf $(DESTDIR)$(DOCDIR)
@echo "Kept /etc/steward, /var/lib/steward and /var/backups/steward."
# A Debian package, built with dpkg-deb (no root needed). The package's
# scripts are in deploy/debian; scripts/mkdeb.sh explains the layout.
deb: build
DEB_ARCH='$(DEB_ARCH)' DEB_VERSION='$(DEB_VERSION)' VERSION='$(VERSION)' \
LDFLAGS='$(LDFLAGS)' GOFLAGS='$(GOFLAGS)' scripts/mkdeb.sh
dist: build
for arch in amd64 arm64; do \
GOOS=linux GOARCH=$$arch go build $(GOFLAGS) -ldflags '$(LDFLAGS)' -o dist/steward-$(VERSION)-linux-$$arch/stewardd ./cmd/stewardd && \
GOOS=linux GOARCH=$$arch go build $(GOFLAGS) -ldflags '$(LDFLAGS)' -o dist/steward-$(VERSION)-linux-$$arch/steward ./cmd/steward && \
tar -C dist -czf dist/steward-$(VERSION)-linux-$$arch.tar.gz steward-$(VERSION)-linux-$$arch || exit 1; \
done
# The platforms cli-dist builds the CLI for.
CLI_PLATFORMS ?= linux/amd64 linux/arm64 darwin/amd64 darwin/arm64 windows/amd64 windows/arm64
cli-dist:
@mkdir -p dist
for p in $(CLI_PLATFORMS); do \
os=$${p%/*} arch=$${p#*/} ext=; [ $$os = windows ] && ext=.exe; \
GOOS=$$os GOARCH=$$arch go build $(GOFLAGS) -ldflags '$(LDFLAGS)' -o dist/steward-cli-$(VERSION)-$$os-$$arch$$ext ./cmd/steward || exit 1; \
done
clean:
rm -rf bin dist
+103
View File
@@ -0,0 +1,103 @@
<p align="center"><img src="artwork/logo.svg" width="140" alt="Steward"></p>
# Steward
Steward is a DHCPv4 server you configure through a web UI, a REST API and
a command line client, all three backed by the same API. It is the
companion of [Jarl](https://gitea.adbraun.lol/adam/jarl), the BIND manager,
and works the same way: every change is checked as a whole before it is
used, is recorded in a history where it can be undone, and is audited.
In Skyrim, the Jarl's steward runs the hold's daily business, keeping the
keys among other things. Steward hands out the hold's addresses.
## What it does
* **Subnets per interface.** Each subnet names the network interface it is
served on (`eth0`, `eth0.20`, `br-iot`). Clients only get addresses from
the subnets of the interface their request came in on, so each VLAN gets
its own addresses, whatever its clients ask for.
* **Pools, reservations and options.** Pools are ranges handed out to any
client. Reservations fix a client's address, by MAC address or client
identifier, optionally with a host name and options of its own. Options
can be set for every subnet, per subnet and per reservation; the most
specific wins. The common options have names (`routers`,
`domain-name-servers`, `domain-search`, `ntp-servers`,
`classless-static-route`, …), and any other can be set by number.
* **Leases that survive.** Leases live in SQLite and are written before the
client gets its answer, so a crash or a restart never hands an address
out twice. A client that comes back gets the address it had.
* **Conflict checks.** An address is pinged before it is offered. One that
answers, or that a client declines, is held back for a day.
* **Changes you can trust.** Each change is validated as a whole, with
errors that say what is wrong and how to fix it. A dry run shows what a
change would do; the history shows every change, field by field, and
undoes any of them.
* **Easy to watch.** The dashboard shows each interface and whether it is
served, how full each subnet's pools are, and any problems. The Activity
page lists the latest DHCP messages, so you can see why a device didn't
get an address.
* **Moving from Kea.** `steward import kea` reads a `kea-dhcp4.conf` and
Kea's lease file, so clients keep their addresses ([docs/kea.md](docs/kea.md)).
* **Users and tokens.** Each user and API token gets permissions: an
administrator, or `config`, `leases` and `audit` access, read or write.
On the server itself, root needs no setup: the CLI uses stewardd's local
socket.
* **One small service.** `stewardd` is one static binary, run by a
sandboxed systemd unit whose only privilege is binding port 67. Debian
packages and a daily backup timer are included.
Not yet: relays (`giaddr`), DHCPv6, failover, client classes, and
registering clients' names in DNS through Jarl ([docs/design.md](docs/design.md)).
## Quick start
On Debian or Ubuntu, from the package registry (see
[docs/install.md](docs/install.md) for other ways):
```sh
sudo apt install steward
sudo -u steward stewardd init # asks for the admin password
sudo systemctl enable --now steward
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 --option domain-name-servers=10.0.10.2
sudo steward settings set --option domain-name=lan
sudo steward reservation add 10.0.10.20 aa:bb:cc:dd:ee:01 --hostname nas
sudo steward status
```
The web UI is on `http://127.0.0.1:8067/` (see `listen` in
`/etc/steward/steward.yaml`).
Coming from Kea:
```sh
sudo steward import kea /etc/kea/kea-dhcp4.conf --leases /var/lib/kea/kea-leases4.csv -n # look first
sudo steward import kea /etc/kea/kea-dhcp4.conf --leases /var/lib/kea/kea-leases4.csv
sudo systemctl disable --now kea-dhcp4-server
```
## Documentation
* [install.md](docs/install.md): packages, building from source, first steps
* [subnets.md](docs/subnets.md): subnets, interfaces, pools, reservations, options, leases, and how addresses are chosen
* [options.md](docs/options.md): the options Steward knows and how to write their values
* [kea.md](docs/kea.md): moving from Kea
* [cli.md](docs/cli.md): the `steward` command line client
* [api.md](docs/api.md): the REST API
* [configuration.md](docs/configuration.md): `/etc/steward/steward.yaml`
* [permissions.md](docs/permissions.md): users, roles, grants and API tokens
* [operations.md](docs/operations.md): backups, logs, upgrades, troubleshooting
* [validation.md](docs/validation.md): every check and what it means
* [design.md](docs/design.md): how Steward is built, and what 0.1 leaves out
* [development.md](docs/development.md): building, testing, releasing
## Status
Steward 0.1.0 is in development.
## Licence
MIT, see [LICENSE](LICENSE).
+1
View File
@@ -0,0 +1 @@
0.1.0-dev
+17
View File
@@ -0,0 +1,17 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="30 22 940 940">
<!-- The Steward logo: a key whose bow is a crown with Jarl's network nodes. The master; scripts/logo.py builds the web UI's copies. -->
<g fill="#fff" stroke="#1d5287" stroke-width="26" stroke-linejoin="miter">
<path d="M452 540 V890 L478 940 H522 L548 890 V540 Z"/>
<path d="M548 690 H688 V742 H640 V782 H712 V832 H664 V890 H548 Z"/>
<path d="M420 450 H580 L606 498 L580 546 H420 L394 498 Z"/>
<path d="M305 490 L245 135 L382 238 L500 45 L618 238 L755 135 L695 490 Q500 445 305 490 Z"/>
</g>
<path d="M287 418 Q500 368 713 418" stroke="#1d5287" stroke-width="22" fill="none"/>
<g stroke="#1d5287" stroke-width="22" fill="none">
<path d="M500 200 V258 M463 312 L414 334 M537 312 L586 334"/>
<circle cx="500" cy="170" r="30" fill="#fff"/>
<circle cx="390" cy="344" r="27" fill="#fff"/>
<circle cx="610" cy="344" r="27" fill="#fff"/>
</g>
<circle cx="500" cy="296" r="40" fill="#1d5287"/>
</svg>

After

Width:  |  Height:  |  Size: 976 B

+700
View File
@@ -0,0 +1,700 @@
package main
import (
"encoding/json"
"fmt"
"io"
"os"
"sort"
"strconv"
"strings"
"github.com/spf13/cobra"
"golang.org/x/term"
"steward/internal/api"
"steward/internal/model"
)
func statusCmd() *cobra.Command {
return &cobra.Command{
Use: "status", Short: "Show what Steward serves, and any problems", Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
var st api.Status
if err := c.Get(ctx(), "/status", nil, &st); err != nil {
return err
}
return output(st, func(w io.Writer) {
fmt.Fprintf(w, "Steward:\t%s\n", st.Build.String())
fmt.Fprintf(w, "Running since:\t%s\n", st.Started.Local().Format("2006-01-02 15:04"))
fmt.Fprintln(w, "Interfaces:")
if len(st.Interfaces) == 0 {
fmt.Fprintln(w, " none: add a subnet ('steward subnet add PREFIX --interface IFACE')")
}
for _, i := range st.Interfaces {
state := "serving DHCP"
if !i.Listening {
state = "NOT serving: " + i.Error
}
fmt.Fprintf(w, " %s\t%s\t%s\n", i.Name, strings.Join(i.Addresses, ", "), state)
}
fmt.Fprintln(w, "Subnets:")
for _, s := range st.Subnets {
d := ""
if s.Disabled {
d = " (disabled)"
}
fmt.Fprintf(w, " %s\t%s\t%s leased, %d reserved%s\n", s.Label(), s.Interface, usageText(s.Usage), s.Usage.Reservations, d)
}
var states []string
for k, v := range st.Leases {
states = append(states, fmt.Sprintf("%d %s", v, k))
}
sort.Strings(states)
fmt.Fprintf(w, "Leases:\t%s\n", orDash(strings.Join(states, ", ")))
var msgs []string
for _, t := range []string{"DHCPDISCOVER", "DHCPOFFER", "DHCPREQUEST", "DHCPACK", "DHCPNAK", "DHCPRELEASE", "DHCPDECLINE", "DHCPINFORM"} {
if n := st.Messages[t]; n > 0 {
msgs = append(msgs, fmt.Sprintf("%d %s", n, strings.TrimPrefix(t, "DHCP")))
}
}
fmt.Fprintf(w, "Messages:\t%s\n", orDash(strings.Join(msgs, ", ")))
if st.PingProblem != "" {
fmt.Fprintf(w, "Ping check:\t%s\n", st.PingProblem)
}
if len(st.Problems) > 0 {
fmt.Fprintln(w, "Problems:")
for _, p := range st.Problems {
fmt.Fprintf(w, " %s: %s: %s\n", p.Severity, p.Object, p.Message)
}
}
})
},
}
}
func historyCmd() *cobra.Command {
cmd := &cobra.Command{Use: "history", Short: "The history of configuration changes, and undo"}
var limit int
list := &cobra.Command{
Use: "list", Short: "List recent changes", Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
var hs []model.HistoryEntry
if err := c.Get(ctx(), "/history", q("limit", strconv.Itoa(limit)), &hs); err != nil {
return err
}
return output(hs, func(w io.Writer) {
fmt.Fprintln(w, "ID\tWHEN\tBY\tVIA\tCHANGES\tDESCRIPTION")
for _, h := range hs {
desc := h.Description
if h.RevertedBy != 0 {
desc += fmt.Sprintf(" (undone by %d)", h.RevertedBy)
}
fmt.Fprintf(w, "%d\t%s\t%s\t%s\t%d\t%s\n", h.ID, h.Time.Local().Format("2006-01-02 15:04"), h.Username, h.Via, len(h.Changes), desc)
}
})
},
}
list.Flags().IntVar(&limit, "limit", 30, "how many")
cmd.AddCommand(list)
cmd.AddCommand(&cobra.Command{
Use: "show ID", Short: "Show a change: each object before and after", Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
var h model.HistoryEntry
if err := c.Get(ctx(), "/history/"+args[0], nil, &h); err != nil {
return err
}
return output(h, func(w io.Writer) {
fmt.Fprintf(w, "%d\t%s by %s (%s): %s\n", h.ID, h.Time.Local().Format("2006-01-02 15:04:05"), h.Username, h.Via, h.Description)
for _, ch := range h.Changes {
fmt.Fprintf(w, "\n%s %s %s\n", ch.Action(), ch.Kind, ch.Label)
for _, line := range diffJSON(ch.Before, ch.After) {
fmt.Fprintln(w, " "+line)
}
}
})
},
})
cmd.AddCommand(&cobra.Command{
Use: "undo ID",
Short: "Undo a change",
Long: `Undoes a change by applying it backwards. It is refused when an object the
change touched has changed since; undo the later changes first.`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
var resp api.MutationResponse
if err := c.Do(ctx(), "POST", "/history/"+args[0]+"/revert", nil, nil, &resp); err != nil {
return err
}
return report(&resp)
},
})
return cmd
}
// diffJSON lists the fields that differ between two versions of an
// object: "name: office -> lab".
func diffJSON(before, after json.RawMessage) []string {
var a, b map[string]any
json.Unmarshal(before, &a)
json.Unmarshal(after, &b)
keys := map[string]bool{}
for k := range a {
keys[k] = true
}
for k := range b {
keys[k] = true
}
var names []string
for k := range keys {
names = append(names, k)
}
sort.Strings(names)
var out []string
show := func(v any, ok bool) string {
if !ok {
return "-"
}
x, _ := json.Marshal(v)
return string(x)
}
for _, k := range names {
va, oka := a[k]
vb, okb := b[k]
sa, sb := show(va, oka), show(vb, okb)
if sa == sb {
continue
}
switch {
case before == nil:
out = append(out, k+": "+sb)
case after == nil:
out = append(out, k+": "+sa)
default:
out = append(out, k+": "+sa+" -> "+sb)
}
}
return out
}
func whoamiCmd() *cobra.Command {
return &cobra.Command{
Use: "whoami", Short: "Show who you are to the server", Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
var me api.Whoami
if err := c.Get(ctx(), "/auth/whoami", nil, &me); err != nil {
return err
}
return output(me, func(w io.Writer) {
fmt.Fprintf(w, "User:\t%s (%s)\n", me.User.Username, me.Method)
fmt.Fprintf(w, "Administrator:\t%v\n", me.Admin)
fmt.Fprintf(w, "May:\t%s\n", strings.Join(me.Permissions, ", "))
fmt.Fprintf(w, "Server:\tSteward %s\n", me.Build.String())
})
},
}
}
func readSecret(prompt string) (string, error) {
fmt.Fprint(os.Stderr, prompt)
if term.IsTerminal(int(os.Stdin.Fd())) {
b, err := term.ReadPassword(int(os.Stdin.Fd()))
fmt.Fprintln(os.Stderr)
return strings.TrimSpace(string(b)), err
}
var s string
_, err := fmt.Fscanln(os.Stdin, &s)
return strings.TrimSpace(s), err
}
func userID(name string) (int64, error) {
c, err := newClient()
if err != nil {
return 0, err
}
var us []api.User
if err := c.Get(ctx(), "/users", nil, &us); err != nil {
return 0, err
}
for _, u := range us {
if strings.EqualFold(u.Username, name) || strconv.FormatInt(u.ID, 10) == name {
return u.ID, nil
}
}
return 0, fmt.Errorf("no user %s", name)
}
func userCmd() *cobra.Command {
cmd := &cobra.Command{Use: "user", Aliases: []string{"users"}, Short: "Users (administrators only)"}
cmd.AddCommand(&cobra.Command{
Use: "list", Short: "List users", Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
var us []api.User
if err := c.Get(ctx(), "/users", nil, &us); err != nil {
return err
}
return output(us, func(w io.Writer) {
fmt.Fprintln(w, "ID\tUSER\tADMIN\tGRANTS\tPASSWORD\tDISABLED\tLAST LOGIN")
for _, u := range us {
login := "-"
if u.LastLoginAt != nil {
login = fmtWhen(*u.LastLoginAt)
}
fmt.Fprintf(w, "%d\t%s\t%v\t%s\t%v\t%v\t%s\n", u.ID, u.Username, u.Admin, orDash(strings.Join(u.GrantStrings, " ")), u.HasPassword, u.Disabled, login)
}
})
},
})
var admin, noPassword bool
var role, comment string
var grants []string
add := &cobra.Command{
Use: "add USER",
Short: "Add a user (asks for the password)",
Long: `Adds a user. Without --admin, give what they may do: a role (viewer,
operator, leases) or grants such as --grant config:read --grant leases:write.
--no-password makes a service account that only uses API tokens.`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
in := api.UserInput{Username: &args[0], Admin: &admin}
if !noPassword {
pw, err := readSecret("Password for " + args[0] + ": ")
if err != nil {
return err
}
in.Password = &pw
}
if role != "" {
in.Role = &role
}
if grants != nil {
in.Grants = &grants
}
if comment != "" {
in.Comment = &comment
}
var u api.User
if err := c.Do(ctx(), "POST", "/users", nil, in, &u); err != nil {
return err
}
if !flagQuiet {
fmt.Printf("added user %s (%d)\n", u.Username, u.ID)
}
return nil
},
}
f := add.Flags()
f.BoolVar(&admin, "admin", false, "an administrator (may do everything)")
f.StringVar(&role, "role", "", "a role: viewer, operator or leases")
f.StringArrayVar(&grants, "grant", nil, "a grant, resource:access (config, leases, audit; read or write)")
f.BoolVar(&noPassword, "no-password", false, "no password: API tokens only")
f.StringVar(&comment, "comment", "", "a comment")
cmd.AddCommand(add)
var setPassword, disable, enable bool
update := &cobra.Command{
Use: "update USER", Short: "Change a user", Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
id, err := userID(args[0])
if err != nil {
return err
}
c, _ := newClient()
in := api.UserInput{}
fl := cmd.Flags()
if fl.Changed("admin") {
in.Admin = &admin
}
if fl.Changed("role") {
in.Role = &role
}
if fl.Changed("grant") {
in.Grants = &grants
}
if fl.Changed("comment") {
in.Comment = &comment
}
if disable || enable {
in.Disabled = &disable
}
if setPassword {
pw, err := readSecret("New password for " + args[0] + ": ")
if err != nil {
return err
}
in.Password = &pw
}
var u api.User
if err := c.Do(ctx(), "PATCH", "/users/"+strconv.FormatInt(id, 10), nil, in, &u); err != nil {
return err
}
if !flagQuiet {
fmt.Printf("updated user %s\n", u.Username)
}
return nil
},
}
f = update.Flags()
f.BoolVar(&admin, "admin", false, "make (or with --admin=false, unmake) an administrator")
f.StringVar(&role, "role", "", "replace the grants with a role's")
f.StringArrayVar(&grants, "grant", nil, "replace the grants (repeat for several)")
f.BoolVar(&setPassword, "password", false, "set a new password (asks for it)")
f.BoolVar(&disable, "disable", false, "disable the user (logs them out)")
f.BoolVar(&enable, "enable", false, "enable the user again")
f.StringVar(&comment, "comment", "", "a comment")
cmd.AddCommand(update)
cmd.AddCommand(&cobra.Command{
Use: "delete USER", Short: "Delete a user and their tokens", Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
id, err := userID(args[0])
if err != nil {
return err
}
c, _ := newClient()
if err := c.Do(ctx(), "DELETE", "/users/"+strconv.FormatInt(id, 10), nil, nil, nil); err != nil {
return err
}
if !flagQuiet {
fmt.Println("deleted user", args[0])
}
return nil
},
})
return cmd
}
func tokenCmd() *cobra.Command {
cmd := &cobra.Command{Use: "token", Aliases: []string{"tokens"}, Short: "API tokens"}
var all bool
var user string
list := &cobra.Command{
Use: "list", Short: "List your tokens (--all: everyone's, for administrators)", Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
path := "/me/tokens"
if all {
path = "/tokens"
}
var ts []api.Token
if err := c.Get(ctx(), path, nil, &ts); err != nil {
return err
}
return output(ts, func(w io.Writer) {
fmt.Fprintln(w, "ID\tUSER\tNAME\tGRANTS\tEXPIRES\tLAST USED")
for _, t := range ts {
exp, used := "never", "never"
if t.ExpiresAt != nil {
exp = fmtWhen(*t.ExpiresAt)
}
if t.LastUsedAt != nil {
used = fmtWhen(*t.LastUsedAt) + " from " + t.LastUsedIP
}
fmt.Fprintf(w, "%d\t%s\t%s\t%s\t%s\t%s\n", t.ID, t.Username, t.Name, strings.Join(t.GrantStrings, " "), exp, used)
}
})
},
}
list.Flags().BoolVar(&all, "all", false, "everyone's tokens")
cmd.AddCommand(list)
var role, expires string
var grants []string
var save bool
create := &cobra.Command{
Use: "create NAME",
Short: "Make an API token (the secret is shown once)",
Long: `Makes an API token. It has all of its user's permissions unless --role or
--grant narrows it. On the server as root, --user names whose token it is.
--save puts it in the CLI's configuration.`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
path := "/me/tokens"
if user != "" {
id, err := userID(user)
if err != nil {
return err
}
path = "/users/" + strconv.FormatInt(id, 10) + "/tokens"
}
in := api.TokenInput{Name: args[0], Role: role, Grants: grants, ExpiresIn: expires}
var t api.Token
if err := c.Do(ctx(), "POST", path, nil, in, &t); err != nil {
return err
}
if save {
cfg, err := loadCLIConfig()
if err != nil {
return err
}
cfg.Token = t.Secret
if err := saveCLIConfig(cfg); err != nil {
return err
}
fmt.Fprintln(os.Stderr, "saved in", flagConfig)
}
if flagOutput == "json" || flagOutput == "yaml" {
return output(t, nil)
}
fmt.Println(t.Secret)
if !flagQuiet {
fmt.Fprintln(os.Stderr, "this is the only time the token is shown; keep it safe")
}
return nil
},
}
f := create.Flags()
f.StringVar(&role, "role", "", "narrow it to a role: viewer, operator or leases")
f.StringArrayVar(&grants, "grant", nil, "narrow it to grants (resource:access)")
f.StringVar(&expires, "expires", "", "expire after this long (90d, 720h)")
f.StringVar(&user, "user", "", "the user it belongs to (administrators)")
f.BoolVar(&save, "save", false, "save it as this CLI's token")
cmd.AddCommand(create)
cmd.AddCommand(&cobra.Command{
Use: "revoke ID", Short: "Revoke a token", Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
if err := c.Do(ctx(), "DELETE", "/tokens/"+args[0], nil, nil, nil); err != nil {
return err
}
if !flagQuiet {
fmt.Println("revoked token", args[0])
}
return nil
},
})
return cmd
}
func passwordCmd() *cobra.Command {
return &cobra.Command{
Use: "password", Short: "Change your own password", Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
cur, err := readSecret("Current password: ")
if err != nil {
return err
}
pw, err := readSecret("New password: ")
if err != nil {
return err
}
if err := c.Do(ctx(), "POST", "/me/password", nil, api.PasswordChange{Current: cur, Password: pw}, nil); err != nil {
return err
}
fmt.Println("password changed")
return nil
},
}
}
func auditCmd() *cobra.Command {
var user, action, search string
var limit int
cmd := &cobra.Command{
Use: "audit", Short: "Show the audit log: logins, users, tokens, changes, lease deletions", Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
var evs []model.AuditEvent
if err := c.Get(ctx(), "/audit", q("user", user, "action", action, "q", search, "limit", strconv.Itoa(limit)), &evs); err != nil {
return err
}
return output(evs, func(w io.Writer) {
fmt.Fprintln(w, "WHEN\tUSER\tFROM\tACTION\tOBJECT\tRESULT\tDETAIL")
for _, e := range evs {
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\t%s\n", e.Time.Local().Format("2006-01-02 15:04:05"), orDash(e.Username), orDash(e.IP), e.Action, orDash(e.Object), e.Result, e.Detail)
}
})
},
}
f := cmd.Flags()
f.StringVar(&user, "user", "", "only this user's")
f.StringVar(&action, "action", "", "only actions starting with this (auth., lease.delete)")
f.StringVar(&search, "search", "", "only events whose object or detail contains this")
f.IntVar(&limit, "limit", 100, "how many")
return cmd
}
func configCmd() *cobra.Command {
cmd := &cobra.Command{Use: "config", Short: "This CLI's settings: the server and token"}
cmd.AddCommand(&cobra.Command{
Use: "show", Short: "Show the CLI's configuration", Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
cfg, err := loadCLIConfig()
if err != nil {
return err
}
tok := "-"
if cfg.Token != "" {
tok = cfg.Token[:min(len(cfg.Token), 24)] + "…"
}
fmt.Printf("file: %s\nserver: %s\ntoken: %s\ninsecure: %v\nca_cert: %s\n", flagConfig, orDash(cfg.Server)+" (empty: the local socket)", tok, cfg.Insecure, orDash(cfg.CACert))
return nil
},
})
cmd.AddCommand(&cobra.Command{
Use: "set KEY [VALUE]",
Short: "Set server, token (asks when no value), insecure or ca_cert",
Args: cobra.RangeArgs(1, 2),
RunE: func(cmd *cobra.Command, args []string) error {
cfg, err := loadCLIConfig()
if err != nil {
return err
}
val := ""
if len(args) == 2 {
val = args[1]
} else if args[0] == "token" {
if val, err = readSecret("API token: "); err != nil {
return err
}
}
switch args[0] {
case "server":
cfg.Server = val
case "token":
cfg.Token = val
case "insecure":
cfg.Insecure = val == "true" || val == "yes" || val == "on"
case "ca_cert", "ca-cert":
cfg.CACert = val
default:
return fmt.Errorf("unknown setting %q (use server, token, insecure or ca_cert)", args[0])
}
if err := saveCLIConfig(cfg); err != nil {
return err
}
fmt.Fprintln(os.Stderr, "saved in", flagConfig)
return nil
},
})
return cmd
}
func importCmd() *cobra.Command {
cmd := &cobra.Command{Use: "import", Short: "Import another DHCP server's configuration"}
var leases, ifaces []string
var replace bool
kea := &cobra.Command{
Use: "kea CONFIG",
Short: "Import a Kea configuration and its leases",
Long: `Imports a kea-dhcp4.conf: settings, subnets (with pools and options),
shared networks and reservations; and with --leases, Kea's current leases,
so clients keep their addresses. What Steward doesn't support is listed.
Every subnet needs its interface. Kea's own setting is used, else the
interface on this server with an address in the subnet, else the only
interface Kea listened on; give others with --interface PREFIX=IFACE.
steward import kea /etc/kea/kea-dhcp4.conf --leases /var/lib/kea/kea-leases4.csv -n
steward import kea /etc/kea/kea-dhcp4.conf --leases /var/lib/kea/kea-leases4.csv.2 \
--leases /var/lib/kea/kea-leases4.csv
Stop Kea before Steward starts serving: they can't share port 67.`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
b, err := os.ReadFile(args[0])
if err != nil {
return err
}
body := map[string]any{"config": string(b), "replace": replace}
var texts []string
for _, l := range leases {
lb, err := os.ReadFile(l)
if err != nil {
return err
}
texts = append(texts, string(lb))
}
if len(texts) > 0 {
body["leases"] = texts
}
if len(ifaces) > 0 {
m := map[string]string{}
for _, i := range ifaces {
p, name, ok := strings.Cut(i, "=")
if !ok {
return fmt.Errorf("--interface %q: write it as PREFIX=IFACE, e.g. 10.0.20.0/24=eth0.20", i)
}
m[strings.TrimSpace(p)] = strings.TrimSpace(name)
}
body["interfaces"] = m
}
var res api.ImportResult
if err := c.Do(ctx(), "POST", "/import/kea", nil, body, &res); err != nil {
return err
}
if flagOutput == "json" || flagOutput == "yaml" {
return output(res, nil)
}
for _, n := range res.Notes {
fmt.Println("note:", n)
}
for _, s := range res.Skipped {
fmt.Println("not imported:", s)
}
if err := report(&res.MutationResponse); err != nil {
return err
}
switch {
case res.DryRun:
fmt.Printf("would import %d leases\n", res.Leases)
case res.Applied:
fmt.Printf("imported %d leases\n", res.Leases)
}
return nil
},
}
f := kea.Flags()
f.StringArrayVar(&leases, "leases", nil, "a Kea lease file (kea-leases4.csv); give older ones first")
f.StringArrayVar(&ifaces, "interface", nil, "a subnet's interface: PREFIX=IFACE")
f.BoolVar(&replace, "replace", false, "replace Steward's configuration if it has one")
cmd.AddCommand(kea)
return cmd
}
+404
View File
@@ -0,0 +1,404 @@
package main
import (
"fmt"
"io"
"os"
"sort"
"strings"
"time"
"github.com/spf13/cobra"
"steward/internal/api"
"steward/internal/model"
)
func leaseHost(l api.Lease) string {
switch {
case l.ReservedHostname != "" && l.Hostname != "" && l.ReservedHostname != l.Hostname:
return l.ReservedHostname + " (says " + l.Hostname + ")"
case l.ReservedHostname != "":
return l.ReservedHostname
}
return orDash(l.Hostname)
}
func leaseCmd() *cobra.Command {
cmd := &cobra.Command{Use: "lease", Aliases: []string{"leases"}, Short: "Leases: the addresses handed out"}
var subnet, state, iface, search string
var all bool
list := &cobra.Command{
Use: "list",
Short: "List leases (active and declined ones; --all for every one)",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
var ls []api.Lease
if err := c.Get(ctx(), "/leases", q("subnet", subnet, "state", state, "interface", iface, "q", search), &ls); err != nil {
return err
}
if !all && state == "" {
keep := ls[:0]
for _, l := range ls {
if l.State == model.LeaseActive || l.State == model.LeaseDeclined {
keep = append(keep, l)
}
}
ls = keep
}
return output(ls, func(w io.Writer) {
fmt.Fprintln(w, "IP\tMAC\tHOSTNAME\tSTATE\tENDS\tINTERFACE\tSUBNET")
for _, l := range ls {
st := l.State
if l.Reservation != 0 {
st += ", reserved"
}
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\t%s\n", l.IP, orDash(l.MAC), leaseHost(l), st, fmtWhen(l.End), l.Interface, orDash(l.SubnetName))
}
})
},
}
f := list.Flags()
f.StringVar(&subnet, "subnet", "", "only this subnet's (ID, name or prefix)")
f.StringVar(&state, "state", "", "only leases in this state: active, expired, released or declined")
f.StringVar(&iface, "interface", "", "only leases of clients on this interface")
f.StringVar(&search, "search", "", "only leases whose address, MAC address or host name contains this")
f.BoolVarP(&all, "all", "a", false, "also expired and released leases (kept a while so returning clients get their old address)")
cmd.AddCommand(list)
cmd.AddCommand(&cobra.Command{
Use: "show IP", Short: "Show the lease of an address", Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
var l api.Lease
if err := c.Get(ctx(), "/leases/"+args[0], nil, &l); err != nil {
return err
}
return output(l, func(w io.Writer) {
fmt.Fprintf(w, "Address:\t%s (subnet %s)\n", l.IP, orDash(l.SubnetName))
fmt.Fprintf(w, "State:\t%s\n", l.State)
fmt.Fprintf(w, "MAC address:\t%s\n", orDash(l.MAC))
if l.ClientID != "" {
fmt.Fprintf(w, "Client ID:\t%s\n", l.ClientID)
}
fmt.Fprintf(w, "Host name:\t%s\n", leaseHost(l))
fmt.Fprintf(w, "Since:\t%s\n", l.Start.Local().Format("2006-01-02 15:04:05"))
fmt.Fprintf(w, "Ends:\t%s (%s)\n", l.End.Local().Format("2006-01-02 15:04:05"), fmtWhen(l.End))
fmt.Fprintf(w, "Interface:\t%s\n", l.Interface)
if l.Reservation != 0 {
fmt.Fprintf(w, "Reservation:\t%d\n", l.Reservation)
}
})
},
})
var yes bool
del := &cobra.Command{
Use: "delete IP",
Short: "Forget a lease, freeing the address",
Long: `Forgets a lease: the address is free at once. A client still using it
gets a NAK when it next renews, and then asks for a new address.`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
if !confirm("Delete the lease of "+args[0]+"?", yes) {
return fmt.Errorf("not deleted")
}
c, err := newClient()
if err != nil {
return err
}
if err := c.Do(ctx(), "DELETE", "/leases/"+args[0], nil, nil, nil); err != nil {
return err
}
if !flagQuiet {
fmt.Println("deleted the lease of", args[0])
}
return nil
},
}
del.Flags().BoolVarP(&yes, "yes", "y", false, "don't ask")
cmd.AddCommand(del)
var hostname, comment string
reserve := &cobra.Command{
Use: "reserve IP",
Short: "Make a lease's address its client's reservation",
Long: `Makes the address a client has now its reservation: it keeps it from
then on. The host name the client sent becomes the reservation's, unless
--hostname gives another.`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
body := map[string]any{}
if cmd.Flags().Changed("hostname") {
body["hostname"] = hostname
}
if comment != "" {
body["comment"] = comment
}
var resp api.MutationResponse
if err := c.Do(ctx(), "POST", "/leases/"+args[0]+"/reserve", nil, body, &resp); err != nil {
return err
}
return report(&resp)
},
}
reserve.Flags().StringVar(&hostname, "hostname", "", "the reservation's host name")
reserve.Flags().StringVar(&comment, "comment", "", "a comment")
cmd.AddCommand(reserve)
return cmd
}
func activityCmd() *cobra.Command {
var mac, iface string
var limit int
var follow bool
cmd := &cobra.Command{
Use: "activity",
Short: "Show recent DHCP messages (requests and answers)",
Long: `Shows the latest DHCP messages Steward received and sent, newest last.
-f keeps watching. Handy to see why a device doesn't get an address:
steward activity --mac aa:bb:cc:dd:ee:01 -f`,
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
var last time.Time
tw := func(evs []api.Event) {
sort.Slice(evs, func(i, j int) bool { return evs[i].Time.Before(evs[j].Time) })
for _, e := range evs {
if !e.Time.After(last) {
continue
}
last = e.Time
detail := ""
if e.Hostname != "" {
detail = " " + e.Hostname
}
if e.Detail != "" {
detail += " (" + e.Detail + ")"
}
fmt.Printf("%s %-8s %-13s %-17s %-15s%s\n", e.Time.Local().Format("15:04:05.000"), e.Interface, e.Type, orDash(e.MAC), orDash(e.IP), detail)
}
}
for {
var evs []api.Event
if err := c.Get(ctx(), "/events", q("mac", mac, "interface", iface, "limit", fmt.Sprint(limit)), &evs); err != nil {
return err
}
if flagOutput == "json" || flagOutput == "yaml" {
return output(evs, nil)
}
tw(evs)
if !follow {
if len(evs) == 0 {
fmt.Fprintln(os.Stderr, "no DHCP messages yet")
}
return nil
}
time.Sleep(2 * time.Second)
}
},
}
f := cmd.Flags()
f.StringVar(&mac, "mac", "", "only this client's")
f.StringVar(&iface, "interface", "", "only this interface's")
f.IntVar(&limit, "limit", 50, "at most this many")
f.BoolVarP(&follow, "follow", "f", false, "keep watching")
return cmd
}
func settingsCmd() *cobra.Command {
cmd := &cobra.Command{Use: "settings", Short: "Server-wide settings and the options every subnet gets"}
cmd.AddCommand(&cobra.Command{
Use: "show", Short: "Show the settings", Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
var s model.Settings
if err := c.Get(ctx(), "/settings", nil, &s); err != nil {
return err
}
return output(s, func(w io.Writer) {
fmt.Fprintf(w, "Lease time:\t%s\n", fmtSeconds(s.EffectiveLeaseTime()))
fmt.Fprintln(w, "Options:\t"+optionsText(s.Options))
ping := "off"
if s.PingCheck {
ping = "on, waiting " + s.EffectivePingTimeout().String()
}
fmt.Fprintf(w, "Ping check:\t%s\n", ping)
fmt.Fprintf(w, "Decline hold:\t%s\n", fmtSeconds(int(s.EffectiveDeclineHold()/time.Second)))
auth := "yes (requests for addresses that don't belong on the interface get a NAK)"
if s.NotAuthoritative {
auth = "no (such requests get no answer)"
}
fmt.Fprintf(w, "Authoritative:\t%s\n", auth)
})
},
})
var leaseTime, ping, pingTimeout, hold, authoritative string
var opts, unsetOpts []string
set := &cobra.Command{
Use: "set",
Short: "Change settings (only what is given changes)",
Long: `Changes the settings. Options set here go to every subnet; a subnet's or a
reservation's own option of the same name wins.
steward settings set --option domain-name-servers=10.0.10.2 --option domain-name=lan
steward settings set --lease-time 1d --ping-check off`,
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
in := api.SettingsInput{}
fl := cmd.Flags()
if fl.Changed("lease-time") {
n, err := parseSeconds(leaseTime)
if err != nil {
return err
}
in.LeaseTime = &n
}
onOff := func(name, v string) (*bool, error) {
switch strings.ToLower(v) {
case "on", "yes", "true":
t := true
return &t, nil
case "off", "no", "false":
f := false
return &f, nil
}
return nil, fmt.Errorf("--%s: use on or off", name)
}
if fl.Changed("ping-check") {
if in.PingCheck, err = onOff("ping-check", ping); err != nil {
return err
}
}
if fl.Changed("ping-timeout") {
d, err := time.ParseDuration(pingTimeout)
if err != nil {
return fmt.Errorf("--ping-timeout: %v (use e.g. 500ms)", err)
}
ms := int(d / time.Millisecond)
in.PingTimeout = &ms
}
if fl.Changed("decline-hold") {
n, err := parseSeconds(hold)
if err != nil {
return err
}
in.DeclineHold = &n
}
if fl.Changed("authoritative") {
a, err := onOff("authoritative", authoritative)
if err != nil {
return err
}
not := !*a
in.NotAuthoritative = &not
}
if len(opts) > 0 || len(unsetOpts) > 0 {
var cur model.Settings
if err := c.Get(ctx(), "/settings", nil, &cur); err != nil {
return err
}
s, err := optionsArg(opts)
if err != nil {
return err
}
merged, err := mergeOptions(cur.Options, s, unsetOpts)
if err != nil {
return err
}
in.Options = &merged
}
var resp api.MutationResponse
if err := c.Do(ctx(), "PATCH", "/settings", nil, in, &resp); err != nil {
return err
}
return report(&resp)
},
}
f := set.Flags()
f.StringVar(&leaseTime, "lease-time", "", "the default lease time (12h, 1d)")
f.StringVar(&ping, "ping-check", "", "ping addresses before offering them: on or off")
f.StringVar(&pingTimeout, "ping-timeout", "", "how long to wait for a ping's answer (500ms)")
f.StringVar(&hold, "decline-hold", "", "how long an address in use by someone else is held back (24h)")
f.StringVar(&authoritative, "authoritative", "", "refuse requests for addresses that don't belong on the interface: on or off")
f.StringArrayVar(&opts, "option", nil, "set an option NAME=VALUE for every subnet")
f.StringArrayVar(&unsetOpts, "unset-option", nil, "remove an option")
cmd.AddCommand(set)
return cmd
}
func optionCmd() *cobra.Command {
cmd := &cobra.Command{Use: "option", Aliases: []string{"options"}, Short: "The DHCP options Steward knows by name"}
cmd.AddCommand(&cobra.Command{
Use: "list", Short: "List the options and their value types", Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
var cat api.Catalog
if err := c.Get(ctx(), "/catalog", nil, &cat); err != nil {
return err
}
return output(cat, func(w io.Writer) {
fmt.Fprintln(w, "CODE\tNAME\tTYPE\tWHAT")
for _, o := range cat.Options {
fmt.Fprintf(w, "%d\t%s\t%s\t%s\n", o.Code, o.Name, o.Type, o.Help)
}
fmt.Fprintf(w, "\nAny other option by number: --option 224=01:02 (hex), or with a type: --option 224:string=hello.\nTypes: %s.\n", strings.Join(cat.Types, ", "))
})
},
})
return cmd
}
func interfaceCmd() *cobra.Command {
cmd := &cobra.Command{Use: "interface", Aliases: []string{"interfaces"}, Short: "The server's network interfaces"}
cmd.AddCommand(&cobra.Command{
Use: "list", Short: "List the interfaces, their addresses and subnets", Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
var ifs []api.HostInterface
if err := c.Get(ctx(), "/interfaces", nil, &ifs); err != nil {
return err
}
return output(ifs, func(w io.Writer) {
fmt.Fprintln(w, "INTERFACE\tADDRESSES\tSUBNETS")
for _, i := range ifs {
var subs []string
for _, s := range i.Subnets {
subs = append(subs, fmt.Sprint(s))
}
addrs := strings.Join(i.Addresses, ", ")
if len(i.Addresses) == 0 {
addrs = "-"
}
fmt.Fprintf(w, "%s\t%s\t%s\n", i.Name, addrs, orDash(strings.Join(subs, ", ")))
}
})
},
})
return cmd
}
+493
View File
@@ -0,0 +1,493 @@
// Command steward is the command line client for Steward. It talks to
// stewardd's REST API: on the server itself through the local socket, or
// anywhere with the server's URL and an API token.
package main
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/url"
"os"
"path/filepath"
"strconv"
"strings"
"text/tabwriter"
"time"
"github.com/spf13/cobra"
"go.yaml.in/yaml/v3"
"steward/internal/api"
"steward/internal/client"
"steward/internal/model"
"steward/internal/validate"
"steward/internal/version"
)
func main() {
if err := rootCmd().Execute(); err != nil {
printError(err)
os.Exit(exitCode(err))
}
}
// Global flags.
var (
flagServer string
flagToken string
flagConfig string
flagDryRun bool
flagOutput string
flagInsecure bool
flagCACert string
flagQuiet bool
)
// DefaultSocket is stewardd's local socket.
const DefaultSocket = "/run/steward/steward.sock"
// cliConfig is the CLI's configuration file.
type cliConfig struct {
Server string `yaml:"server,omitempty"`
Token string `yaml:"token,omitempty"`
Insecure bool `yaml:"insecure,omitempty"`
CACert string `yaml:"ca_cert,omitempty"`
}
func defaultConfigPath() string {
if p := os.Getenv("STEWARD_CLI_CONFIG"); p != "" {
return p
}
dir, err := os.UserConfigDir()
if err != nil {
dir = filepath.Join(os.Getenv("HOME"), ".config")
}
return filepath.Join(dir, "steward", "config.yaml")
}
func loadCLIConfig() (*cliConfig, error) {
c := &cliConfig{}
b, err := os.ReadFile(flagConfig)
if errors.Is(err, os.ErrNotExist) {
return c, nil
}
if err != nil {
return nil, err
}
return c, yaml.Unmarshal(b, c)
}
func saveCLIConfig(c *cliConfig) error {
if err := os.MkdirAll(filepath.Dir(flagConfig), 0o700); err != nil {
return err
}
b, err := yaml.Marshal(c)
if err != nil {
return err
}
return os.WriteFile(flagConfig, b, 0o600)
}
func firstNonEmpty(vals ...string) string {
for _, v := range vals {
if v != "" {
return v
}
}
return ""
}
// newClient connects to the server: --server, STEWARD_SERVER, the config
// file, or else the local socket.
func newClient() (*client.Client, error) {
cfg, err := loadCLIConfig()
if err != nil {
return nil, fmt.Errorf("reading %s: %w", flagConfig, err)
}
server := firstNonEmpty(flagServer, os.Getenv("STEWARD_SERVER"), cfg.Server)
token := firstNonEmpty(flagToken, os.Getenv("STEWARD_TOKEN"), cfg.Token)
if server == "" || server == "local" {
sock := firstNonEmpty(os.Getenv("STEWARD_SOCKET"), DefaultSocket)
if _, err := os.Stat(sock); err != nil {
return nil, fmt.Errorf("no server configured, and there is no local socket %s: use --server URL, STEWARD_SERVER or 'steward config set server https://...'; on the server itself, check that stewardd runs", sock)
}
server = "unix://" + sock
}
if strings.HasPrefix(token, "@") {
b, err := os.ReadFile(token[1:])
if err != nil {
return nil, fmt.Errorf("reading the token file: %w", err)
}
token = strings.TrimSpace(string(b))
}
if _, isSocket := client.SocketPath(server); !isSocket && token == "" {
return nil, errors.New("no API token: use --token, STEWARD_TOKEN or 'steward config set token'")
}
c, err := client.New(client.Options{Server: server, Token: token, Insecure: flagInsecure || cfg.Insecure, CACert: firstNonEmpty(flagCACert, cfg.CACert)})
if err != nil {
return nil, err
}
c.DryRun = flagDryRun
return c, nil
}
func rootCmd() *cobra.Command {
root := &cobra.Command{
Use: "steward",
Short: "Command line client for Steward, the DHCP server",
Long: `steward manages a Steward DHCP server through its API.
Every change is checked as a whole before it is saved and used, and is
recorded in the history, where 'steward history undo ID' takes it back.
--dry-run shows what a change would do.
On the server, root needs no setup: the CLI uses stewardd's local socket.
From elsewhere, set the server and an API token once:
steward config set server https://dhcp.example.com:8067
steward config set token (paste the token at the prompt)
Exit codes: 0 ok, 1 error, 3 not authenticated or not allowed, 4 not found,
5 conflict, 6 invalid or rejected by validation, 7 server error.`,
SilenceUsage: true,
SilenceErrors: true,
Version: version.String(),
}
pf := root.PersistentFlags()
pf.StringVarP(&flagServer, "server", "s", "", "server URL, or \"local\" for the local socket (env STEWARD_SERVER)")
pf.StringVar(&flagToken, "token", "", "API token, or @FILE to read it from a file (env STEWARD_TOKEN)")
pf.StringVar(&flagConfig, "config", defaultConfigPath(), "CLI configuration file")
pf.BoolVarP(&flagDryRun, "dry-run", "n", false, "check and show a change without making it")
pf.StringVarP(&flagOutput, "output", "o", "table", "output format: table, json, yaml or id")
pf.BoolVar(&flagInsecure, "insecure", false, "don't verify the server's TLS certificate")
pf.StringVar(&flagCACert, "ca-cert", "", "PEM file with a CA to trust for the server's certificate")
pf.BoolVarP(&flagQuiet, "quiet", "q", false, "print only errors (and IDs of new objects)")
root.AddGroup(&cobra.Group{ID: "dhcp", Title: "DHCP:"}, &cobra.Group{ID: "changes", Title: "Changes:"}, &cobra.Group{ID: "admin", Title: "Administration:"})
add := func(group string, cmds ...*cobra.Command) {
for _, c := range cmds {
c.GroupID = group
root.AddCommand(c)
}
}
add("dhcp", statusCmd(), subnetCmd(), reservationCmd(), leaseCmd(), activityCmd(), settingsCmd(), optionCmd(), interfaceCmd())
add("changes", historyCmd(), importCmd())
add("admin", whoamiCmd(), userCmd(), tokenCmd(), passwordCmd(), auditCmd(), configCmd())
return root
}
func ctx() context.Context { return context.Background() }
// ---- output
func output(v any, table func(w io.Writer)) error {
switch flagOutput {
case "json":
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
return enc.Encode(v)
case "yaml":
b, err := json.Marshal(v)
if err != nil {
return err
}
var x any
if err := json.Unmarshal(b, &x); err != nil {
return err
}
return yaml.NewEncoder(os.Stdout).Encode(x)
case "id":
return outputIDs(v)
case "table", "":
if table == nil {
flagOutput = "yaml"
return output(v, nil)
}
tw := tabwriter.NewWriter(os.Stdout, 2, 4, 2, ' ', 0)
table(tw)
return tw.Flush()
}
return fmt.Errorf("unknown output format %q (use table, json, yaml or id)", flagOutput)
}
// outputIDs prints the id (or for leases the ip) of each object.
func outputIDs(v any) error {
b, err := json.Marshal(v)
if err != nil {
return err
}
dec := json.NewDecoder(bytes.NewReader(b))
dec.UseNumber()
var x any
if err := dec.Decode(&x); err != nil {
return err
}
list, ok := x.([]any)
if !ok {
list = []any{x}
}
for _, it := range list {
if m, ok := it.(map[string]any); ok {
if id, ok := m["id"]; ok {
fmt.Println(id)
} else if ip, ok := m["ip"]; ok {
fmt.Println(ip)
}
}
}
return nil
}
func printProblems(w io.Writer, ps []validate.Problem) {
for _, p := range ps {
obj := ""
if p.Object != "" {
obj = p.Object + ": "
}
fmt.Fprintf(w, "%s: %s%s [%s]\n", p.Severity, obj, p.Message, p.Code)
}
}
func printError(err error) {
var ce *client.Error
if errors.As(err, &ce) {
ps := ce.Info.Problems
if len(ps) == 1 {
fmt.Fprintf(os.Stderr, "error: %s [%s]\n", ce.Info.Message, ps[0].Code)
return
}
fmt.Fprintln(os.Stderr, "error:", ce.Info.Message)
if len(ps) > 1 {
printProblems(os.Stderr, ps)
}
return
}
fmt.Fprintln(os.Stderr, "error:", err)
}
// exitCode maps errors to exit codes: 1 other errors, 3 not authenticated
// or not allowed, 4 not found, 5 conflict, 6 invalid request or rejected
// by validation, 7 server error.
func exitCode(err error) int {
var ce *client.Error
if errors.As(err, &ce) {
switch {
case ce.Status == 401 || ce.Status == 403:
return 3
case ce.Status == 404:
return 4
case ce.Status == 409:
return 5
case ce.Status == 400 || ce.Status == 422:
return 6
case ce.Status >= 500:
return 7
}
}
return 1
}
// report prints the outcome of a change.
func report(resp *api.MutationResponse) error {
switch flagOutput {
case "json", "yaml":
return output(resp, nil)
case "id":
if len(resp.Object) > 0 {
return outputIDs(resp.Object)
}
return nil
}
w := os.Stdout
verb := func(c model.Change) string {
if resp.DryRun {
return "would " + c.Action()
}
return c.Action() + "d"
}
if !flagQuiet || resp.DryRun {
for _, c := range resp.Changes {
if c.Kind == model.KindSettings {
fmt.Fprintf(w, "%s settings\n", verb(c))
continue
}
fmt.Fprintf(w, "%s %s %s\n", verb(c), c.Kind, c.Label)
}
}
switch {
case resp.DryRun:
fmt.Fprintln(w, "dry run: nothing was changed")
case resp.Applied && !flagQuiet:
fmt.Fprintf(w, "saved (history %d; undo with 'steward history undo %d')\n", resp.HistoryID, resp.HistoryID)
case !resp.Applied && len(resp.Changes) == 0 && !flagQuiet:
fmt.Fprintln(w, "nothing to change")
}
if flagQuiet && len(resp.Object) > 0 && resp.Applied {
outputIDs(resp.Object)
}
printProblems(os.Stderr, resp.Warnings)
return nil
}
func q(kv ...string) url.Values {
v := url.Values{}
for i := 0; i+1 < len(kv); i += 2 {
if kv[i+1] != "" {
v.Set(kv[i], kv[i+1])
}
}
return v
}
func confirm(prompt string, yes bool) bool {
if yes || flagDryRun {
return true
}
fmt.Fprintf(os.Stderr, "%s [y/N] ", prompt)
var answer string
fmt.Scanln(&answer)
return strings.EqualFold(answer, "y") || strings.EqualFold(answer, "yes")
}
// ---- durations
// parseSeconds reads a lease time: "12h", "30m", "7d", "1d12h" or plain
// seconds.
func parseSeconds(s string) (int, error) {
s = strings.TrimSpace(s)
if n, err := strconv.Atoi(s); err == nil {
return n, nil
}
days := 0
if i := strings.Index(s, "d"); i > 0 {
n, err := strconv.Atoi(s[:i])
if err != nil {
return 0, fmt.Errorf("invalid duration %q (use e.g. 12h, 30m or 7d)", s)
}
days, s = n, s[i+1:]
}
var d time.Duration
if s != "" {
var err error
if d, err = time.ParseDuration(s); err != nil {
return 0, fmt.Errorf("invalid duration %q (use e.g. 12h, 30m or 7d)", s)
}
}
return days*86400 + int(d/time.Second), nil
}
// fmtSeconds writes seconds the short way: 43200 is "12h", 90 is "1m30s".
func fmtSeconds(n int) string {
if n <= 0 {
return "-"
}
var parts []string
for _, u := range []struct {
s int
name string
}{{86400, "d"}, {3600, "h"}, {60, "m"}, {1, "s"}} {
if n >= u.s {
parts = append(parts, strconv.Itoa(n/u.s)+u.name)
n %= u.s
}
}
return strings.Join(parts, "")
}
// fmtWhen writes a time relative to now: "in 3h", "2d ago".
func fmtWhen(t time.Time) string {
if t.IsZero() {
return "-"
}
d := time.Until(t)
ago := d < 0
if ago {
d = -d
}
var s string
switch {
case d < time.Minute:
return "now"
case d < time.Hour:
s = fmt.Sprintf("%dm", int(d.Minutes()))
case d < 48*time.Hour:
s = fmt.Sprintf("%dh%02dm", int(d.Hours()), int(d.Minutes())%60)
default:
s = fmt.Sprintf("%dd", int(d.Hours()/24))
}
if ago {
return s + " ago"
}
return "in " + s
}
func orDash(s string) string {
if s == "" {
return "-"
}
return s
}
// optionsArg turns --option NAME=VALUE flags into option inputs.
func optionsArg(flags []string) ([]api.OptionInput, error) {
var out []api.OptionInput
for _, f := range flags {
name, value, ok := strings.Cut(f, "=")
if !ok {
return nil, fmt.Errorf("--option %q: write it as NAME=VALUE, e.g. routers=10.0.10.1", f)
}
in := api.OptionInput{Option: strings.TrimSpace(name), Value: strings.TrimSpace(value)}
// A custom option's type: 224:string=hello.
if n, typ, ok := strings.Cut(in.Option, ":"); ok {
in.Option, in.Type = n, typ
}
out = append(out, in)
}
return out, nil
}
// mergeOptions sets and unsets options on a current list (by code).
func mergeOptions(cur []model.OptionValue, set []api.OptionInput, unset []string) ([]api.OptionInput, error) {
out := []api.OptionInput{}
drop := map[string]bool{}
for _, u := range unset {
drop[strings.ToLower(strings.TrimSpace(u))] = true
}
setNames := map[string]bool{}
for _, s := range set {
setNames[strings.ToLower(s.Option)] = true
}
for _, o := range cur {
code := strconv.Itoa(o.Code)
if drop[o.Name] || drop[code] || setNames[o.Name] || setNames[code] {
continue
}
out = append(out, api.OptionInput{Option: code, Value: o.Value, Type: o.Type})
}
for name := range drop {
found := false
for _, o := range cur {
if o.Name == name || strconv.Itoa(o.Code) == name {
found = true
}
}
if !found {
return nil, fmt.Errorf("option %s isn't set here", name)
}
}
return append(out, set...), nil
}
func optionsText(opts []model.OptionValue) string {
var parts []string
for _, o := range opts {
parts = append(parts, o.Name+"="+o.Value)
}
if len(parts) == 0 {
return "-"
}
return strings.Join(parts, "; ")
}
+44
View File
@@ -0,0 +1,44 @@
package main
import (
"testing"
"steward/internal/api"
"steward/internal/model"
)
func TestDurations(t *testing.T) {
for in, want := range map[string]int{"3600": 3600, "12h": 43200, "30m": 1800, "1d": 86400, "1d12h": 129600, "1h30m": 5400} {
if got, err := parseSeconds(in); err != nil || got != want {
t.Errorf("parseSeconds(%q) = %d, %v; want %d", in, got, err, want)
}
}
for _, bad := range []string{"12x", "d", "1d2x"} {
if _, err := parseSeconds(bad); err == nil {
t.Errorf("parseSeconds(%q) accepted", bad)
}
}
for n, want := range map[int]string{43200: "12h", 5400: "1h30m", 90061: "1d1h1m1s", 0: "-"} {
if got := fmtSeconds(n); got != want {
t.Errorf("fmtSeconds(%d) = %q, want %q", n, got, want)
}
}
}
func TestOptions(t *testing.T) {
in, err := optionsArg([]string{"routers=10.0.10.1", "224:string=hi", " ntp-servers = 10.0.10.2 "})
if err != nil || len(in) != 3 || in[1].Option != "224" || in[1].Type != "string" || in[2].Option != "ntp-servers" || in[2].Value != "10.0.10.2" {
t.Fatalf("optionsArg: %+v %v", in, err)
}
if _, err := optionsArg([]string{"routers"}); err == nil {
t.Error("an option without a value accepted")
}
cur := []model.OptionValue{{Code: 3, Name: "routers", Value: "10.0.10.1"}, {Code: 6, Name: "domain-name-servers", Value: "10.0.10.2"}}
got, err := mergeOptions(cur, []api.OptionInput{{Option: "routers", Value: "10.0.10.254"}}, []string{"domain-name-servers"})
if err != nil || len(got) != 1 || got[0].Option != "routers" || got[0].Value != "10.0.10.254" {
t.Errorf("merge: %+v %v", got, err)
}
if _, err := mergeOptions(cur, nil, []string{"ntp-servers"}); err == nil {
t.Error("unsetting an option that isn't set accepted")
}
}
+507
View File
@@ -0,0 +1,507 @@
package main
import (
"fmt"
"io"
"net/url"
"strings"
"github.com/spf13/cobra"
"steward/internal/api"
"steward/internal/model"
)
// subnetPath is the API path of a subnet given by ID, name or prefix.
func subnetPath(ref string) string {
return "/subnets/" + url.PathEscape(strings.Replace(ref, "/", "_", 1))
}
func poolsText(ps []model.Pool) string {
var out []string
for _, p := range ps {
out = append(out, p.String())
}
if len(out) == 0 {
return "-"
}
return strings.Join(out, ", ")
}
func usageText(u api.Usage) string {
if u.PoolSize == 0 {
return "-"
}
return fmt.Sprintf("%d/%d (%d%%)", u.Leased, u.PoolSize, u.Leased*100/u.PoolSize)
}
func subnetCmd() *cobra.Command {
cmd := &cobra.Command{Use: "subnet", Aliases: []string{"subnets"}, Short: "Subnets: the networks Steward serves, each on its interface"}
cmd.AddCommand(&cobra.Command{
Use: "list", Short: "List the subnets with their interface, pools and use", Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
var subs []api.Subnet
if err := c.Get(ctx(), "/subnets", nil, &subs); err != nil {
return err
}
return output(subs, func(w io.Writer) {
fmt.Fprintln(w, "ID\tNAME\tINTERFACE\tPREFIX\tPOOLS\tLEASED\tRESERVED\tLEASE TIME\tSTATE")
for _, s := range subs {
state := "on"
if s.Disabled {
state = "disabled"
}
fmt.Fprintf(w, "%d\t%s\t%s\t%s\t%s\t%s\t%d\t%s\t%s\n", s.ID, orDash(s.Name), s.Interface, s.Prefix, poolsText(s.Pools), usageText(s.Usage),
s.Usage.Reservations, fmtSeconds(s.LeaseTime), state)
}
})
},
})
cmd.AddCommand(&cobra.Command{
Use: "show SUBNET", Short: "Show a subnet (by ID, name or prefix)", Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
var s api.Subnet
if err := c.Get(ctx(), subnetPath(args[0]), nil, &s); err != nil {
return err
}
return output(s, func(w io.Writer) {
fmt.Fprintf(w, "ID:\t%d\n", s.ID)
fmt.Fprintf(w, "Name:\t%s\n", orDash(s.Name))
fmt.Fprintf(w, "Interface:\t%s\n", s.Interface)
fmt.Fprintf(w, "Prefix:\t%s (mask %s)\n", s.Prefix, model.Mask(s.Prefix))
fmt.Fprintf(w, "Pools:\t%s\n", poolsText(s.Pools))
fmt.Fprintf(w, "Leased:\t%s, %d declined\n", usageText(s.Usage), s.Usage.Declined)
fmt.Fprintf(w, "Reservations:\t%d (%d with a lease now)\n", s.Usage.Reservations, s.Usage.ReservationsLeased)
lt := "the default"
if s.LeaseTime > 0 {
lt = fmtSeconds(s.LeaseTime)
}
fmt.Fprintf(w, "Lease time:\t%s\n", lt)
fmt.Fprintln(w, "Options:\t"+optionsText(s.Options))
if s.Disabled {
fmt.Fprintln(w, "State:\tdisabled (no answers)")
}
if s.Comment != "" {
fmt.Fprintf(w, "Comment:\t%s\n", s.Comment)
}
})
},
})
var name, iface, leaseTime, comment, prefix string
var pools, opts, addPools, removePools, unsetOpts []string
add := &cobra.Command{
Use: "add PREFIX --interface IFACE",
Short: "Add a subnet",
Long: `Adds a subnet, served on one interface: only clients on that interface
get its addresses.
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 --option domain-name-servers=10.0.10.2
Pools are FIRST-LAST or a prefix; give --pool once per pool. Options are
NAME=VALUE ('steward option list' has the names); a number works for any
other option, with its type for ones Steward doesn't know: 224:string=hi.`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
in := api.SubnetInput{Prefix: &args[0], Interface: &iface, Pools: &pools}
if pools == nil {
in.Pools = &[]string{}
}
if name != "" {
in.Name = &name
}
if comment != "" {
in.Comment = &comment
}
if leaseTime != "" {
n, err := parseSeconds(leaseTime)
if err != nil {
return err
}
in.LeaseTime = &n
}
if len(opts) > 0 {
o, err := optionsArg(opts)
if err != nil {
return err
}
in.Options = &o
}
var resp api.MutationResponse
if err := c.Do(ctx(), "POST", "/subnets", nil, in, &resp); err != nil {
return err
}
return report(&resp)
},
}
f := add.Flags()
f.StringVar(&iface, "interface", "", "the network interface it is served on (required)")
f.StringVar(&name, "name", "", "a name for it")
f.StringArrayVar(&pools, "pool", nil, "a pool: FIRST-LAST or a prefix (repeat for several)")
f.StringVar(&leaseTime, "lease-time", "", "lease time (12h, 1d; default: the settings')")
f.StringArrayVar(&opts, "option", nil, "an option NAME=VALUE (repeat for several)")
f.StringVar(&comment, "comment", "", "a comment")
add.MarkFlagRequired("interface")
cmd.AddCommand(add)
update := &cobra.Command{
Use: "update SUBNET",
Short: "Change a subnet",
Long: `Changes a subnet. Only what is given changes. --pool replaces all pools;
--add-pool and --remove-pool change one. --option sets an option (keeping
the others), --unset-option removes one.
steward subnet update office --add-pool 10.0.10.200-10.0.10.220
steward subnet update office --option ntp-servers=10.0.10.2 --unset-option time-offset`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
var cur api.Subnet
if err := c.Get(ctx(), subnetPath(args[0]), nil, &cur); err != nil {
return err
}
in := api.SubnetInput{}
fl := cmd.Flags()
if fl.Changed("name") {
in.Name = &name
}
if fl.Changed("interface") {
in.Interface = &iface
}
if fl.Changed("prefix") {
in.Prefix = &prefix
}
if fl.Changed("comment") {
in.Comment = &comment
}
if fl.Changed("lease-time") {
n := 0
if leaseTime != "default" {
if n, err = parseSeconds(leaseTime); err != nil {
return err
}
}
in.LeaseTime = &n
}
if fl.Changed("pool") || len(addPools) > 0 || len(removePools) > 0 {
list := []string{}
if !fl.Changed("pool") {
for _, p := range cur.Pools {
list = append(list, p.String())
}
} else {
list = append(list, pools...)
}
for _, r := range removePools {
rp, err := model.ParsePool(r)
if err != nil {
return err
}
found := false
for i, p := range list {
if pp, _ := model.ParsePool(p); pp == rp {
list = append(list[:i], list[i+1:]...)
found = true
break
}
}
if !found {
return fmt.Errorf("the subnet has no pool %s (it has %s)", rp, poolsText(cur.Pools))
}
}
list = append(list, addPools...)
in.Pools = &list
}
if len(opts) > 0 || len(unsetOpts) > 0 {
set, err := optionsArg(opts)
if err != nil {
return err
}
merged, err := mergeOptions(cur.Options, set, unsetOpts)
if err != nil {
return err
}
in.Options = &merged
}
var resp api.MutationResponse
if err := c.Do(ctx(), "PATCH", subnetPath(args[0]), nil, in, &resp); err != nil {
return err
}
return report(&resp)
},
}
f = update.Flags()
f.StringVar(&name, "name", "", "a new name (\"\" for none)")
f.StringVar(&iface, "interface", "", "a new interface")
f.StringVar(&prefix, "prefix", "", "a new prefix")
f.StringArrayVar(&pools, "pool", nil, "replace the pools (repeat for several)")
f.StringArrayVar(&addPools, "add-pool", nil, "add a pool")
f.StringArrayVar(&removePools, "remove-pool", nil, "remove a pool")
f.StringVar(&leaseTime, "lease-time", "", "lease time (12h, 1d, or \"default\")")
f.StringArrayVar(&opts, "option", nil, "set an option NAME=VALUE")
f.StringArrayVar(&unsetOpts, "unset-option", nil, "remove an option (by name or number)")
f.StringVar(&comment, "comment", "", "a comment")
cmd.AddCommand(update)
for _, on := range []bool{true, false} {
on := on
verb := "disable"
short := "Stop answering a subnet's clients (its configuration and leases stay)"
if on {
verb, short = "enable", "Answer a disabled subnet's clients again"
}
cmd.AddCommand(&cobra.Command{
Use: verb + " SUBNET", Short: short, Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
disabled := !on
var resp api.MutationResponse
if err := c.Do(ctx(), "PATCH", subnetPath(args[0]), nil, api.SubnetInput{Disabled: &disabled}, &resp); err != nil {
return err
}
return report(&resp)
},
})
}
var yes bool
del := &cobra.Command{
Use: "delete SUBNET", Short: "Delete a subnet and its reservations", Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
var cur api.Subnet
if err := c.Get(ctx(), subnetPath(args[0]), nil, &cur); err != nil {
return err
}
msg := fmt.Sprintf("Delete subnet %s", cur.Label())
if cur.Usage.Reservations > 0 {
msg += fmt.Sprintf(" and its %d reservations", cur.Usage.Reservations)
}
if !confirm(msg+"?", yes) {
return fmt.Errorf("not deleted")
}
var resp api.MutationResponse
if err := c.Do(ctx(), "DELETE", subnetPath(args[0]), nil, nil, &resp); err != nil {
return err
}
return report(&resp)
},
}
del.Flags().BoolVarP(&yes, "yes", "y", false, "don't ask")
cmd.AddCommand(del)
return cmd
}
func reservationCmd() *cobra.Command {
cmd := &cobra.Command{Use: "reservation", Aliases: []string{"reservations", "res"}, Short: "Reservations: fixed addresses for clients"}
var subnet string
list := &cobra.Command{
Use: "list", Short: "List reservations", Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
var rs []api.Reservation
if err := c.Get(ctx(), "/reservations", q("subnet", subnet), &rs); err != nil {
return err
}
return output(rs, func(w io.Writer) {
fmt.Fprintln(w, "ID\tIP\tMAC / CLIENT ID\tHOSTNAME\tSUBNET\tLEASE\tCOMMENT")
for _, r := range rs {
who := r.MAC
if r.ClientID != "" {
who = "id " + r.ClientID
}
lease := "-"
if r.Lease != nil {
lease = "until " + r.Lease.End.Local().Format("Jan 2 15:04")
}
fmt.Fprintf(w, "%d\t%s\t%s\t%s\t%s\t%s\t%s\n", r.ID, r.IP, who, orDash(r.Hostname), orDash(r.SubnetName), lease, r.Comment)
}
})
},
}
list.Flags().StringVar(&subnet, "subnet", "", "only this subnet's (ID, name or prefix)")
cmd.AddCommand(list)
cmd.AddCommand(&cobra.Command{
Use: "show RESERVATION", Short: "Show a reservation (by ID, address or MAC address)", Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
var r api.Reservation
if err := c.Get(ctx(), "/reservations/"+url.PathEscape(args[0]), nil, &r); err != nil {
return err
}
return output(r, func(w io.Writer) {
fmt.Fprintf(w, "ID:\t%d\n", r.ID)
fmt.Fprintf(w, "Address:\t%s (subnet %s)\n", r.IP, orDash(r.SubnetName))
if r.MAC != "" {
fmt.Fprintf(w, "MAC address:\t%s\n", r.MAC)
}
if r.ClientID != "" {
fmt.Fprintf(w, "Client ID:\t%s\n", r.ClientID)
}
fmt.Fprintf(w, "Host name:\t%s\n", orDash(r.Hostname))
fmt.Fprintln(w, "Options:\t"+optionsText(r.Options))
if r.Lease != nil {
fmt.Fprintf(w, "Lease:\tuntil %s, last seen on %s\n", r.Lease.End.Local().Format("2006-01-02 15:04"), r.Lease.Interface)
} else {
fmt.Fprintln(w, "Lease:\tnone now")
}
if r.Comment != "" {
fmt.Fprintf(w, "Comment:\t%s\n", r.Comment)
}
})
},
})
var hostname, clientID, comment, ip, mac string
var opts, unsetOpts []string
add := &cobra.Command{
Use: "add IP MAC",
Short: "Reserve an address for a client",
Long: `Reserves an address for a client, known by its MAC address (or by its
client identifier: give "-" for the MAC and --client-id). The address picks
the subnet. An address inside a pool is fine: it is never handed out to
anyone else.
steward reservation add 10.0.10.20 aa:bb:cc:dd:ee:01 --hostname nas`,
Args: cobra.ExactArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
in := api.ReservationInput{IP: &args[0]}
if args[1] != "-" {
in.MAC = &args[1]
}
if clientID != "" {
in.ClientID = &clientID
}
if hostname != "" {
in.Hostname = &hostname
}
if comment != "" {
in.Comment = &comment
}
if len(opts) > 0 {
o, err := optionsArg(opts)
if err != nil {
return err
}
in.Options = &o
}
var resp api.MutationResponse
if err := c.Do(ctx(), "POST", "/reservations", nil, in, &resp); err != nil {
return err
}
return report(&resp)
},
}
f := add.Flags()
f.StringVar(&hostname, "hostname", "", "the host name to send it")
f.StringVar(&clientID, "client-id", "", "match the client identifier (hex) instead of the MAC address")
f.StringArrayVar(&opts, "option", nil, "an option NAME=VALUE for this client only")
f.StringVar(&comment, "comment", "", "a comment")
cmd.AddCommand(add)
update := &cobra.Command{
Use: "update RESERVATION", Short: "Change a reservation (only what is given changes)", Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
path := "/reservations/" + url.PathEscape(args[0])
var cur api.Reservation
if err := c.Get(ctx(), path, nil, &cur); err != nil {
return err
}
in := api.ReservationInput{}
fl := cmd.Flags()
if fl.Changed("ip") {
in.IP = &ip
}
if fl.Changed("mac") {
in.MAC = &mac
}
if fl.Changed("client-id") {
in.ClientID = &clientID
}
if fl.Changed("hostname") {
in.Hostname = &hostname
}
if fl.Changed("comment") {
in.Comment = &comment
}
if len(opts) > 0 || len(unsetOpts) > 0 {
set, err := optionsArg(opts)
if err != nil {
return err
}
merged, err := mergeOptions(cur.Options, set, unsetOpts)
if err != nil {
return err
}
in.Options = &merged
}
var resp api.MutationResponse
if err := c.Do(ctx(), "PATCH", path, nil, in, &resp); err != nil {
return err
}
return report(&resp)
},
}
f = update.Flags()
f.StringVar(&ip, "ip", "", "a new address")
f.StringVar(&mac, "mac", "", "a new MAC address (\"\" for none)")
f.StringVar(&clientID, "client-id", "", "a client identifier (\"\" for none)")
f.StringVar(&hostname, "hostname", "", "a new host name")
f.StringArrayVar(&opts, "option", nil, "set an option NAME=VALUE")
f.StringArrayVar(&unsetOpts, "unset-option", nil, "remove an option")
f.StringVar(&comment, "comment", "", "a comment")
cmd.AddCommand(update)
cmd.AddCommand(&cobra.Command{
Use: "delete RESERVATION", Short: "Delete a reservation", Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
var resp api.MutationResponse
if err := c.Do(ctx(), "DELETE", "/reservations/"+url.PathEscape(args[0]), nil, nil, &resp); err != nil {
return err
}
return report(&resp)
},
})
return cmd
}
+40
View File
@@ -0,0 +1,40 @@
package main
import (
"errors"
"fmt"
"os"
"path/filepath"
"syscall"
)
// errInUse means another process holds the database lock.
var errInUse = errors.New("the database is in use by a running stewardd or by 'stewardd restore'")
// lockDatabase takes the exclusive lock (on DATABASE.lock) that marks the
// database as in use by a running stewardd or by 'stewardd restore', so a
// restore can never replace the database under a running stewardd, and two
// stewardds can't serve the same database. Other commands (backup, user, ...)
// don't take it: they work alongside a running stewardd.
func lockDatabase(db string) (release func(), err error) {
if err := os.MkdirAll(filepath.Dir(db), 0o750); err != nil {
return nil, err
}
path := db + ".lock"
// Read-only is enough for flock, and works whoever created the file.
f, err := os.OpenFile(path, os.O_RDONLY|os.O_CREATE, 0o640)
if err != nil {
return nil, err
}
if err := syscall.Flock(int(f.Fd()), syscall.LOCK_EX|syscall.LOCK_NB); err != nil {
f.Close()
if errors.Is(err, syscall.EWOULDBLOCK) {
return nil, errInUse
}
return nil, fmt.Errorf("locking %s: %w", path, err)
}
return func() {
syscall.Flock(int(f.Fd()), syscall.LOCK_UN)
f.Close()
}, nil
}
+614
View File
@@ -0,0 +1,614 @@
// Command stewardd is Steward's daemon: the DHCP server, its REST API and
// the web UI. It also has the commands that work on the database directly
// (init, user, backup, restore, check).
package main
import (
"bufio"
"context"
"errors"
"fmt"
"io"
"log/slog"
"net"
"os"
"os/signal"
"path/filepath"
"sort"
"strings"
"syscall"
"time"
"github.com/spf13/cobra"
"golang.org/x/term"
"steward/internal/auth"
"steward/internal/config"
"steward/internal/dhcpd"
"steward/internal/engine"
"steward/internal/model"
"steward/internal/server"
"steward/internal/store"
"steward/internal/validate"
"steward/internal/version"
"steward/internal/web"
)
var configPath string
func main() {
if err := rootCmd().Execute(); err != nil {
fmt.Fprintln(os.Stderr, "error:", err)
os.Exit(1)
}
}
func rootCmd() *cobra.Command {
root := &cobra.Command{
Use: "stewardd",
Short: "Steward's DHCP server, API and web UI",
SilenceUsage: true,
SilenceErrors: true,
Version: version.String(),
}
root.PersistentFlags().StringVarP(&configPath, "config", "c", config.DefaultPath, "configuration file")
root.AddCommand(serveCmd(), initCmd(), userCmd(), checkCmd(), backupCmd(), restoreCmd(), configCmd(), versionCmd())
return root
}
func newLogger(cfg *config.Config, w io.Writer) *slog.Logger {
var level slog.Level
switch cfg.Log.Level {
case "debug":
level = slog.LevelDebug
case "warn":
level = slog.LevelWarn
case "error":
level = slog.LevelError
}
opts := &slog.HandlerOptions{Level: level}
if cfg.Log.Format == "json" {
return slog.New(slog.NewJSONHandler(w, opts))
}
// The journal adds its own timestamps.
if os.Getenv("JOURNAL_STREAM") != "" {
opts.ReplaceAttr = func(groups []string, a slog.Attr) slog.Attr {
if a.Key == slog.TimeKey && len(groups) == 0 {
return slog.Attr{}
}
return a
}
}
return slog.New(slog.NewTextHandler(w, opts))
}
type app struct {
cfg *config.Config
log *slog.Logger
store *store.Store
}
func openApp(allowMissing bool) (*app, error) {
cfg, err := config.Load(configPath, allowMissing)
if err != nil {
return nil, fmt.Errorf("loading the configuration: %w", err)
}
log := newLogger(cfg, os.Stderr)
if err := os.MkdirAll(filepath.Dir(cfg.Database), 0o750); err != nil {
return nil, err
}
st, err := store.Open(cfg.Database)
if err != nil {
return nil, fmt.Errorf("opening the database %s: %w", cfg.Database, err)
}
return &app{cfg: cfg, log: log, store: st}, nil
}
// hostEnv describes this machine's interfaces for validation.
func hostEnv(h *dhcpd.HostInterfaces) func() *validate.Env {
return func() *validate.Env {
all, err := h.All()
if err != nil {
return nil
}
return &validate.Env{Interfaces: all}
}
}
func serveCmd() *cobra.Command {
return &cobra.Command{
Use: "serve",
Short: "Serve DHCP, the API and the web UI",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
a, err := openApp(true)
if err != nil {
return err
}
defer a.store.Close()
release, err := lockDatabase(a.cfg.Database)
if err != nil {
return err
}
defer release()
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
a.log.Info("starting", "version", version.String(), "database", a.cfg.Database)
if users, err := a.store.ListUsers(ctx); err == nil && len(users) == 0 {
a.log.Warn("there are no users yet; create an administrator with 'stewardd init' (root can use 'steward' on the local socket meanwhile)")
}
eng, err := engine.New(ctx, a.store, a.log)
if err != nil {
return err
}
host := &dhcpd.HostInterfaces{}
eng.Env = hostEnv(host)
pinger := &dhcpd.ICMPPinger{Log: a.log}
d := dhcpd.New(a.log, a.store, host, pinger)
if err := d.LoadLeases(ctx); err != nil {
return fmt.Errorf("reading the leases: %w", err)
}
d.SetConfig(eng.State())
eng.OnChange = d.SetConfig
for _, p := range eng.Problems() {
a.log.Warn("configuration problem: "+p.Message, "object", p.Object, "code", p.Code)
}
srv := server.New(server.Config{
Listen: a.cfg.Listen, TLSCertFile: a.cfg.TLS.CertFile, TLSKeyFile: a.cfg.TLS.KeyFile, TrustedProxies: a.cfg.Proxies(),
SessionLifetime: time.Duration(a.cfg.Session.Lifetime), SessionIdle: time.Duration(a.cfg.Session.IdleTimeout),
CookieSecure: a.cfg.Session.CookieSecure, Socket: a.cfg.Socket, PingProblem: pinger.Problem,
}, eng, d, host, a.log, web.FS())
go d.Run(ctx)
hup := make(chan os.Signal, 1)
signal.Notify(hup, syscall.SIGHUP)
go func() {
for range hup {
sdNotify("RELOADING=1")
srv.ReloadTLS()
sdNotify("READY=1")
}
}()
err = srv.Serve(ctx, func() { sdNotify("READY=1") })
sdNotify("STOPPING=1")
a.log.Info("stopped")
return err
},
}
}
func sdNotify(state string) {
sock := os.Getenv("NOTIFY_SOCKET")
if sock == "" {
return
}
addr := &net.UnixAddr{Name: sock, Net: "unixgram"}
if strings.HasPrefix(sock, "@") {
addr.Name = "\x00" + sock[1:]
}
c, err := net.DialUnix("unixgram", nil, addr)
if err != nil {
return
}
defer c.Close()
c.Write([]byte(state))
}
// ---- init and users
func readPassword(prompt string, fromStdin bool) (string, error) {
if fromStdin || !term.IsTerminal(int(os.Stdin.Fd())) {
line, err := bufio.NewReader(os.Stdin).ReadString('\n')
if err != nil && !errors.Is(err, io.EOF) {
return "", err
}
return strings.TrimRight(line, "\r\n"), nil
}
fmt.Fprint(os.Stderr, prompt)
b, err := term.ReadPassword(int(os.Stdin.Fd()))
fmt.Fprintln(os.Stderr)
if err != nil {
return "", err
}
fmt.Fprint(os.Stderr, "Repeat: ")
b2, err := term.ReadPassword(int(os.Stdin.Fd()))
fmt.Fprintln(os.Stderr)
if err != nil {
return "", err
}
if string(b) != string(b2) {
return "", errors.New("the passwords don't match")
}
return string(b), nil
}
func localPrincipal(u *model.User) *auth.Principal {
return &auth.Principal{User: u, Method: "local", IP: "local"}
}
func audit(ctx context.Context, st *store.Store, p *auth.Principal, action, object string) {
ev := &model.AuditEvent{Action: action, Object: object, Result: "success", Auth: p.Method, Username: p.Name()}
if p.User != nil {
ev.UserID = p.User.ID
}
st.AddAudit(ctx, ev)
}
func createToken(ctx context.Context, st *store.Store, u *model.User, name string) (string, error) {
secret, pid, hash := auth.NewToken()
if err := st.CreateToken(ctx, &model.Token{UserID: u.ID, Name: name, PublicID: pid, SecretHash: hash}); err != nil {
return "", err
}
return secret, nil
}
func initCmd() *cobra.Command {
var username string
var stdin, withToken bool
cmd := &cobra.Command{
Use: "init",
Short: "Create the database and the first administrator",
Long: `Creates Steward's database with a first administrator.
Steward starts with no subnets: add them with 'steward subnet add', in the
web UI, or import a Kea configuration with 'steward import kea'.`,
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
a, err := openApp(true)
if err != nil {
return err
}
defer a.store.Close()
ctx := context.Background()
users, err := a.store.ListUsers(ctx)
if err != nil {
return err
}
if len(users) > 0 {
return fmt.Errorf("Steward is already set up (%d users); add users with 'stewardd user add' or 'steward user add'", len(users))
}
if err := server.ValidUsername(username); err != nil {
return err
}
pw, err := readPassword("Password for "+username+": ", stdin)
if err != nil {
return err
}
hash, err := auth.HashPassword(pw)
if err != nil {
return err
}
u := &model.User{Username: username, PasswordHash: hash, Admin: true, Comment: "created by stewardd init"}
if err := a.store.CreateUser(ctx, u); err != nil {
return err
}
audit(ctx, a.store, localPrincipal(u), "system.init", "user "+u.Username)
fmt.Printf("Created the administrator %q in %s.\n", u.Username, a.cfg.Database)
if withToken {
tok, err := createToken(ctx, a.store, u, "cli")
if err != nil {
return err
}
fmt.Printf("\nAPI token for the CLI (shown only once):\n\n %s\n", tok)
}
fmt.Println("\nNext steps:")
fmt.Println(" 1. Start the daemon: sudo systemctl enable --now steward")
fmt.Println(" 2. Add a subnet: sudo steward subnet add 10.0.10.0/24 --interface eth0 --pool 10.0.10.100-10.0.10.199 --option routers=10.0.10.1")
fmt.Println(" or import Kea's setup: sudo steward import kea /etc/kea/kea-dhcp4.conf --leases /var/lib/kea/kea-leases4.csv")
fmt.Printf(" 3. Open the web UI: http://%s/ (see listen in %s)\n", a.cfg.Listen[0], configPath)
return nil
},
}
cmd.Flags().StringVarP(&username, "user", "u", "admin", "the administrator's name (best your own: each person should have their own account)")
cmd.Flags().BoolVar(&stdin, "password-stdin", false, "read the password from standard input")
cmd.Flags().BoolVar(&withToken, "token", false, "also make an API token for the CLI")
return cmd
}
func userCmd() *cobra.Command {
cmd := &cobra.Command{Use: "user", Short: "Manage users directly in the database (when the API can't be used)"}
var admin, stdin bool
add := &cobra.Command{
Use: "add NAME", Short: "Add a user", Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
a, err := openApp(true)
if err != nil {
return err
}
defer a.store.Close()
if err := server.ValidUsername(args[0]); err != nil {
return err
}
pw, err := readPassword("Password for "+args[0]+": ", stdin)
if err != nil {
return err
}
hash, err := auth.HashPassword(pw)
if err != nil {
return err
}
u := &model.User{Username: args[0], PasswordHash: hash, Admin: admin}
if err := a.store.CreateUser(context.Background(), u); err != nil {
if errors.Is(err, store.ErrConflict) {
return fmt.Errorf("user %s already exists", args[0])
}
return err
}
audit(context.Background(), a.store, localPrincipal(&model.User{Username: "root"}), "user.create", "user "+u.Username)
fmt.Printf("added user %s\n", u.Username)
return nil
},
}
add.Flags().BoolVar(&admin, "admin", false, "an administrator")
add.Flags().BoolVar(&stdin, "password-stdin", false, "read the password from standard input")
cmd.AddCommand(add)
pw := &cobra.Command{
Use: "password NAME", Short: "Set a user's password (and log them out)", Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
a, err := openApp(true)
if err != nil {
return err
}
defer a.store.Close()
ctx := context.Background()
u, err := a.store.GetUserByName(ctx, args[0])
if err != nil {
return fmt.Errorf("no user %s", args[0])
}
p, err := readPassword("New password for "+u.Username+": ", stdin)
if err != nil {
return err
}
if u.PasswordHash, err = auth.HashPassword(p); err != nil {
return err
}
u.Disabled = false
if err := a.store.UpdateUser(ctx, u); err != nil {
return err
}
a.store.DeleteUserSessions(ctx, u.ID, "")
audit(ctx, a.store, localPrincipal(&model.User{Username: "root"}), "user.password", "user "+u.Username)
fmt.Printf("set the password of %s\n", u.Username)
return nil
},
}
pw.Flags().BoolVar(&stdin, "password-stdin", false, "read the password from standard input")
cmd.AddCommand(pw)
cmd.AddCommand(&cobra.Command{
Use: "token NAME TOKEN-NAME", Short: "Make an API token for a user (printed once)", Args: cobra.ExactArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
a, err := openApp(true)
if err != nil {
return err
}
defer a.store.Close()
ctx := context.Background()
u, err := a.store.GetUserByName(ctx, args[0])
if err != nil {
return fmt.Errorf("no user %s", args[0])
}
tok, err := createToken(ctx, a.store, u, args[1])
if err != nil {
return err
}
audit(ctx, a.store, localPrincipal(&model.User{Username: "root"}), "token.create", "token "+u.Username+"/"+args[1])
fmt.Println(tok)
return nil
},
})
return cmd
}
func checkCmd() *cobra.Command {
return &cobra.Command{
Use: "check",
Short: "Check the configuration in the database against this machine",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
a, err := openApp(false)
if err != nil {
return err
}
defer a.store.Close()
eng, err := engine.New(context.Background(), a.store, a.log)
if err != nil {
return err
}
eng.Env = hostEnv(&dhcpd.HostInterfaces{})
ps := eng.Problems()
for _, p := range ps {
fmt.Printf("%s: %s: %s [%s]\n", p.Severity, p.Object, p.Message, p.Code)
}
if validate.HasErrors(ps) {
return errors.New("the configuration has errors")
}
if len(ps) == 0 {
fmt.Println("ok: no problems")
}
return nil
},
}
}
// ---- backups
// backupName is a backup's file name; the time in it orders and prunes
// backups.
const backupPrefix, backupSuffix = "steward-", ".db"
func backupCmd() *cobra.Command {
return &cobra.Command{
Use: "backup [FILE]",
Short: "Write a backup of the database (and delete old ones)",
Long: `Writes a consistent copy of the database, also while stewardd runs. Without
FILE it goes to backup.dir as steward-<time>.db, and backups older than
backup.retention are deleted (keeping at least backup.keep_at_least).`,
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
cfg, err := config.Load(configPath, true)
if err != nil {
return err
}
if _, err := os.Stat(cfg.Database); err != nil {
return fmt.Errorf("no database at %s yet (run 'stewardd init')", cfg.Database)
}
st, err := store.OpenExisting(cfg.Database)
if err != nil {
return err
}
defer st.Close()
dest := ""
if len(args) == 1 {
dest = args[0]
} else {
if err := os.MkdirAll(cfg.Backup.Dir, 0o700); err != nil {
return err
}
dest = filepath.Join(cfg.Backup.Dir, backupPrefix+time.Now().UTC().Format("20060102T150405Z")+backupSuffix)
}
tmp := dest + ".tmp"
os.Remove(tmp)
if err := st.Backup(context.Background(), tmp); err != nil {
return fmt.Errorf("writing the backup: %w", err)
}
if err := os.Rename(tmp, dest); err != nil {
return err
}
fmt.Println(dest)
if len(args) == 0 {
return prune(cfg)
}
return nil
},
}
}
func prune(cfg *config.Config) error {
entries, err := os.ReadDir(cfg.Backup.Dir)
if err != nil {
return err
}
type bk struct {
name string
t time.Time
}
var list []bk
for _, e := range entries {
n := e.Name()
if !strings.HasPrefix(n, backupPrefix) || !strings.HasSuffix(n, backupSuffix) {
continue
}
t, err := time.Parse("20060102T150405Z", strings.TrimSuffix(strings.TrimPrefix(n, backupPrefix), backupSuffix))
if err != nil {
continue
}
list = append(list, bk{n, t})
}
sort.Slice(list, func(i, j int) bool { return list[i].t.After(list[j].t) })
cut := time.Now().Add(-time.Duration(cfg.Backup.Retention))
for i, b := range list {
if i < cfg.Backup.KeepAtLeast || b.t.After(cut) {
continue
}
os.Remove(filepath.Join(cfg.Backup.Dir, b.name))
}
return nil
}
func restoreCmd() *cobra.Command {
var yes bool
cmd := &cobra.Command{
Use: "restore FILE",
Short: "Replace the database with a backup (stewardd must be stopped)",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
cfg, err := config.Load(configPath, true)
if err != nil {
return err
}
release, err := lockDatabase(cfg.Database)
if err != nil {
return fmt.Errorf("%w; stop it first: systemctl stop steward", err)
}
defer release()
src, err := store.OpenExisting(args[0])
if err != nil {
return fmt.Errorf("%s isn't a database: %w", args[0], err)
}
v, err := src.Version(context.Background())
if err != nil {
src.Close()
return fmt.Errorf("%s isn't a Steward database: %w", args[0], err)
}
var ok string
if err := src.DB().QueryRow(`PRAGMA integrity_check`).Scan(&ok); err != nil || ok != "ok" {
src.Close()
return fmt.Errorf("%s fails SQLite's integrity check: %s %v", args[0], ok, err)
}
src.Close()
if v > store.SchemaVersion() {
return fmt.Errorf("the backup is from a newer Steward (schema %d, this one has %d)", v, store.SchemaVersion())
}
if !yes {
fmt.Fprintf(os.Stderr, "Replace %s with %s? [y/N] ", cfg.Database, args[0])
var a string
fmt.Scanln(&a)
if a != "y" && a != "yes" {
return errors.New("not restored")
}
}
stamp := time.Now().UTC().Format("20060102T150405Z")
for _, ext := range []string{"", "-wal", "-shm"} {
if _, err := os.Stat(cfg.Database + ext); err == nil {
if err := os.Rename(cfg.Database+ext, cfg.Database+".before-restore-"+stamp+ext); err != nil {
return err
}
}
}
b, err := os.ReadFile(args[0])
if err != nil {
return err
}
if err := os.WriteFile(cfg.Database, b, 0o640); err != nil {
return err
}
st, err := store.Open(cfg.Database)
if err != nil {
return err
}
defer st.Close()
audit(context.Background(), st, localPrincipal(&model.User{Username: "root"}), "system.restore", args[0])
fmt.Printf("restored %s; the old database is %s.before-restore-%s\n", args[0], cfg.Database, stamp)
return nil
},
}
cmd.Flags().BoolVarP(&yes, "yes", "y", false, "don't ask")
return cmd
}
func configCmd() *cobra.Command {
var example bool
cmd := &cobra.Command{
Use: "config", Short: "Show the configuration in use (--example: a commented example)", Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
if example {
fmt.Print(config.Example)
return nil
}
cfg, err := config.Load(configPath, true)
if err != nil {
return err
}
fmt.Printf("listen: %s\nsocket: %s\ndatabase: %s\nbackups: %s\n", strings.Join(cfg.Listen, ", "), cfg.Socket, cfg.Database, cfg.Backup.Dir)
return nil
},
}
cmd.Flags().BoolVar(&example, "example", false, "print a commented example configuration")
return cmd
}
func versionCmd() *cobra.Command {
return &cobra.Command{
Use: "version", Short: "Print the version", Args: cobra.NoArgs,
Run: func(cmd *cobra.Command, args []string) { fmt.Println("stewardd", version.String()) },
}
}
+16
View File
@@ -0,0 +1,16 @@
Package: steward-cli
Version: @VERSION@
Architecture: @ARCH@
Maintainer: @MAINTAINER@
Installed-Size: @SIZE@
Section: net
Priority: optional
Description: command line client for Steward, the DHCP server
steward is the command line client for Steward servers (stewardd):
subnets, pools, reservations, options, leases and DHCP activity, the
history of changes with undo, users and tokens, and importing Kea.
It talks to stewardd's REST API with an API token, or, on the server
itself, to stewardd's local socket.
.
Install this package alone to manage Steward from another machine; the
steward package (the server) depends on it.
+21
View File
@@ -0,0 +1,21 @@
Package: steward
Version: @VERSION@
Architecture: @ARCH@
Maintainer: @MAINTAINER@
Installed-Size: @SIZE@
Depends: systemd, steward-cli (= @VERSION@)
Section: net
Priority: optional
Description: DHCPv4 server with a web UI, a REST API and a CLI
Steward is a DHCPv4 server: subnets served per network interface, pools,
reservations and options, with leases kept in SQLite. It is configured
through a web UI, a REST API and the steward command line client. Every
change is validated as a whole before it is used, and recorded in a
history where it can be undone. Users and API tokens have permissions,
and every change is audited. It can import a Kea configuration and its
leases.
.
This package is the server: stewardd (as the systemd service steward)
with its daily backup timer. The steward CLI comes from steward-cli,
which can also be installed on its own on other machines. stewardd isn't
started on a first install: see /usr/share/doc/steward/docs/install.md.
+57
View File
@@ -0,0 +1,57 @@
#!/bin/sh
# Steward: runs after the package's files are in place.
set -e
if [ "$1" = configure ]; then
# The steward system user and Steward's directories: /etc/steward,
# /var/lib/steward and /var/backups/steward.
systemd-sysusers steward.conf
systemd-tmpfiles --create steward.conf || true
# An example configuration on the first install; an existing one is
# never touched.
if [ ! -e /etc/steward/steward.yaml ]; then
/usr/bin/stewardd config --example > /etc/steward/steward.yaml.dpkg-new
chown root:steward /etc/steward/steward.yaml.dpkg-new
chmod 0640 /etc/steward/steward.yaml.dpkg-new
mv /etc/steward/steward.yaml.dpkg-new /etc/steward/steward.yaml
fi
# The daily database backup (steward-backup.timer) is on by default. An
# administrator's 'systemctl disable steward-backup.timer' is remembered
# by deb-systemd-helper and kept across upgrades. Before 'stewardd init'
# the backup skips itself (there is no database yet).
if command -v deb-systemd-helper >/dev/null 2>&1; then
deb-systemd-helper unmask steward-backup.timer >/dev/null || true
if deb-systemd-helper --quiet was-enabled steward-backup.timer; then
deb-systemd-helper enable steward-backup.timer >/dev/null || true
else
deb-systemd-helper update-state steward-backup.timer >/dev/null || true
fi
elif [ -z "$2" ] && [ -d /run/systemd/system ]; then
systemctl enable steward-backup.timer >/dev/null 2>&1 || true
fi
if [ -d /run/systemd/system ]; then
systemctl daemon-reload || true
if systemctl is-enabled --quiet steward-backup.timer 2>/dev/null; then
systemctl start steward-backup.timer || true
fi
# An upgrade restarts a running stewardd (its database is upgraded
# when it starts). A first install starts nothing: Steward needs
# 'stewardd init' first.
if [ -n "$2" ] && systemctl is-active --quiet steward.service; then
systemctl restart steward.service || echo "steward: restarting stewardd failed; see 'journalctl -u steward'" >&2
fi
fi
if [ -z "$2" ]; then
echo "Steward is installed. Next: create the database and start it, as described"
echo "in /usr/share/doc/steward/docs/install.md:"
echo " sudo -u steward stewardd init && sudo systemctl enable --now steward"
echo "Stop any other DHCP server first (Kea, dnsmasq, isc-dhcp-server): only one"
echo "can listen on an interface's port 67. 'steward import kea' brings Kea's setup over."
fi
fi
exit 0
+35
View File
@@ -0,0 +1,35 @@
#!/bin/sh
# Steward: runs after the package's files are removed.
set -e
case "$1" in
remove|purge)
if [ -d /run/systemd/system ]; then
systemctl daemon-reload || true
fi
;;
esac
# The backup timer's enabled state (see postinst): masked while the
# package is removed, forgotten on purge.
if command -v deb-systemd-helper >/dev/null 2>&1; then
case "$1" in
remove)
deb-systemd-helper mask steward-backup.timer >/dev/null || true
;;
purge)
deb-systemd-helper purge steward-backup.timer >/dev/null || true
deb-systemd-helper unmask steward-backup.timer >/dev/null || true
;;
esac
fi
# Even a purge keeps Steward's data: the database in /var/lib/steward holds
# the configuration and the leases. Remove it by hand when you no longer
# need it.
if [ "$1" = purge ]; then
echo "steward: kept /etc/steward, /var/lib/steward and /var/backups/steward;"
echo "remove them by hand if unused."
fi
exit 0
+16
View File
@@ -0,0 +1,16 @@
#!/bin/sh
# Steward: runs before the package's files are removed (also on upgrades).
set -e
# On removal (not on upgrade) stop stewardd and the backup timer and remove
# stewardd's boot-time link (postrm deals with the timer's). Clients keep
# their addresses until their leases run out.
if [ "$1" = remove ] && [ -d /run/systemd/system ]; then
systemctl stop steward.service steward-backup.timer steward-backup.service 2>/dev/null || true
systemctl disable steward.service 2>/dev/null || true
if ! command -v deb-systemd-helper >/dev/null 2>&1; then
systemctl disable steward-backup.timer 2>/dev/null || true
fi
fi
exit 0
+44
View File
@@ -0,0 +1,44 @@
# Steward: one database backup, run daily by steward-backup.timer.
# Installed as /etc/systemd/system/steward-backup.service (see
# docs/operations.md#backups). What to keep, and where, is in the backup:
# section of /etc/steward/steward.yaml.
[Unit]
Description=Steward database backup
Documentation=file:///usr/local/share/doc/steward/docs/operations.md
# Nothing to back up before 'stewardd init'.
ConditionPathExists=/var/lib/steward/steward.db
[Service]
Type=oneshot
ExecStart=/usr/local/bin/stewardd --config /etc/steward/steward.yaml backup
User=steward
Group=steward
UMask=0077
Nice=10
IOSchedulingClass=idle
# If you change backup.dir in steward.yaml, allow the new one too:
# 'systemctl edit steward-backup.service' and add ReadWritePaths=/new/dir.
ProtectSystem=strict
ReadWritePaths=/var/lib/steward -/var/backups/steward
ProtectHome=yes
PrivateTmp=yes
PrivateDevices=yes
PrivateNetwork=yes
NoNewPrivileges=yes
CapabilityBoundingSet=
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectKernelLogs=yes
ProtectControlGroups=yes
ProtectClock=yes
ProtectHostname=yes
RestrictNamespaces=yes
RestrictRealtime=yes
RestrictSUIDSGID=yes
LockPersonality=yes
MemoryDenyWriteExecute=yes
RestrictAddressFamilies=AF_UNIX
SystemCallArchitectures=native
SystemCallFilter=@system-service
SystemCallErrorNumber=EPERM
+20
View File
@@ -0,0 +1,20 @@
# Steward: runs steward-backup.service (a database backup) every day.
# Installed as /etc/systemd/system/steward-backup.timer. Change the schedule
# with 'systemctl edit steward-backup.timer', e.g.
# [Timer]
# OnCalendar=
# OnCalendar=*-*-* 02:30
# and keep backup.retention in steward.yaml above the new interval.
[Unit]
Description=Daily Steward database backup
Documentation=file:///usr/local/share/doc/steward/docs/operations.md
[Timer]
OnCalendar=daily
# Spread the start over an hour, and catch up on a run missed while the
# machine was off.
RandomizedDelaySec=1h
Persistent=true
[Install]
WantedBy=timers.target
+63
View File
@@ -0,0 +1,63 @@
# Steward: the DHCP server, its REST API and web UI.
# Installed as /etc/systemd/system/steward.service (see docs/install.md).
[Unit]
Description=Steward DHCP server (with web UI and API)
Documentation=file:///usr/local/share/doc/steward/README.md
After=network-online.target
Wants=network-online.target
[Service]
Type=notify
NotifyAccess=main
ExecStart=/usr/local/bin/stewardd --config /etc/steward/steward.yaml serve
# SIGHUP rereads the TLS certificate.
ExecReload=/bin/kill -HUP $MAINPID
Restart=on-failure
RestartSec=2s
User=steward
Group=steward
UMask=0027
# /var/lib/steward: the database (configuration and leases).
StateDirectory=steward
StateDirectoryMode=0750
ConfigurationDirectory=steward
ConfigurationDirectoryMode=0750
# /run/steward: the local API socket (steward.sock), for root and the
# steward user.
RuntimeDirectory=steward
RuntimeDirectoryMode=0750
# Sandboxing: stewardd writes only its own state.
ProtectSystem=strict
ProtectHome=yes
PrivateTmp=yes
PrivateDevices=yes
NoNewPrivileges=yes
# The only privilege stewardd gets: listening on ports below 1024 (DHCP's
# port 67). Binding each socket to its interface (SO_BINDTODEVICE) and the
# ping check (an unprivileged ping socket) need nothing more.
CapabilityBoundingSet=CAP_NET_BIND_SERVICE
AmbientCapabilities=CAP_NET_BIND_SERVICE
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectKernelLogs=yes
ProtectControlGroups=yes
ProtectClock=yes
ProtectHostname=yes
RestrictNamespaces=yes
RestrictRealtime=yes
RestrictSUIDSGID=yes
LockPersonality=yes
MemoryDenyWriteExecute=yes
# AF_NETLINK lets stewardd read the interfaces and their addresses.
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 AF_NETLINK
SystemCallArchitectures=native
SystemCallFilter=@system-service
SystemCallErrorNumber=EPERM
[Install]
WantedBy=multi-user.target
# 'systemctl restart stewardd' works too (the daemon's binary is stewardd).
Alias=stewardd.service
+2
View File
@@ -0,0 +1,2 @@
# Steward's system user (installed as /usr/lib/sysusers.d/steward.conf).
u steward - "Steward DHCP server" /var/lib/steward
+8
View File
@@ -0,0 +1,8 @@
# Directories Steward needs (installed as /usr/lib/tmpfiles.d/steward.conf).
# Apply with: systemd-tmpfiles --create steward.conf
#
# Type Path Mode User Group
d /etc/steward 0750 root steward
d /var/lib/steward 0750 steward steward
# Database backups (steward-backup.timer; backup.dir in steward.yaml).
d /var/backups/steward 0700 steward steward
+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 |
+23
View File
@@ -0,0 +1,23 @@
module steward
go 1.27.1
require (
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/mattn/go-isatty v0.0.24 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/spf13/cobra v1.10.2 // indirect
github.com/spf13/pflag v1.0.9 // indirect
go.yaml.in/yaml/v3 v3.0.5 // indirect
golang.org/x/crypto v0.57.0 // indirect
golang.org/x/net v0.58.0 // indirect
golang.org/x/sys v0.48.0 // indirect
golang.org/x/term v0.46.0 // indirect
modernc.org/libc v1.77.1 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.12.1 // indirect
modernc.org/sqlite v1.60.1 // indirect
)
+38
View File
@@ -0,0 +1,38 @@
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M=
golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA=
golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To=
golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU=
golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
golang.org/x/term v0.46.0 h1:3+OXuTbaKDgwk8jTi3aSLHRlmWqHEUDUtxnbFigO4YE=
golang.org/x/term v0.46.0/go.mod h1:+K02xbkittuwc0Am4abfA3Fc+XRGXkvBXNO88NCXPoc=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
modernc.org/libc v1.77.1 h1:Ct8j47QtiZ1Enj2DtFXQtUqrPCAjdCmPjtCuvrYQ0Hs=
modernc.org/libc v1.77.1/go.mod h1:87/pZ4L6nD1zqW4nItuS12YO7hN1igAah34xjnQo/W0=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.12.1 h1:nFMiWrpStgZczNl6XI9GnIk/rWhYIyHGUaR04pGbp9g=
modernc.org/memory v1.12.1/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/sqlite v1.60.1 h1:/blz53O951KWFOso4QQvEs/Fq6cDBKLtMVrYNSeJVKw=
modernc.org/sqlite v1.60.1/go.mod h1:1dIoEagfDE72QytD5scH1lxARtaUgKgHC/NuApA27r0=
+246
View File
@@ -0,0 +1,246 @@
// Package api holds the JSON shapes of Steward's REST API, shared by the
// server and the Go client (and so the CLI).
package api
import (
"encoding/json"
"time"
"steward/internal/model"
"steward/internal/validate"
"steward/internal/version"
)
// Prefix starts every API path.
const Prefix = "/api/v1"
// ErrorBody is every error response.
type ErrorBody struct {
Error ErrorInfo `json:"error"`
}
// ErrorInfo describes an error.
type ErrorInfo struct {
Code string `json:"code"`
Message string `json:"message"`
Problems []validate.Problem `json:"problems,omitempty"`
// Result is the preview of a refused change.
Result json.RawMessage `json:"result,omitempty"`
}
// Whoami describes the caller.
type Whoami struct {
User *model.User `json:"user"`
Admin bool `json:"admin"`
Method string `json:"method"`
Permissions []string `json:"permissions"`
CSRFToken string `json:"csrf_token,omitempty"`
Build version.Build `json:"build"`
}
// Login is the login request.
type Login struct {
Username string `json:"username"`
Password string `json:"password"`
}
// PasswordChange changes the caller's password.
type PasswordChange struct {
Current string `json:"current"`
Password string `json:"password"`
}
// OptionInput sets an option: by name ("routers") or number ("43"), with
// a type for numbers the catalog doesn't know (default hex).
type OptionInput struct {
Option string `json:"option"`
Value string `json:"value"`
Type string `json:"type,omitempty"`
}
// SettingsInput changes the settings (unset fields stay).
type SettingsInput struct {
LeaseTime *int `json:"lease_time,omitempty"`
Options *[]OptionInput `json:"options,omitempty"`
PingCheck *bool `json:"ping_check,omitempty"`
PingTimeout *int `json:"ping_timeout_ms,omitempty"`
DeclineHold *int `json:"decline_hold,omitempty"`
NotAuthoritative *bool `json:"not_authoritative,omitempty"`
}
// SubnetInput creates or changes a subnet (unset fields stay). Pools are
// "FIRST-LAST" or prefixes.
type SubnetInput struct {
Name *string `json:"name,omitempty"`
Interface *string `json:"interface,omitempty"`
Prefix *string `json:"prefix,omitempty"`
Pools *[]string `json:"pools,omitempty"`
LeaseTime *int `json:"lease_time,omitempty"`
Options *[]OptionInput `json:"options,omitempty"`
Disabled *bool `json:"disabled,omitempty"`
Comment *string `json:"comment,omitempty"`
}
// ReservationInput creates or changes a reservation (unset fields stay).
type ReservationInput struct {
MAC *string `json:"mac,omitempty"`
ClientID *string `json:"client_id,omitempty"`
IP *string `json:"ip,omitempty"`
Hostname *string `json:"hostname,omitempty"`
Options *[]OptionInput `json:"options,omitempty"`
Comment *string `json:"comment,omitempty"`
}
// Subnet is a subnet with its usage.
type Subnet struct {
*model.Subnet
Usage Usage `json:"usage"`
}
// Usage counts a subnet's addresses.
type Usage struct {
// PoolSize is the number of addresses in the pools; Leased of them
// are held by clients, Declined held back.
PoolSize int `json:"pool_size"`
Leased int `json:"leased"`
Declined int `json:"declined"`
// Reservations in the subnet, and how many of them have a lease now.
Reservations int `json:"reservations"`
ReservationsLeased int `json:"reservations_leased"`
}
// Reservation is a reservation with where it is.
type Reservation struct {
*model.Reservation
SubnetID int64 `json:"subnet_id"`
SubnetName string `json:"subnet"`
// Lease is the reservation's current lease, if any.
Lease *Lease `json:"lease,omitempty"`
}
// Lease is a lease with its state now.
type Lease struct {
*model.Lease
SubnetName string `json:"subnet"`
// Reservation is the ID of the reservation for the address, if any.
Reservation int64 `json:"reservation,omitempty"`
// ReservedHostname is the reservation's host name.
ReservedHostname string `json:"reserved_hostname,omitempty"`
}
// MutationResponse answers a configuration change.
type MutationResponse struct {
Applied bool `json:"applied"`
DryRun bool `json:"dry_run,omitempty"`
HistoryID int64 `json:"history_id,omitempty"`
Changes []model.Change `json:"changes"`
Warnings []validate.Problem `json:"warnings,omitempty"`
// Object is the created or changed object.
Object json.RawMessage `json:"object,omitempty"`
}
// HostInterface is one of the server's network interfaces.
type HostInterface struct {
Name string `json:"name"`
Addresses []string `json:"addresses"`
// Subnets served on it.
Subnets []int64 `json:"subnets"`
}
// InterfaceStatus says whether DHCP is served on an interface.
type InterfaceStatus struct {
Name string `json:"name"`
Listening bool `json:"listening"`
Error string `json:"error,omitempty"`
Since time.Time `json:"since"`
Addresses []string `json:"addresses"`
Subnets []int64 `json:"subnets"`
}
// Status is the dashboard's overview.
type Status struct {
Build version.Build `json:"build"`
Started time.Time `json:"started"`
Interfaces []InterfaceStatus `json:"interfaces"`
Subnets []Subnet `json:"subnets"`
Problems []validate.Problem `json:"problems"`
// Leases counts leases by state.
Leases map[string]int `json:"leases"`
// Messages counts DHCP messages since the start, by type.
Messages map[string]uint64 `json:"messages"`
// PingProblem says why addresses can't be pinged before an offer.
PingProblem string `json:"ping_problem,omitempty"`
Database string `json:"database"`
}
// TokenInput creates a token.
type TokenInput struct {
Name string `json:"name"`
// Grants ("leases:read") or a role restrict the token; none: all of
// the user's permissions.
Grants []string `json:"grants,omitempty"`
Role string `json:"role,omitempty"`
ExpiresIn string `json:"expires_in,omitempty"` // 90d, 720h
ExpiresAt *time.Time `json:"expires_at,omitempty"`
}
// Token is a token; Secret is only set when it is created.
type Token struct {
*model.Token
GrantStrings []string `json:"grant_strings"`
Secret string `json:"secret,omitempty"`
}
// UserInput creates or changes a user.
type UserInput struct {
Username *string `json:"username,omitempty"`
Password *string `json:"password,omitempty"`
Admin *bool `json:"admin,omitempty"`
Grants *[]string `json:"grants,omitempty"`
Role *string `json:"role,omitempty"`
Disabled *bool `json:"disabled,omitempty"`
Comment *string `json:"comment,omitempty"`
}
// User is a user with grants written out.
type User struct {
*model.User
GrantStrings []string `json:"grant_strings"`
}
// OptionDef is an option of the catalog.
type OptionDef struct {
Code int `json:"code"`
Name string `json:"name"`
Type string `json:"type"`
Help string `json:"help"`
Common bool `json:"common,omitempty"`
}
// Catalog lists the options Steward knows, the value types and the codes
// that can't be set.
type Catalog struct {
Options []OptionDef `json:"options"`
Types []string `json:"types"`
Reserved map[int]string `json:"reserved"`
Roles map[string][]string `json:"roles"`
}
// Event is a DHCP message in or out.
type Event struct {
Time time.Time `json:"time"`
Interface string `json:"interface"`
Type string `json:"type"`
MAC string `json:"mac"`
IP string `json:"ip,omitempty"`
Hostname string `json:"hostname,omitempty"`
Detail string `json:"detail,omitempty"`
}
// ImportResult reports a Kea import.
type ImportResult struct {
MutationResponse
Leases int `json:"leases"`
Notes []string `json:"notes"`
Skipped []string `json:"skipped"`
}
+281
View File
@@ -0,0 +1,281 @@
// Package auth handles passwords, API tokens, sessions and permissions.
package auth
import (
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"encoding/base32"
"encoding/base64"
"encoding/hex"
"errors"
"fmt"
"sort"
"strconv"
"strings"
"golang.org/x/crypto/argon2"
"steward/internal/model"
)
// Argon2id parameters (OWASP: m=19 MiB, t=2, p=1).
const (
argonTime = 2
argonMemory = 19 * 1024
argonThreads = 1
argonKeyLen = 32
saltLen = 16
)
// MinPasswordLength is the shortest accepted password.
const MinPasswordLength = 8
// HashPassword returns an argon2id hash in PHC string format.
func HashPassword(password string) (string, error) {
if len(password) < MinPasswordLength {
return "", fmt.Errorf("a password needs at least %d characters", MinPasswordLength)
}
salt := make([]byte, saltLen)
if _, err := rand.Read(salt); err != nil {
return "", err
}
key := argon2.IDKey([]byte(password), salt, argonTime, argonMemory, argonThreads, argonKeyLen)
return fmt.Sprintf("$argon2id$v=%d$m=%d,t=%d,p=%d$%s$%s", argon2.Version, argonMemory, argonTime, argonThreads,
base64.RawStdEncoding.EncodeToString(salt), base64.RawStdEncoding.EncodeToString(key)), nil
}
// dummyHash is checked when a user doesn't exist, so a login takes as
// long whether or not the username is right.
var dummyHash, _ = HashPassword("not-a-real-password")
// CheckPassword reports whether password matches hash. An empty hash never
// matches (in the same time).
func CheckPassword(hash, password string) bool {
if hash == "" {
hash = dummyHash
password = "\x00" + password
}
parts := strings.Split(hash, "$")
if len(parts) != 6 || parts[1] != "argon2id" {
return false
}
var m, t uint32
var p uint8
for _, kv := range strings.Split(parts[3], ",") {
k, v, _ := strings.Cut(kv, "=")
n, err := strconv.ParseUint(v, 10, 32)
if err != nil {
return false
}
switch k {
case "m":
m = uint32(n)
case "t":
t = uint32(n)
case "p":
p = uint8(n)
}
}
salt, err1 := base64.RawStdEncoding.DecodeString(parts[4])
want, err2 := base64.RawStdEncoding.DecodeString(parts[5])
if err1 != nil || err2 != nil || m == 0 || t == 0 || p == 0 {
return false
}
got := argon2.IDKey([]byte(password), salt, t, m, p, uint32(len(want)))
return subtle.ConstantTimeCompare(got, want) == 1
}
var b32 = base32.NewEncoding("abcdefghijklmnopqrstuvwxyz234567").WithPadding(base32.NoPadding)
// RandomString returns a random string with nbytes of entropy.
func RandomString(nbytes int) string {
b := make([]byte, nbytes)
if _, err := rand.Read(b); err != nil {
panic(err)
}
return b32.EncodeToString(b)
}
// TokenPrefix starts every API token, so leaked ones are easy to spot.
const TokenPrefix = "steward_"
// NewToken makes a token: the whole string to give out, its public ID
// (stored in clear) and the hash of its secret.
func NewToken() (token, publicID, secretHash string) {
publicID = RandomString(10) // 16 characters
secret := RandomString(20) // 32 characters
return TokenPrefix + publicID + "_" + secret, publicID, HashSecret(secret)
}
// ParseToken splits a token into its public ID and secret.
func ParseToken(token string) (publicID, secret string, err error) {
rest, ok := strings.CutPrefix(strings.TrimSpace(token), TokenPrefix)
if !ok {
return "", "", errors.New("not a Steward token (they start with " + TokenPrefix + ")")
}
publicID, secret, ok = strings.Cut(rest, "_")
if !ok || len(publicID) != 16 || len(secret) != 32 {
return "", "", errors.New("malformed token")
}
return publicID, secret, nil
}
// HashSecret hashes a high-entropy secret (token secrets, session IDs).
func HashSecret(secret string) string {
h := sha256.Sum256([]byte(secret))
return hex.EncodeToString(h[:])
}
// CheckSecret compares a secret with a stored hash in constant time.
func CheckSecret(secret, hash string) bool {
return subtle.ConstantTimeCompare([]byte(HashSecret(secret)), []byte(hash)) == 1
}
// NewSessionID returns a session ID and its CSRF token.
func NewSessionID() (id, csrf string) {
return RandomString(32), RandomString(32)
}
// Resources grants apply to.
const (
ResConfig = "config" // settings, subnets, reservations, history
ResLeases = "leases" // leases and DHCP activity
ResAudit = "audit" // the audit log
ResAll = "*"
)
// Resources lists them in display order.
var Resources = []string{ResConfig, ResLeases, ResAudit}
// Access levels.
const (
Read = "read"
Write = "write"
)
// Roles are ready-made sets of grants.
var Roles = map[string][]model.Grant{
"viewer": {{Resource: ResConfig, Access: Read}, {Resource: ResLeases, Access: Read}},
"operator": {{Resource: ResConfig, Access: Write}, {Resource: ResLeases, Access: Write}, {Resource: ResAudit, Access: Read}},
"leases": {{Resource: ResLeases, Access: Read}},
}
// RoleNames lists the roles.
func RoleNames() []string {
var out []string
for n := range Roles {
out = append(out, n)
}
sort.Strings(out)
return out
}
// ParseGrant reads "resource:access" ("leases:read", "*:write").
func ParseGrant(s string) (model.Grant, error) {
res, acc, ok := strings.Cut(strings.ToLower(strings.TrimSpace(s)), ":")
g := model.Grant{Resource: res, Access: acc}
if !ok {
return g, fmt.Errorf("grant %q: write it as resource:access, e.g. leases:read", s)
}
return g, ValidateGrant(&g)
}
// ValidateGrant checks a grant.
func ValidateGrant(g *model.Grant) error {
g.Resource = strings.ToLower(strings.TrimSpace(g.Resource))
g.Access = strings.ToLower(strings.TrimSpace(g.Access))
ok := g.Resource == ResAll
for _, r := range Resources {
ok = ok || g.Resource == r
}
if !ok {
return fmt.Errorf("unknown resource %q (use %s or *)", g.Resource, strings.Join(Resources, ", "))
}
if g.Access != Read && g.Access != Write {
return fmt.Errorf("access must be read or write, not %q", g.Access)
}
return nil
}
// FormatGrant writes a grant as "resource:access".
func FormatGrant(g model.Grant) string { return g.Resource + ":" + g.Access }
// Principal is who makes a request.
type Principal struct {
User *model.User
Token *model.Token
Method string // session, token, socket
IP string
Agent string
}
// Name is the user's name.
func (p *Principal) Name() string {
if p == nil || p.User == nil {
return ""
}
return p.User.Username
}
// IsAdmin reports whether the principal has full rights: an
// administrator, not through a restricted token.
func (p *Principal) IsAdmin() bool {
return p.User != nil && p.User.Admin && (p.Token == nil || p.Token.Grants == nil)
}
// Can reports whether the principal may read (write false) or change
// (write true) a resource.
func (p *Principal) Can(resource string, write bool) bool {
if p == nil || p.User == nil {
return false
}
allowed := p.User.Admin || covers(p.User.Grants, resource, write)
if p.Token != nil && p.Token.Grants != nil {
allowed = allowed && covers(p.Token.Grants, resource, write)
}
return allowed
}
func covers(gs []model.Grant, resource string, write bool) bool {
for _, g := range gs {
if (g.Resource == ResAll || g.Resource == resource) && (!write || g.Access == Write) {
return true
}
}
return false
}
// Permissions lists what the principal may do, for display.
func (p *Principal) Permissions() []string {
var out []string
for _, r := range Resources {
switch {
case p.Can(r, true):
out = append(out, r+":write")
case p.Can(r, false):
out = append(out, r+":read")
}
}
if p.IsAdmin() {
out = append(out, "users:write")
}
return out
}
// Narrower reports whether every grant in a is covered by b.
func Narrower(a, b []model.Grant) bool {
for _, g := range a {
if !covers(b, g.Resource, g.Access == Write) {
if g.Resource != ResAll {
return false
}
for _, r := range Resources {
if !covers(b, r, g.Access == Write) {
return false
}
}
}
}
return true
}
+53
View File
@@ -0,0 +1,53 @@
package auth
import (
"testing"
"steward/internal/model"
)
func TestPasswords(t *testing.T) {
h, err := HashPassword("correct horse")
if err != nil {
t.Fatal(err)
}
if !CheckPassword(h, "correct horse") || CheckPassword(h, "wrong horse") || CheckPassword("", "") {
t.Error("password check")
}
if _, err := HashPassword("short"); err == nil {
t.Error("short password accepted")
}
}
func TestTokens(t *testing.T) {
tok, pid, hash := NewToken()
gotPID, secret, err := ParseToken(tok)
if err != nil || gotPID != pid || !CheckSecret(secret, hash) {
t.Fatalf("token %q: %v", tok, err)
}
if _, _, err := ParseToken("jarl_abc_def"); err == nil {
t.Error("a Jarl token was accepted")
}
}
func TestPermissions(t *testing.T) {
op := &model.User{Username: "op", Grants: Roles["operator"]}
p := &Principal{User: op}
if !p.Can(ResConfig, true) || !p.Can(ResAudit, false) || p.Can(ResAudit, true) || p.IsAdmin() {
t.Error("operator")
}
admin := &model.User{Username: "admin", Admin: true}
p = &Principal{User: admin, Token: &model.Token{Grants: []model.Grant{{Resource: ResLeases, Access: Read}}}}
if !p.Can(ResLeases, false) || p.Can(ResLeases, true) || p.Can(ResConfig, false) || p.IsAdmin() {
t.Error("restricted admin token")
}
if !Narrower(Roles["leases"], Roles["operator"]) || Narrower(Roles["operator"], Roles["viewer"]) {
t.Error("narrower")
}
if _, err := ParseGrant("leases"); err == nil {
t.Error("grant without access accepted")
}
if g, err := ParseGrant("Config:Write"); err != nil || g.Resource != "config" {
t.Errorf("grant %v %v", g, err)
}
}
+166
View File
@@ -0,0 +1,166 @@
// Package client is a small Go client for Steward's REST API, used by the
// steward CLI.
package client
import (
"bytes"
"context"
"crypto/tls"
"crypto/x509"
"encoding/json"
"fmt"
"io"
"net"
"net/http"
"net/url"
"os"
"strings"
"time"
"steward/internal/api"
"steward/internal/version"
)
// Client talks to a Steward server.
type Client struct {
BaseURL string
Token string
HTTP *http.Client
// DryRun previews changes instead of making them.
DryRun bool
}
// Options configure New.
type Options struct {
Server string // https://... or unix:///run/steward/steward.sock
Token string
Insecure bool // skip TLS certificate checks
CACert string // PEM file with an extra CA to trust
Timeout time.Duration
}
// New makes a client.
func New(o Options) (*Client, error) {
tr := http.DefaultTransport.(*http.Transport).Clone()
base := strings.TrimSuffix(o.Server, "/")
if path, ok := SocketPath(o.Server); ok {
tr.Proxy = nil
tr.DialContext = func(ctx context.Context, _, _ string) (net.Conn, error) {
return (&net.Dialer{}).DialContext(ctx, "unix", path)
}
base = "http://stewardd"
} else if u, err := url.Parse(o.Server); err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" {
return nil, fmt.Errorf("invalid server URL %q (use e.g. https://dhcp.example.com:8067, or unix:///run/steward/steward.sock)", o.Server)
}
tc := &tls.Config{MinVersion: tls.VersionTLS12, InsecureSkipVerify: o.Insecure} //nolint:gosec // the user's choice
if o.CACert != "" {
pem, err := os.ReadFile(o.CACert)
if err != nil {
return nil, err
}
pool, err := x509.SystemCertPool()
if err != nil || pool == nil {
pool = x509.NewCertPool()
}
if !pool.AppendCertsFromPEM(pem) {
return nil, fmt.Errorf("no certificates in %s", o.CACert)
}
tc.RootCAs = pool
}
tr.TLSClientConfig = tc
timeout := o.Timeout
if timeout == 0 {
timeout = 2 * time.Minute
}
return &Client{BaseURL: base, Token: o.Token, HTTP: &http.Client{Transport: tr, Timeout: timeout}}, nil
}
// SocketPath returns the path of a unix:// URL.
func SocketPath(server string) (string, bool) {
path, ok := strings.CutPrefix(server, "unix://")
if !ok || !strings.HasPrefix(path, "/") {
return "", false
}
return path, true
}
// Error is an API error.
type Error struct {
Status int
Info api.ErrorInfo
}
func (e *Error) Error() string {
if e.Info.Message != "" {
return e.Info.Message
}
return fmt.Sprintf("HTTP %d", e.Status)
}
// Do sends a request: body as JSON, the JSON answer into out (if not nil).
// Changes get dry_run=1 when the client's DryRun is set.
func (c *Client) Do(ctx context.Context, method, path string, query url.Values, body, out any) error {
if c.DryRun && method != http.MethodGet {
if query == nil {
query = url.Values{}
}
query.Set("dry_run", "1")
}
u := c.BaseURL + api.Prefix + path
if len(query) > 0 {
u += "?" + query.Encode()
}
var rd io.Reader
if body != nil {
b, err := json.Marshal(body)
if err != nil {
return err
}
rd = bytes.NewReader(b)
}
req, err := http.NewRequestWithContext(ctx, method, u, rd)
if err != nil {
return err
}
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
req.Header.Set("Accept", "application/json")
req.Header.Set("User-Agent", "steward-cli/"+version.Short())
if c.Token != "" {
req.Header.Set("Authorization", "Bearer "+c.Token)
}
resp, err := c.HTTP.Do(req)
if err != nil {
if c.BaseURL == "http://stewardd" {
return fmt.Errorf("can't reach stewardd on its local socket: %w (is stewardd running? 'systemctl status steward')", err)
}
return err
}
defer resp.Body.Close()
b, err := io.ReadAll(resp.Body)
if err != nil {
return err
}
if resp.StatusCode >= 400 {
e := &Error{Status: resp.StatusCode}
var eb api.ErrorBody
if json.Unmarshal(b, &eb) == nil && eb.Error.Message != "" {
e.Info = eb.Error
} else {
e.Info.Message = fmt.Sprintf("HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(b)))
}
return e
}
if out != nil && len(b) > 0 {
if err := json.Unmarshal(b, out); err != nil {
return fmt.Errorf("reading the answer: %w", err)
}
}
return nil
}
// Get is Do for GET requests.
func (c *Client) Get(ctx context.Context, path string, query url.Values, out any) error {
return c.Do(ctx, http.MethodGet, path, query, nil, out)
}
+238
View File
@@ -0,0 +1,238 @@
// Package config loads stewardd's configuration file. It only says how
// the daemon runs (where it listens, its files, logging); the DHCP
// configuration itself is in the database.
package config
import (
"bytes"
"fmt"
"net/netip"
"os"
"path/filepath"
"strings"
"time"
"go.yaml.in/yaml/v3"
)
// DefaultPath is where stewardd looks for its configuration.
const DefaultPath = "/etc/steward/steward.yaml"
// Config is the daemon configuration.
type Config struct {
// Listen are the web UI's and API's addresses.
Listen []string `yaml:"listen"`
TLS TLS `yaml:"tls"`
TrustedProxies []string `yaml:"trusted_proxies"`
// Socket is a local unix socket for the API, where root and
// stewardd's own user need no token ("" for none).
Socket string `yaml:"socket"`
Database string `yaml:"database"`
Log Log `yaml:"log"`
Session Session `yaml:"session"`
Backup Backup `yaml:"backup"`
}
// TLS is a certificate for HTTPS.
type TLS struct {
CertFile string `yaml:"cert_file"`
KeyFile string `yaml:"key_file"`
}
// Log configures logging to stderr (the journal under systemd).
type Log struct {
Level string `yaml:"level"` // debug, info, warn, error
Format string `yaml:"format"` // text or json
}
// Session configures web UI logins.
type Session struct {
Lifetime Duration `yaml:"lifetime"`
IdleTimeout Duration `yaml:"idle_timeout"`
// CookieSecure is auto (secure over HTTPS), always or never.
CookieSecure string `yaml:"cookie_secure"`
}
// Backup configures 'stewardd backup'.
type Backup struct {
Dir string `yaml:"dir"`
Retention Duration `yaml:"retention"`
// KeepAtLeast backups are kept however old they are.
KeepAtLeast int `yaml:"keep_at_least"`
}
// Duration is a time.Duration written as "15s" or "12h"; days ("30d")
// work too.
type Duration time.Duration
// UnmarshalYAML implements yaml.Unmarshaler.
func (d *Duration) UnmarshalYAML(n *yaml.Node) error {
var s string
if err := n.Decode(&s); err != nil {
return err
}
v, err := ParseDuration(s)
if err != nil {
return err
}
*d = Duration(v)
return nil
}
// ParseDuration parses a Go duration, also accepting days ("30d").
func ParseDuration(s string) (time.Duration, error) {
s = strings.TrimSpace(s)
if n, ok := strings.CutSuffix(s, "d"); ok {
var days int
if _, err := fmt.Sscanf(n, "%d", &days); err != nil || fmt.Sprint(days) != n {
return 0, fmt.Errorf("invalid duration %q", s)
}
return time.Duration(days) * 24 * time.Hour, nil
}
return time.ParseDuration(s)
}
// Default returns the defaults for Debian/Ubuntu.
func Default() *Config {
return &Config{
Listen: []string{"127.0.0.1:8067"},
TrustedProxies: []string{},
Socket: "/run/steward/steward.sock",
Database: "/var/lib/steward/steward.db",
Log: Log{Level: "info", Format: "text"},
Session: Session{Lifetime: Duration(12 * time.Hour), IdleTimeout: Duration(2 * time.Hour), CookieSecure: "auto"},
Backup: Backup{Dir: "/var/backups/steward", Retention: Duration(30 * 24 * time.Hour), KeepAtLeast: 7},
}
}
// Load reads the file at path over the defaults. A missing file is fine
// when allowMissing is set.
func Load(path string, allowMissing bool) (*Config, error) {
c := Default()
b, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) && allowMissing {
return c, c.check()
}
return nil, err
}
dec := yaml.NewDecoder(bytes.NewReader(b))
dec.KnownFields(true)
if err := dec.Decode(c); err != nil && err.Error() != "EOF" {
return nil, fmt.Errorf("%s: %w", path, err)
}
if err := c.check(); err != nil {
return nil, fmt.Errorf("%s: %w", path, err)
}
return c, nil
}
func (c *Config) check() error {
if len(c.Listen) == 0 {
return fmt.Errorf("listen: at least one address is needed")
}
if c.Socket != "" && !filepath.IsAbs(c.Socket) {
return fmt.Errorf("socket must be an absolute path (or \"\" for none)")
}
if len(c.Socket) > 107 {
return fmt.Errorf("socket: the path is %d bytes long; unix sockets allow at most 107", len(c.Socket))
}
if (c.TLS.CertFile == "") != (c.TLS.KeyFile == "") {
return fmt.Errorf("tls: set both cert_file and key_file")
}
for _, p := range c.TrustedProxies {
if _, err := ParsePrefix(p); err != nil {
return fmt.Errorf("trusted_proxies: %v", err)
}
}
if c.Database == "" || !filepath.IsAbs(c.Database) {
return fmt.Errorf("database must be an absolute path")
}
if c.Backup.Dir != "" && !filepath.IsAbs(c.Backup.Dir) {
return fmt.Errorf("backup.dir must be an absolute path")
}
switch c.Session.CookieSecure {
case "auto", "always", "never":
default:
return fmt.Errorf("session.cookie_secure must be auto, always or never")
}
switch c.Log.Format {
case "text", "json":
default:
return fmt.Errorf("log.format must be text or json")
}
switch c.Log.Level {
case "debug", "info", "warn", "error":
default:
return fmt.Errorf("log.level must be debug, info, warn or error")
}
return nil
}
// ParsePrefix parses an address or a prefix.
func ParsePrefix(s string) (netip.Prefix, error) {
if strings.Contains(s, "/") {
return netip.ParsePrefix(s)
}
a, err := netip.ParseAddr(s)
if err != nil {
return netip.Prefix{}, err
}
return netip.PrefixFrom(a, a.BitLen()), nil
}
// Proxies returns the trusted proxies.
func (c *Config) Proxies() []netip.Prefix {
var out []netip.Prefix
for _, p := range c.TrustedProxies {
if pf, err := ParsePrefix(p); err == nil {
out = append(out, pf.Masked())
}
}
return out
}
// Example is a commented example configuration with the defaults.
const Example = `# Steward daemon configuration (/etc/steward/steward.yaml).
# Every setting is optional; the values shown are the defaults. The DHCP
# configuration (subnets, pools, reservations, options) is not here: it is
# in the database, changed with the steward CLI or the web UI.
# Addresses the web UI and API listen on. Keep this off the internet:
# bind to localhost or a management network, and use TLS (below) or a
# TLS-terminating reverse proxy.
listen:
- "127.0.0.1:8067"
# HTTPS from a certificate you renew yourself (reread when it changes).
# tls:
# cert_file: /etc/steward/tls/fullchain.pem
# key_file: /etc/steward/tls/privkey.pem
# Reverse proxies whose X-Forwarded-For and X-Forwarded-Proto are
# believed.
trusted_proxies: []
# A local socket for the API: root and stewardd's own user use it without
# a token ('sudo steward status' just works). "" turns it off.
socket: /run/steward/steward.sock
database: /var/lib/steward/steward.db
log:
level: info # debug, info, warn or error
format: text # text or json
session:
lifetime: 12h
idle_timeout: 2h
cookie_secure: auto # auto (secure over HTTPS), always or never
# 'stewardd backup' (the daily steward-backup.timer) writes copies of the
# database here and deletes those older than retention, keeping at least
# keep_at_least.
backup:
dir: /var/backups/steward
retention: 30d
keep_at_least: 7
`
+42
View File
@@ -0,0 +1,42 @@
package config
import (
"os"
"path/filepath"
"reflect"
"strings"
"testing"
)
func TestExampleIsTheDefaults(t *testing.T) {
path := filepath.Join(t.TempDir(), "steward.yaml")
os.WriteFile(path, []byte(Example), 0o644)
c, err := Load(path, false)
if err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(c, Default()) {
t.Errorf("example differs from the defaults:\n%+v\n%+v", c, Default())
}
}
func TestErrors(t *testing.T) {
cases := map[string]string{
"listen: []": "listen",
"database: relative.db": "database",
"session: {cookie_secure: x}": "cookie_secure",
"tls: {cert_file: /a}": "tls",
"no_such_setting: 1": "no_such_setting",
"backup: {retention: 3x}": "duration",
}
for yml, want := range cases {
path := filepath.Join(t.TempDir(), "s.yaml")
os.WriteFile(path, []byte(yml), 0o644)
if _, err := Load(path, false); err == nil || !strings.Contains(err.Error(), want) {
t.Errorf("%q: %v", yml, err)
}
}
if _, err := Load("/nonexistent/steward.yaml", true); err != nil {
t.Errorf("missing file allowed: %v", err)
}
}
+444
View File
@@ -0,0 +1,444 @@
package dhcp4
import (
"encoding/binary"
"encoding/hex"
"errors"
"fmt"
"math"
"net/netip"
"sort"
"strconv"
"strings"
)
// Value types of options, as written in Steward's configuration.
const (
TypeIP = "ip" // one IPv4 address
TypeIPs = "ips" // a list of IPv4 addresses: "10.0.0.1, 10.0.0.2"
TypeString = "string" // text
TypeU8 = "uint8" // 0-255
TypeU16 = "uint16" // 0-65535
TypeU32 = "uint32" // 0-4294967295
TypeI32 = "int32" // a signed number
TypeBool = "bool" // true or false
TypeDomains = "domains" // a list of domain names, in DNS wire format (option 119)
TypeRoutes = "routes" // classless static routes (option 121): "10.20.0.0/16 via 10.0.10.1"
TypeHex = "hex" // raw bytes: "01:02:0a" or "01020a"
)
// Types lists the value types, for custom options.
var Types = []string{TypeIP, TypeIPs, TypeString, TypeU8, TypeU16, TypeU32, TypeI32, TypeBool, TypeDomains, TypeRoutes, TypeHex}
// Def describes an option Steward knows.
type Def struct {
Code byte `json:"code"`
Name string `json:"name"` // Kea's name for it, so imports and habits carry over
Type string `json:"type"`
// Help is a one-line description for the web UI and the CLI.
Help string `json:"help"`
// Common options are offered first in the web UI.
Common bool `json:"common,omitempty"`
}
// Catalog holds the options Steward knows by name. Any other option can
// be set by its number, with a type (hex when not given).
var Catalog = []Def{
{Code: 2, Name: "time-offset", Type: TypeI32, Help: "the client's offset from UTC in seconds (obsolete; prefer tcode)"},
{Code: 3, Name: "routers", Type: TypeIPs, Help: "default gateways, in order of preference", Common: true},
{Code: 4, Name: "time-servers", Type: TypeIPs, Help: "RFC 868 time servers (rarely used; see ntp-servers)"},
{Code: 6, Name: "domain-name-servers", Type: TypeIPs, Help: "DNS servers, in order of preference", Common: true},
{Code: 7, Name: "log-servers", Type: TypeIPs, Help: "MIT-LCS UDP log servers"},
{Code: 15, Name: "domain-name", Type: TypeString, Help: "the domain the client's host name belongs to", Common: true},
{Code: 19, Name: "ip-forwarding", Type: TypeBool, Help: "whether the client should forward packets"},
{Code: 23, Name: "default-ip-ttl", Type: TypeU8, Help: "the TTL for the client's outgoing packets"},
{Code: 26, Name: "interface-mtu", Type: TypeU16, Help: "the MTU of the client's interface", Common: true},
{Code: 28, Name: "broadcast-address", Type: TypeIP, Help: "the subnet's broadcast address"},
{Code: 31, Name: "perform-router-discovery", Type: TypeBool, Help: "whether the client should solicit routers (RFC 1256)"},
{Code: 42, Name: "ntp-servers", Type: TypeIPs, Help: "NTP servers, in order of preference", Common: true},
{Code: 43, Name: "vendor-encapsulated-options", Type: TypeHex, Help: "vendor-specific information"},
{Code: 44, Name: "netbios-name-servers", Type: TypeIPs, Help: "NetBIOS name servers (WINS)"},
{Code: 46, Name: "netbios-node-type", Type: TypeU8, Help: "NetBIOS node type: 1 B, 2 P, 4 M, 8 H"},
{Code: 47, Name: "netbios-scope", Type: TypeString, Help: "NetBIOS scope"},
{Code: 60, Name: "vendor-class-identifier", Type: TypeString, Help: "the server's vendor class (PXE: PXEClient)"},
{Code: 66, Name: "tftp-server-name", Type: TypeString, Help: "the TFTP server for network boot"},
{Code: 67, Name: "boot-file-name", Type: TypeString, Help: "the file to boot from the TFTP server"},
{Code: 69, Name: "smtp-server", Type: TypeIPs, Help: "mail servers"},
{Code: 100, Name: "pcode", Type: TypeString, Help: "the time zone as a POSIX TZ string (EST5EDT,M3.2.0,M11.1.0)"},
{Code: 101, Name: "tcode", Type: TypeString, Help: "the time zone by name (Europe/London)"},
{Code: 114, Name: "v4-captive-portal", Type: TypeString, Help: "the captive portal's API URL (RFC 8910)"},
{Code: 119, Name: "domain-search", Type: TypeDomains, Help: "domains to try when looking up short names", Common: true},
{Code: 121, Name: "classless-static-route", Type: TypeRoutes, Help: "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)"},
{Code: 150, Name: "tftp-server-address", Type: TypeIPs, Help: "TFTP servers by address (Cisco phones and others)"},
{Code: 252, Name: "wpad-url", Type: TypeString, Help: "web proxy auto-discovery URL"},
}
// Reserved options are set by the server itself and can't be configured.
var Reserved = map[byte]string{
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",
}
var byCode, byName = func() (map[byte]Def, map[string]Def) {
c, n := map[byte]Def{}, map[string]Def{}
for _, d := range Catalog {
c[d.Code], n[d.Name] = d, d
}
return c, n
}()
// Lookup finds an option by name ("routers") or number ("3").
func Lookup(nameOrCode string) (Def, bool) {
s := strings.ToLower(strings.TrimSpace(nameOrCode))
if d, ok := byName[s]; ok {
return d, true
}
if n, err := strconv.Atoi(strings.TrimPrefix(s, "option-")); err == nil && n > 0 && n < 255 {
if d, ok := byCode[byte(n)]; ok {
return d, true
}
return Def{Code: byte(n), Name: "option-" + strconv.Itoa(n), Type: TypeHex}, true
}
return Def{}, false
}
// ByCode returns the definition of an option code: the catalog's, or a
// hex one for codes it doesn't know.
func ByCode(code byte) Def {
if d, ok := byCode[code]; ok {
return d
}
return Def{Code: code, Name: "option-" + strconv.Itoa(int(code)), Type: TypeHex}
}
// Known reports whether the catalog has the code.
func Known(code byte) bool {
_, ok := byCode[code]
return ok
}
// Names lists the catalog's names, sorted.
func Names() []string {
var out []string
for n := range byName {
out = append(out, n)
}
sort.Strings(out)
return out
}
// Encode turns an option value written as text into its bytes, for the
// given type.
func Encode(typ, value string) ([]byte, error) {
v := strings.TrimSpace(value)
switch typ {
case TypeIP:
a, err := parseIP4(v)
if err != nil {
return nil, err
}
return IP(a), nil
case TypeIPs:
items := splitList(v)
if len(items) == 0 {
return nil, errors.New("give at least one address")
}
var out []byte
for _, s := range items {
a, err := parseIP4(s)
if err != nil {
return nil, err
}
out = append(out, IP(a)...)
}
return out, nil
case TypeString:
if v == "" {
return nil, errors.New("the value is empty")
}
if len(value) > 255 {
return nil, errors.New("the text is longer than 255 bytes")
}
return []byte(value), nil
case TypeU8, TypeU16, TypeU32:
bits := map[string]int{TypeU8: 8, TypeU16: 16, TypeU32: 32}[typ]
n, err := strconv.ParseUint(v, 10, bits)
if err != nil {
return nil, fmt.Errorf("%q is not a whole number from 0 to %d", v, uint64(1)<<bits-1)
}
switch bits {
case 8:
return []byte{byte(n)}, nil
case 16:
return binary.BigEndian.AppendUint16(nil, uint16(n)), nil
}
return U32(uint32(n)), nil
case TypeI32:
n, err := strconv.ParseInt(v, 10, 32)
if err != nil {
return nil, fmt.Errorf("%q is not a whole number from %d to %d", v, math.MinInt32, math.MaxInt32)
}
return U32(uint32(int32(n))), nil
case TypeBool:
switch strings.ToLower(v) {
case "true", "yes", "on", "1":
return []byte{1}, nil
case "false", "no", "off", "0":
return []byte{0}, nil
}
return nil, fmt.Errorf("%q is not true or false", v)
case TypeDomains:
items := splitList(v)
if len(items) == 0 {
return nil, errors.New("give at least one domain")
}
return encodeNames(items)
case TypeRoutes:
return encodeRoutes(v)
case TypeHex:
s := strings.NewReplacer(":", "", " ", "", "-", "").Replace(strings.TrimPrefix(strings.ToLower(v), "0x"))
b, err := hex.DecodeString(s)
if err != nil {
return nil, fmt.Errorf("%q is not hex bytes (write them as 01:02:0a or 01020a)", v)
}
return b, nil
}
return nil, fmt.Errorf("unknown option type %q (use one of %s)", typ, strings.Join(Types, ", "))
}
// Decode turns option bytes into text, the way Encode reads it. Data that
// doesn't fit the type is shown as hex.
func Decode(typ string, b []byte) string {
asHex := func() string {
parts := make([]string, len(b))
for i, c := range b {
parts[i] = fmt.Sprintf("%02x", c)
}
return strings.Join(parts, ":")
}
switch typ {
case TypeIP, TypeIPs:
if len(b) == 0 || len(b)%4 != 0 || typ == TypeIP && len(b) != 4 {
return asHex()
}
var parts []string
for i := 0; i < len(b); i += 4 {
parts = append(parts, addr4(b[i:]).String())
}
return strings.Join(parts, ", ")
case TypeString:
return string(b)
case TypeU8:
if len(b) == 1 {
return strconv.Itoa(int(b[0]))
}
case TypeU16:
if len(b) == 2 {
return strconv.Itoa(int(binary.BigEndian.Uint16(b)))
}
case TypeU32:
if len(b) == 4 {
return strconv.FormatUint(uint64(binary.BigEndian.Uint32(b)), 10)
}
case TypeI32:
if len(b) == 4 {
return strconv.Itoa(int(int32(binary.BigEndian.Uint32(b))))
}
case TypeBool:
if len(b) == 1 {
return strconv.FormatBool(b[0] != 0)
}
case TypeDomains:
if names, err := decodeNames(b); err == nil {
return strings.Join(names, ", ")
}
case TypeRoutes:
if s, err := decodeRoutes(b); err == nil {
return s
}
}
return asHex()
}
// Normalize rewrites a value in its canonical form ("10.0.0.1,10.0.0.2"
// becomes "10.0.0.1, 10.0.0.2"), or reports why it is invalid.
func Normalize(typ, value string) (string, error) {
b, err := Encode(typ, value)
if err != nil {
return "", err
}
if typ == TypeString {
return value, nil
}
return Decode(typ, b), nil
}
func splitList(v string) []string {
var out []string
for _, s := range strings.FieldsFunc(v, func(r rune) bool { return r == ',' || r == ' ' || r == '\t' || r == '\n' }) {
if s != "" {
out = append(out, s)
}
}
return out
}
func parseIP4(s string) (netip.Addr, error) {
a, err := netip.ParseAddr(strings.TrimSpace(s))
if err != nil || !a.Is4() {
return netip.Addr{}, fmt.Errorf("%q is not an IPv4 address", s)
}
return a, nil
}
// encodeNames writes domain names in DNS wire format, without
// compression (RFC 3397 allows but doesn't require it).
func encodeNames(names []string) ([]byte, error) {
var out []byte
for _, n := range names {
n = strings.TrimSuffix(strings.ToLower(n), ".")
if n == "" {
return nil, errors.New("empty domain name")
}
if len(n) > 253 {
return nil, fmt.Errorf("domain %q is too long", n)
}
for _, l := range strings.Split(n, ".") {
if l == "" || len(l) > 63 {
return nil, fmt.Errorf("domain %q has an empty or over-long label", n)
}
for _, c := range l {
if !(c >= 'a' && c <= 'z' || c >= '0' && c <= '9' || c == '-' || c == '_') {
return nil, fmt.Errorf("domain %q: %q isn't allowed in a domain name", n, c)
}
}
out = append(out, byte(len(l)))
out = append(out, l...)
}
out = append(out, 0)
}
return out, nil
}
// decodeNames reads DNS wire-format names, following compression
// pointers within the data.
func decodeNames(b []byte) ([]string, error) {
var out []string
for i := 0; i < len(b); {
name, next, err := readName(b, i)
if err != nil {
return nil, err
}
out = append(out, name)
i = next
}
return out, nil
}
func readName(b []byte, i int) (string, int, error) {
var labels []string
next := -1
for hops := 0; ; hops++ {
if i >= len(b) || hops > 128 {
return "", 0, errors.New("malformed domain name")
}
n := int(b[i])
switch {
case n == 0:
if next < 0 {
next = i + 1
}
return strings.Join(labels, "."), next, nil
case n&0xc0 == 0xc0:
if i+1 >= len(b) {
return "", 0, errors.New("malformed compression pointer")
}
if next < 0 {
next = i + 2
}
i = int(binary.BigEndian.Uint16(b[i:]) & 0x3fff)
default:
if i+1+n > len(b) {
return "", 0, errors.New("domain label runs past the end")
}
labels = append(labels, string(b[i+1:i+1+n]))
i += 1 + n
}
}
}
// encodeRoutes writes classless static routes (RFC 3442): for each, the
// prefix length, the significant octets of the destination, the router.
func encodeRoutes(v string) ([]byte, error) {
var out []byte
items := strings.Split(v, ",")
n := 0
for _, it := range items {
it = strings.TrimSpace(it)
if it == "" {
continue
}
var dst, gw string
if a, b, ok := strings.Cut(it, " via "); ok {
dst, gw = a, b
} else if a, b, ok := strings.Cut(it, " - "); ok { // Kea's form
dst, gw = a, b
} else {
return nil, fmt.Errorf("route %q: write it as DESTINATION/LENGTH via ROUTER", it)
}
pfx, err := netip.ParsePrefix(strings.TrimSpace(dst))
if err != nil || !pfx.Addr().Is4() {
return nil, fmt.Errorf("route %q: %q is not an IPv4 prefix", it, dst)
}
if pfx.Masked() != pfx {
return nil, fmt.Errorf("route %q: %s has host bits set (the network is %s)", it, pfx, pfx.Masked())
}
router, err := parseIP4(gw)
if err != nil {
return nil, fmt.Errorf("route %q: %v", it, err)
}
a := pfx.Addr().As4()
out = append(out, byte(pfx.Bits()))
out = append(out, a[:(pfx.Bits()+7)/8]...)
out = append(out, IP(router)...)
n++
}
if n == 0 {
return nil, errors.New("give at least one route")
}
return out, nil
}
func decodeRoutes(b []byte) (string, error) {
var parts []string
for i := 0; i < len(b); {
bits := int(b[i])
if bits > 32 {
return "", errors.New("bad prefix length")
}
n := (bits + 7) / 8
if i+1+n+4 > len(b) {
return "", errors.New("route runs past the end")
}
var a [4]byte
copy(a[:], b[i+1:i+1+n])
pfx := netip.PrefixFrom(netip.AddrFrom4(a), bits)
parts = append(parts, pfx.String()+" via "+addr4(b[i+1+n:]).String())
i += 1 + n + 4
}
return strings.Join(parts, ", "), nil
}
+178
View File
@@ -0,0 +1,178 @@
package dhcp4
import (
"bytes"
"net"
"net/netip"
"strings"
"testing"
)
func TestRoundTrip(t *testing.T) {
mac, _ := net.ParseMAC("aa:bb:cc:00:11:22")
p := NewRequest(Discover, mac, 0x12345678)
p.Flags = FlagBroadcast
p.Set(OptHostName, []byte("laptop"))
p.Set(OptParamRequest, []byte{1, 3, 6, 15})
p.Set(OptClientID, append([]byte{1}, mac...))
b := p.Marshal()
if len(b) < MinLen {
t.Fatalf("message is %d bytes, under %d", len(b), MinLen)
}
if p.Len() != len(b) {
t.Errorf("Len() = %d, Marshal gave %d", p.Len(), len(b))
}
q, err := Parse(b)
if err != nil {
t.Fatal(err)
}
if q.Type() != Discover || q.XID != 0x12345678 || !q.Broadcast() || q.MAC().String() != mac.String() {
t.Errorf("parsed %+v", q)
}
if q.HostName() != "laptop" || q.ClientID() != "01aabbcc001122" {
t.Errorf("host %q client id %q", q.HostName(), q.ClientID())
}
if !bytes.Equal(q.ParamRequest(), []byte{1, 3, 6, 15}) {
t.Errorf("PRL %v", q.ParamRequest())
}
r := q.Reply(Offer)
r.YIAddr = netip.MustParseAddr("10.0.10.57")
if r.Op != BootReply || r.XID != q.XID || r.Type() != Offer || r.CHAddr != q.CHAddr {
t.Errorf("reply %+v", r)
}
r2, err := Parse(r.Marshal())
if err != nil || r2.YIAddr != r.YIAddr {
t.Fatalf("reply round trip: %v %v", r2, err)
}
}
func TestLongOptionsAreSplitAndJoined(t *testing.T) {
p := NewRequest(Inform, net.HardwareAddr{1, 2, 3, 4, 5, 6}, 1)
long := bytes.Repeat([]byte{7}, 600)
p.Set(43, long)
b := p.Marshal()
q, err := Parse(b)
if err != nil {
t.Fatal(err)
}
if !bytes.Equal(q.Get(43), long) {
t.Errorf("got %d bytes back, want 600", len(q.Get(43)))
}
if p.Len() != len(b) {
t.Errorf("Len() = %d, Marshal gave %d", p.Len(), len(b))
}
}
func TestOverload(t *testing.T) {
p := NewRequest(Request, net.HardwareAddr{1, 2, 3, 4, 5, 6}, 1)
p.Set(OptOverload, []byte{1})
b := p.Marshal()
// Put option 12 in the file field (bytes 108..236).
copy(b[108:], []byte{12, 3, 'p', 'c', '1', 255})
q, err := Parse(b)
if err != nil {
t.Fatal(err)
}
if q.HostName() != "pc1" || q.File != "" {
t.Errorf("host %q file %q", q.HostName(), q.File)
}
}
func TestParseErrors(t *testing.T) {
if _, err := Parse(make([]byte, 100)); err == nil {
t.Error("short message accepted")
}
b := NewRequest(Discover, net.HardwareAddr{1, 2, 3, 4, 5, 6}, 1).Marshal()
b[236] = 0
if _, err := Parse(b); err == nil || !strings.Contains(err.Error(), "cookie") {
t.Errorf("missing cookie: %v", err)
}
b = NewRequest(Discover, net.HardwareAddr{1, 2, 3, 4, 5, 6}, 1).Marshal()
b[240+1] = 200 // message type option claims 200 bytes
if _, err := Parse(b); err == nil {
t.Error("overlong option accepted")
}
}
func TestHostNameFromFQDN(t *testing.T) {
p := NewRequest(Request, net.HardwareAddr{1, 2, 3, 4, 5, 6}, 1)
wire, _ := encodeNames([]string{"desk.example.com"})
p.Set(OptClientFQDN, append([]byte{0x04, 0, 0}, wire...))
if got := p.HostName(); got != "desk" {
t.Errorf("wire FQDN: %q", got)
}
p.Set(OptClientFQDN, append([]byte{0, 0, 0}, "tv.lan"...))
if got := p.HostName(); got != "tv" {
t.Errorf("ASCII FQDN: %q", got)
}
}
func TestEncodeDecode(t *testing.T) {
cases := []struct {
typ, in, out string
hex string
}{
{TypeIP, " 10.0.0.1 ", "10.0.0.1", "0a000001"},
{TypeIPs, "10.0.0.1,10.0.0.2", "10.0.0.1, 10.0.0.2", "0a0000010a000002"},
{TypeU8, "8", "8", "08"},
{TypeU16, "1500", "1500", "05dc"},
{TypeU32, "86400", "86400", "00015180"},
{TypeI32, "-3600", "-3600", "fffff1f0"},
{TypeBool, "yes", "true", "01"},
{TypeHex, "01:02:0A", "01:02:0a", "01020a"},
{TypeHex, "0x0102", "01:02", "0102"},
{TypeDomains, "lan, Example.com.", "lan, example.com", "036c616e00076578616d706c6503636f6d00"},
{TypeRoutes, "10.20.0.0/16 via 10.0.10.1, 0.0.0.0/0 - 10.0.10.1", "10.20.0.0/16 via 10.0.10.1, 0.0.0.0/0 via 10.0.10.1", "100a140a000a01000a000a01"},
}
for _, c := range cases {
b, err := Encode(c.typ, c.in)
if err != nil {
t.Errorf("%s %q: %v", c.typ, c.in, err)
continue
}
if got := Decode(c.typ, b); got != c.out {
t.Errorf("%s %q decodes as %q, want %q", c.typ, c.in, got, c.out)
}
if got := Decode(TypeHex, b); strings.ReplaceAll(got, ":", "") != c.hex {
t.Errorf("%s %q encodes as %s, want %s", c.typ, c.in, got, c.hex)
}
}
bad := []struct{ typ, in string }{
{TypeIP, "10.0.0.256"}, {TypeIP, "::1"}, {TypeIPs, ""}, {TypeU8, "256"}, {TypeU16, "-1"},
{TypeBool, "maybe"}, {TypeHex, "zz"}, {TypeDomains, "a..b"}, {TypeDomains, "bad!name"},
{TypeRoutes, "10.20.0.0/16"}, {TypeRoutes, "10.20.0.1/16 via 10.0.0.1"}, {TypeString, ""}, {"nope", "x"},
}
for _, c := range bad {
if _, err := Encode(c.typ, c.in); err == nil {
t.Errorf("%s %q accepted", c.typ, c.in)
}
}
}
func TestDecodeCompressedNames(t *testing.T) {
// "lan" then "example.lan" written with a pointer to the first "lan".
b := []byte{3, 'l', 'a', 'n', 0, 7, 'e', 'x', 'a', 'm', 'p', 'l', 'e', 0xc0, 0}
if got := Decode(TypeDomains, b); got != "lan, example.lan" {
t.Errorf("got %q", got)
}
}
func TestLookup(t *testing.T) {
if d, ok := Lookup("routers"); !ok || d.Code != 3 {
t.Errorf("routers: %v %v", d, ok)
}
if d, ok := Lookup("42"); !ok || d.Name != "ntp-servers" {
t.Errorf("42: %v %v", d, ok)
}
if d, ok := Lookup("224"); !ok || d.Type != TypeHex || d.Name != "option-224" {
t.Errorf("224: %v %v", d, ok)
}
if _, ok := Lookup("no-such-option"); ok {
t.Error("unknown name found")
}
for _, d := range Catalog {
if _, ok := Reserved[d.Code]; ok {
t.Errorf("%s (%d) is in the catalog and reserved", d.Name, d.Code)
}
}
}
+413
View File
@@ -0,0 +1,413 @@
// Package dhcp4 reads and writes DHCPv4 messages (RFC 2131) and their
// options (RFC 2132), and holds the catalog of options Steward knows by
// name. It does no I/O.
package dhcp4
import (
"encoding/binary"
"errors"
"fmt"
"net"
"net/netip"
"strings"
)
// Ports.
const (
ServerPort = 67
ClientPort = 68
)
// Op codes.
const (
BootRequest = 1
BootReply = 2
)
// MessageType is option 53.
type MessageType byte
// Message types (RFC 2132 section 9.6).
const (
Discover MessageType = 1
Offer MessageType = 2
Request MessageType = 3
Decline MessageType = 4
Ack MessageType = 5
Nak MessageType = 6
Release MessageType = 7
Inform MessageType = 8
)
func (t MessageType) String() string {
switch t {
case Discover:
return "DHCPDISCOVER"
case Offer:
return "DHCPOFFER"
case Request:
return "DHCPREQUEST"
case Decline:
return "DHCPDECLINE"
case Ack:
return "DHCPACK"
case Nak:
return "DHCPNAK"
case Release:
return "DHCPRELEASE"
case Inform:
return "DHCPINFORM"
}
return fmt.Sprintf("DHCP type %d", byte(t))
}
// Option codes the server itself uses.
const (
OptPad = 0
OptSubnetMask = 1
OptRouters = 3
OptDNSServers = 6
OptHostName = 12
OptDomainName = 15
OptBroadcast = 28
OptRequestedIP = 50
OptLeaseTime = 51
OptOverload = 52
OptMessageType = 53
OptServerID = 54
OptParamRequest = 55
OptMessage = 56
OptMaxMessage = 57
OptRenewalTime = 58
OptRebindingTime = 59
OptVendorClass = 60
OptClientID = 61
OptClientFQDN = 81
OptEnd = 255
)
// FlagBroadcast is the broadcast bit of the flags field: the client can't
// receive unicast before it has an address.
const FlagBroadcast = 0x8000
var magicCookie = []byte{99, 130, 83, 99}
// headerLen is the fixed part of a message, up to the magic cookie.
const headerLen = 236
// MinLen is the shortest message to send: BOOTP's 300 bytes, which some
// old clients and relays insist on.
const MinLen = 300
// Option is one option: its code and its data.
type Option struct {
Code byte
Data []byte
}
// Packet is a DHCPv4 message.
type Packet struct {
Op byte
HType byte
HLen byte
Hops byte
XID uint32
Secs uint16
Flags uint16
CIAddr netip.Addr
YIAddr netip.Addr
SIAddr netip.Addr
GIAddr netip.Addr
CHAddr [16]byte
SName string
File string
// Options in the order they were read or are to be written. Options
// that appear more than once are joined (RFC 3396).
Options []Option
}
var zero4 = netip.AddrFrom4([4]byte{})
// Parse reads a message.
func Parse(b []byte) (*Packet, error) {
if len(b) < headerLen+4 {
return nil, fmt.Errorf("message too short (%d bytes)", len(b))
}
p := &Packet{
Op: b[0], HType: b[1], HLen: b[2], Hops: b[3],
XID: binary.BigEndian.Uint32(b[4:8]),
Secs: binary.BigEndian.Uint16(b[8:10]),
Flags: binary.BigEndian.Uint16(b[10:12]),
CIAddr: addr4(b[12:16]), YIAddr: addr4(b[16:20]), SIAddr: addr4(b[20:24]), GIAddr: addr4(b[24:28]),
}
copy(p.CHAddr[:], b[28:44])
if p.HLen > 16 {
return nil, fmt.Errorf("hardware address length %d is over 16", p.HLen)
}
if string(b[236:240]) != string(magicCookie) {
return nil, errors.New("no DHCP magic cookie (a BOOTP message?)")
}
opts, err := parseOptions(b[240:])
if err != nil {
return nil, err
}
// Option overload (52): more options in the file and sname fields.
sname, file := b[44:108], b[108:236]
if ov := find(opts, OptOverload); len(ov) == 1 {
if ov[0]&1 != 0 {
more, err := parseOptions(file)
if err != nil {
return nil, fmt.Errorf("options in the file field: %w", err)
}
opts = append(opts, more...)
file = nil
}
if ov[0]&2 != 0 {
more, err := parseOptions(sname)
if err != nil {
return nil, fmt.Errorf("options in the sname field: %w", err)
}
opts = append(opts, more...)
sname = nil
}
}
p.SName, p.File = cString(sname), cString(file)
p.Options = join(opts)
return p, nil
}
func addr4(b []byte) netip.Addr { return netip.AddrFrom4([4]byte(b[:4])) }
func cString(b []byte) string {
if i := strings.IndexByte(string(b), 0); i >= 0 {
b = b[:i]
}
return string(b)
}
func parseOptions(b []byte) ([]Option, error) {
var out []Option
for i := 0; i < len(b); {
code := b[i]
i++
switch code {
case OptPad:
continue
case OptEnd:
return out, nil
}
if i >= len(b) {
return nil, fmt.Errorf("option %d has no length", code)
}
n := int(b[i])
i++
if i+n > len(b) {
return nil, fmt.Errorf("option %d is cut short (%d of %d bytes)", code, len(b)-i, n)
}
out = append(out, Option{Code: code, Data: append([]byte(nil), b[i:i+n]...)})
i += n
}
// Some clients leave out the end option.
return out, nil
}
// join concatenates repeated options into one (RFC 3396), keeping the
// position of the first.
func join(opts []Option) []Option {
var out []Option
seen := map[byte]int{}
for _, o := range opts {
if i, ok := seen[o.Code]; ok {
out[i].Data = append(out[i].Data, o.Data...)
continue
}
seen[o.Code] = len(out)
out = append(out, o)
}
return out
}
func find(opts []Option, code byte) []byte {
for _, o := range opts {
if o.Code == code {
return o.Data
}
}
return nil
}
// Marshal writes the message. Options longer than 255 bytes are split
// (RFC 3396); the message is padded to MinLen.
func (p *Packet) Marshal() []byte {
b := make([]byte, headerLen, MinLen+64)
b[0], b[1], b[2], b[3] = p.Op, p.HType, p.HLen, p.Hops
binary.BigEndian.PutUint32(b[4:8], p.XID)
binary.BigEndian.PutUint16(b[8:10], p.Secs)
binary.BigEndian.PutUint16(b[10:12], p.Flags)
put4(b[12:16], p.CIAddr)
put4(b[16:20], p.YIAddr)
put4(b[20:24], p.SIAddr)
put4(b[24:28], p.GIAddr)
copy(b[28:44], p.CHAddr[:])
copy(b[44:107], p.SName)
copy(b[108:235], p.File)
b = append(b, magicCookie...)
for _, o := range p.Options {
data := o.Data
for first := true; first || len(data) > 0; first = false {
n := min(len(data), 255)
b = append(b, o.Code, byte(n))
b = append(b, data[:n]...)
data = data[n:]
}
}
b = append(b, OptEnd)
for len(b) < MinLen {
b = append(b, OptPad)
}
return b
}
// Len is the length Marshal would produce.
func (p *Packet) Len() int {
n := headerLen + 4 + 1
for _, o := range p.Options {
n += len(o.Data) + 2*max(1, (len(o.Data)+254)/255)
}
return max(n, MinLen)
}
func put4(b []byte, a netip.Addr) {
if a.Is4() {
v := a.As4()
copy(b, v[:])
}
}
// Get returns an option's data (nil when absent).
func (p *Packet) Get(code byte) []byte { return find(p.Options, code) }
// Has reports whether the option is present.
func (p *Packet) Has(code byte) bool {
for _, o := range p.Options {
if o.Code == code {
return true
}
}
return false
}
// Set adds or replaces an option.
func (p *Packet) Set(code byte, data []byte) {
for i, o := range p.Options {
if o.Code == code {
p.Options[i].Data = data
return
}
}
p.Options = append(p.Options, Option{Code: code, Data: data})
}
// Type is the message type (0 when the option is missing or malformed).
func (p *Packet) Type() MessageType {
if d := p.Get(OptMessageType); len(d) == 1 {
return MessageType(d[0])
}
return 0
}
// IPOption returns an option holding one IPv4 address.
func (p *Packet) IPOption(code byte) (netip.Addr, bool) {
d := p.Get(code)
if len(d) != 4 {
return netip.Addr{}, false
}
return addr4(d), true
}
// MAC is the client's hardware address, for Ethernet-like types.
func (p *Packet) MAC() net.HardwareAddr {
n := int(p.HLen)
if n == 0 || n > 16 {
return nil
}
return net.HardwareAddr(append([]byte(nil), p.CHAddr[:n]...))
}
// ClientID is option 61 in lower-case hex ("" when absent).
func (p *Packet) ClientID() string {
if d := p.Get(OptClientID); len(d) > 0 {
return fmt.Sprintf("%x", d)
}
return ""
}
// HostName is the name the client gives: option 12, or else the name in
// the client FQDN option (81), without its domain.
func (p *Packet) HostName() string {
if d := p.Get(OptHostName); len(d) > 0 {
return strings.TrimRight(string(d), "\x00")
}
if d := p.Get(OptClientFQDN); len(d) > 3 {
name := d[3:]
if d[0]&0x04 != 0 { // E bit: canonical wire format
if labels, err := decodeNames(name); err == nil && len(labels) > 0 {
return strings.SplitN(labels[0], ".", 2)[0]
}
return ""
}
return strings.SplitN(strings.TrimRight(string(name), "\x00"), ".", 2)[0]
}
return ""
}
// ParamRequest is the client's parameter request list (option 55).
func (p *Packet) ParamRequest() []byte { return p.Get(OptParamRequest) }
// MaxMessageSize is the largest reply the client accepts (option 57),
// or 576 (RFC 2131's minimum) when it doesn't say.
func (p *Packet) MaxMessageSize() int {
if d := p.Get(OptMaxMessage); len(d) == 2 {
if n := int(binary.BigEndian.Uint16(d)); n >= 576 {
return n
}
}
return 576
}
// Broadcast reports whether the client asked for broadcast replies.
func (p *Packet) Broadcast() bool { return p.Flags&FlagBroadcast != 0 }
// Reply starts a reply to a client's message: the same transaction,
// hardware address, flags and relay address, and the message type.
func (p *Packet) Reply(t MessageType) *Packet {
r := &Packet{
Op: BootReply, HType: p.HType, HLen: p.HLen, XID: p.XID, Flags: p.Flags,
CIAddr: zero4, YIAddr: zero4, SIAddr: zero4, GIAddr: p.GIAddr, CHAddr: p.CHAddr,
}
if !r.GIAddr.IsValid() {
r.GIAddr = zero4
}
r.Set(OptMessageType, []byte{byte(t)})
return r
}
// NewRequest starts a client message (for tests and the CLI's probe).
func NewRequest(t MessageType, mac net.HardwareAddr, xid uint32) *Packet {
p := &Packet{Op: BootRequest, HType: 1, HLen: byte(len(mac)), XID: xid, CIAddr: zero4, YIAddr: zero4, SIAddr: zero4, GIAddr: zero4}
copy(p.CHAddr[:], mac)
p.Set(OptMessageType, []byte{byte(t)})
return p
}
// IsZero reports whether a is unset or 0.0.0.0.
func IsZero(a netip.Addr) bool { return !a.IsValid() || a == zero4 }
// U32 encodes a 32-bit option value.
func U32(v uint32) []byte { return binary.BigEndian.AppendUint32(nil, v) }
// IP encodes an address option value.
func IP(a netip.Addr) []byte {
v := a.As4()
return v[:]
}
+139
View File
@@ -0,0 +1,139 @@
package dhcpd
import (
"net/netip"
"time"
"steward/internal/dhcp4"
"steward/internal/model"
)
// config is a configuration prepared for answering requests: subnets by
// interface, reservations indexed, options encoded.
type config struct {
state *model.State
byIface map[string][]*subnet
// resByIP holds every reservation by address.
resByIP map[netip.Addr]*model.Reservation
// resByClient holds reservations per subnet by "mac aa:..." or
// "id 01aa...".
resByClient map[int64]map[string]*model.Reservation
settings model.Settings
}
type subnet struct {
*model.Subnet
network, broadcast netip.Addr
leaseTime time.Duration
// options are the encoded options of the settings and the subnet,
// the subnet's winning.
options map[byte][]byte
}
func compile(st *model.State) *config {
c := &config{
state: st,
byIface: map[string][]*subnet{},
resByIP: map[netip.Addr]*model.Reservation{},
resByClient: map[int64]map[string]*model.Reservation{},
settings: st.Settings,
}
global := encodeOptions(st.Settings.Options, nil)
for _, s := range st.Subnets {
if s.Disabled || s.Interface == "" || !s.Prefix.Addr().Is4() {
continue
}
lt := s.LeaseTime
if lt <= 0 {
lt = st.Settings.EffectiveLeaseTime()
}
sc := &subnet{Subnet: s, network: s.Prefix.Addr(), broadcast: model.Last(s.Prefix), leaseTime: time.Duration(lt) * time.Second,
options: encodeOptions(s.Options, global)}
c.byIface[s.Interface] = append(c.byIface[s.Interface], sc)
}
for _, r := range st.Reservations {
c.resByIP[r.IP] = r
s := st.SubnetFor(r.IP)
if s == nil {
continue
}
if c.resByClient[s.ID] == nil {
c.resByClient[s.ID] = map[string]*model.Reservation{}
}
if r.ClientID != "" {
c.resByClient[s.ID]["id "+r.ClientID] = r
}
if r.MAC != "" {
c.resByClient[s.ID]["mac "+r.MAC] = r
}
}
return c
}
// encodeOptions encodes options over a base set (copied).
func encodeOptions(opts []model.OptionValue, base map[byte][]byte) map[byte][]byte {
out := map[byte][]byte{}
for k, v := range base {
out[k] = v
}
for _, o := range opts {
if o.Code <= 0 || o.Code >= 255 {
continue
}
def := dhcp4.ByCode(byte(o.Code))
typ := def.Type
if !dhcp4.Known(byte(o.Code)) && o.Type != "" {
typ = o.Type
}
if b, err := dhcp4.Encode(typ, o.Value); err == nil {
out[byte(o.Code)] = b
}
}
return out
}
// reservationFor finds the client's reservation in a subnet.
func (c *config) reservationFor(subnetID int64, cl client) *model.Reservation {
m := c.resByClient[subnetID]
if m == nil {
return nil
}
if cl.id != "" {
if r := m["id "+cl.id]; r != nil {
return r
}
}
return m["mac "+cl.mac]
}
// subnetFor finds the interface's subnet holding a.
func (c *config) subnetFor(iface string, a netip.Addr) *subnet {
for _, s := range c.byIface[iface] {
if s.Prefix.Contains(a) {
return s
}
}
return nil
}
// client identifies who sent a request.
type client struct {
mac string // aa:bb:cc:dd:ee:ff
id string // client identifier (option 61) in hex, or ""
}
// matches reports whether a reservation is for the client: by client
// identifier when the reservation has one, else by MAC address.
func (cl client) matches(r *model.Reservation) bool {
if r.ClientID != "" {
return r.ClientID == cl.id
}
return r.MAC == cl.mac
}
func (cl client) String() string {
if cl.id != "" {
return cl.mac + " (client id " + cl.id + ")"
}
return cl.mac
}
+476
View File
@@ -0,0 +1,476 @@
package dhcpd
import (
"context"
"fmt"
"net/netip"
"time"
"steward/internal/dhcp4"
"steward/internal/model"
)
// Outbound is a reply and where to send it.
type Outbound struct {
Packet *dhcp4.Packet
// Dst is the client's address for unicast replies; invalid for a
// broadcast on the interface.
Dst netip.Addr
}
var broadcast = netip.AddrFrom4([4]byte{255, 255, 255, 255})
// Handle answers one request that arrived on an interface. It returns nil
// when there is nothing to send.
func (s *Server) Handle(ctx context.Context, iface string, p *dhcp4.Packet) *Outbound {
if p.Op != dhcp4.BootRequest {
return nil
}
if p.HType != 1 || p.HLen != 6 {
s.warnOnce("htype "+iface, time.Hour, "ignoring a DHCP request for a hardware type other than Ethernet", "interface", iface, "htype", p.HType, "hlen", p.HLen)
return nil
}
cfg := s.cfg.Load()
subs := cfg.byIface[iface]
if len(subs) == 0 {
return nil
}
if !dhcp4.IsZero(p.GIAddr) {
s.warnOnce("relay "+iface, time.Hour, "ignoring a relayed DHCP request: Steward 0.1 doesn't serve relays yet", "interface", iface, "relay", p.GIAddr)
return nil
}
cl := client{mac: p.MAC().String(), id: p.ClientID()}
t := p.Type()
ev := Event{Time: s.now(), Interface: iface, Type: t.String(), MAC: cl.mac, Hostname: p.HostName()}
if a, ok := p.IPOption(dhcp4.OptRequestedIP); ok {
ev.IP = a.String()
} else if !dhcp4.IsZero(p.CIAddr) {
ev.IP = p.CIAddr.String()
}
s.events.add(ev)
var out *Outbound
switch t {
case dhcp4.Discover:
out = s.discover(ctx, cfg, iface, subs, p, cl)
case dhcp4.Request:
out = s.request(ctx, cfg, iface, p, cl)
case dhcp4.Decline:
s.decline(ctx, cfg, iface, p, cl)
case dhcp4.Release:
s.release(ctx, iface, p, cl)
case dhcp4.Inform:
out = s.inform(cfg, iface, p, cl)
default:
return nil
}
if out != nil {
ev := Event{Time: s.now(), Interface: iface, Type: out.Packet.Type().String(), MAC: cl.mac, Hostname: p.HostName()}
if !dhcp4.IsZero(out.Packet.YIAddr) {
ev.IP = out.Packet.YIAddr.String()
}
if m := out.Packet.Get(dhcp4.OptMessage); m != nil {
ev.Detail = string(m)
}
s.events.add(ev)
}
return out
}
// ---- DISCOVER
func (s *Server) discover(ctx context.Context, cfg *config, iface string, subs []*subnet, p *dhcp4.Packet, cl client) *Outbound {
requested, _ := p.IPOption(dhcp4.OptRequestedIP)
for try := 0; try < 8; try++ {
ip, sub, fresh := s.choose(cfg, iface, subs, cl, requested)
if !ip.IsValid() {
s.warnOnce("full "+iface, 10*time.Minute, "no free address for a client: every pool on the interface is in use", "interface", iface, "client", cl.String())
s.events.add(Event{Time: s.now(), Interface: iface, Type: "NO ADDRESS", MAC: cl.mac, Detail: "every pool on " + iface + " is in use"})
return nil
}
// A fresh address from a pool is pinged first: something that
// answers is using it without a lease.
if fresh && cfg.settings.PingCheck && s.pinger != nil {
if s.pinger.Ping(ctx, ip, cfg.settings.EffectivePingTimeout()) {
s.conflict(ctx, cfg, iface, sub, ip)
continue
}
}
res := cfg.reservationFor(sub.ID, cl)
if res != nil && res.IP != ip {
res = nil
}
sid, err := s.serverAddr(iface, sub)
if err != nil {
s.warnOnce("sid "+iface, 10*time.Minute, "can't answer DHCP: "+err.Error(), "interface", iface)
return nil
}
r := p.Reply(dhcp4.Offer)
r.YIAddr = ip
s.addOptions(r, p, sub, res, sid, true)
return &Outbound{Packet: r}
}
return nil
}
// choose picks the address to offer: the client's reservation, the
// address it had, the one it asks for, or a free one from the pools. fresh
// says the address comes from a pool and the client never had it. The
// address is held for the client for a minute.
func (s *Server) choose(cfg *config, iface string, subs []*subnet, cl client, requested netip.Addr) (netip.Addr, *subnet, bool) {
now := s.now()
own := s.ownAddrs(iface)
s.mu.Lock()
defer s.mu.Unlock()
hold := func(ip netip.Addr, sub *subnet, fresh bool) (netip.Addr, *subnet, bool) {
s.offers[ip] = offer{cl: cl, expires: now.Add(offerHold)}
return ip, sub, fresh
}
// 1. A reservation.
for _, sub := range subs {
if r := cfg.reservationFor(sub.ID, cl); r != nil {
if s.usable(cfg, own, sub, r.IP, cl, now) {
return hold(r.IP, sub, false)
}
s.warnOnce("resbusy "+r.IP.String(), 10*time.Minute, "a client's reserved address is held by another client; handing out another one until it is free", "ip", r.IP, "client", cl.String())
}
}
// 2. The address the client had (an active lease first, then the most
// recent one).
var prev *model.Lease
for _, l := range s.leases {
if !l.Owns(cl.mac, cl.id) || l.State == model.LeaseDeclined {
continue
}
if prev == nil || l.ActiveAt(now) && !prev.ActiveAt(now) || l.ActiveAt(now) == prev.ActiveAt(now) && l.End.After(prev.End) {
prev = l
}
}
if prev != nil {
if sub := cfg.subnetFor(iface, prev.IP); sub != nil && s.usable(cfg, own, sub, prev.IP, cl, now) {
return hold(prev.IP, sub, false)
}
}
// 3. The address the client asks for.
if requested.IsValid() {
if sub := cfg.subnetFor(iface, requested); sub != nil && s.usable(cfg, own, sub, requested, cl, now) {
return hold(requested, sub, true)
}
}
// 4. A free address: one never handed out, else the one free the
// longest.
var oldest netip.Addr
var oldestSub *subnet
var oldestEnd time.Time
for _, sub := range subs {
for _, pool := range sub.Pools {
for v := model.U32(pool.Start); v <= model.U32(pool.End) && v != 0; v++ {
ip := model.FromU32(v)
if !s.usable(cfg, own, sub, ip, cl, now) {
continue
}
l := s.leases[ip]
if l == nil {
return hold(ip, sub, true)
}
if !oldest.IsValid() || l.End.Before(oldestEnd) {
oldest, oldestSub, oldestEnd = ip, sub, l.End
}
if v == model.U32(pool.End) {
break
}
}
}
}
if oldest.IsValid() {
return hold(oldest, oldestSub, true)
}
return netip.Addr{}, nil, false
}
// usable reports whether ip can go to the client (s.mu held): inside the
// subnet but not its network, broadcast or server address; reserved for
// this client or in a pool and not reserved for anyone else; not leased
// to, declined by or offered to someone else.
func (s *Server) usable(cfg *config, own map[netip.Addr]bool, sub *subnet, ip netip.Addr, cl client, now time.Time) bool {
if !sub.Prefix.Contains(ip) || ip == sub.network || ip == sub.broadcast || own[ip] {
return false
}
if r := cfg.resByIP[ip]; r != nil {
if !cl.matches(r) {
return false
}
} else {
inPool := false
for _, p := range sub.Pools {
if p.Contains(ip) {
inPool = true
break
}
}
if !inPool {
return false
}
// A client with a reservation in this subnet gets that address,
// not a pool address.
if r := cfg.reservationFor(sub.ID, cl); r != nil {
return false
}
}
if l := s.leases[ip]; l != nil && l.HeldAt(now) && (l.State == model.LeaseDeclined || !l.Owns(cl.mac, cl.id)) {
return false
}
if o, ok := s.offers[ip]; ok && now.Before(o.expires) && o.cl != cl {
return false
}
return true
}
// conflict holds back an address that answered a ping.
func (s *Server) conflict(ctx context.Context, cfg *config, iface string, sub *subnet, ip netip.Addr) {
now := s.now()
s.mu.Lock()
defer s.mu.Unlock()
delete(s.offers, ip)
l := &model.Lease{IP: ip, SubnetID: sub.ID, State: model.LeaseDeclined, Start: now, End: now.Add(cfg.settings.EffectiveDeclineHold()), Interface: iface, Hostname: "(answered a ping)"}
if err := s.putLease(ctx, l); err != nil {
s.log.Error("saving a conflict failed", "ip", ip, "err", err)
}
s.log.Warn("an address answered a ping before it was offered: something uses it without a lease; holding it back", "ip", ip, "interface", iface, "until", l.End.Format(time.RFC3339))
s.events.add(Event{Time: now, Interface: iface, Type: "CONFLICT", IP: ip.String(), Detail: "answered a ping; held back"})
}
// ---- REQUEST
func (s *Server) request(ctx context.Context, cfg *config, iface string, p *dhcp4.Packet, cl client) *Outbound {
sidOpt, hasSID := p.IPOption(dhcp4.OptServerID)
reqIP, hasReq := p.IPOption(dhcp4.OptRequestedIP)
switch {
case hasSID:
// SELECTING: the client answers an offer, ours or another server's.
if !s.isOwnAddr(iface, sidOpt) {
s.mu.Lock()
for ip, o := range s.offers {
if o.cl == cl {
delete(s.offers, ip)
}
}
s.mu.Unlock()
return nil
}
if !hasReq {
return s.nak(cfg, iface, p, nil, "no requested address")
}
return s.ack(ctx, cfg, iface, p, cl, reqIP)
case hasReq:
// INIT-REBOOT: the client wants to keep the address it had.
return s.ack(ctx, cfg, iface, p, cl, reqIP)
case !dhcp4.IsZero(p.CIAddr):
// RENEWING (unicast to us) or REBINDING (broadcast).
return s.ack(ctx, cfg, iface, p, cl, p.CIAddr)
}
return s.nak(cfg, iface, p, nil, "no address in the request")
}
// ack gives the client ip if it may have it, saving the lease first;
// otherwise a NAK, so the client starts over.
func (s *Server) ack(ctx context.Context, cfg *config, iface string, p *dhcp4.Packet, cl client, ip netip.Addr) *Outbound {
sub := cfg.subnetFor(iface, ip)
if sub == nil {
return s.nak(cfg, iface, p, nil, fmt.Sprintf("%s does not belong on %s", ip, iface))
}
now := s.now()
own := s.ownAddrs(iface)
s.mu.Lock()
if !s.usable(cfg, own, sub, ip, cl, now) {
s.mu.Unlock()
why := "address not available"
if r := cfg.reservationFor(sub.ID, cl); r != nil && r.IP != ip {
why = "this client has a reservation for " + r.IP.String()
}
return s.nak(cfg, iface, p, sub, why)
}
start, host := now, p.HostName()
if l := s.leases[ip]; l != nil && l.Owns(cl.mac, cl.id) {
if l.ActiveAt(now) {
start = l.Start
}
// Renewals often leave the host name out.
if host == "" {
host = l.Hostname
}
}
l := &model.Lease{IP: ip, SubnetID: sub.ID, MAC: cl.mac, ClientID: cl.id, Hostname: host, State: model.LeaseActive,
Start: start, End: now.Add(sub.leaseTime), Interface: iface}
err := s.putLease(ctx, l)
delete(s.offers, ip)
s.mu.Unlock()
if err != nil {
// Without the lease saved, the address could go to someone else
// after a restart: don't hand it out.
s.log.Error("saving a lease failed; not answering", "ip", ip, "err", err)
return nil
}
sid, err := s.serverAddr(iface, sub)
if err != nil {
s.warnOnce("sid "+iface, 10*time.Minute, "can't answer DHCP: "+err.Error(), "interface", iface)
return nil
}
res := cfg.reservationFor(sub.ID, cl)
if res != nil && res.IP != ip {
res = nil
}
r := p.Reply(dhcp4.Ack)
r.YIAddr = ip
r.CIAddr = p.CIAddr
if !r.CIAddr.IsValid() {
r.CIAddr = netip.AddrFrom4([4]byte{})
}
s.addOptions(r, p, sub, res, sid, true)
return &Outbound{Packet: r, Dst: unicastTo(p)}
}
// nak refuses a request. Without authority (settings.not_authoritative),
// requests for addresses Steward doesn't know get no answer instead.
func (s *Server) nak(cfg *config, iface string, p *dhcp4.Packet, sub *subnet, why string) *Outbound {
if cfg.settings.NotAuthoritative && sub == nil {
return nil
}
sid, err := s.serverAddr(iface, sub)
if err != nil {
return nil
}
r := p.Reply(dhcp4.Nak)
r.Flags |= dhcp4.FlagBroadcast
r.Set(dhcp4.OptServerID, dhcp4.IP(sid))
r.Set(dhcp4.OptMessage, []byte(why))
return &Outbound{Packet: r}
}
// unicastTo is where a reply goes: the client's own address when it has
// one, else a broadcast.
func unicastTo(p *dhcp4.Packet) netip.Addr {
if !dhcp4.IsZero(p.CIAddr) {
return p.CIAddr
}
return netip.Addr{}
}
// ---- DECLINE, RELEASE, INFORM
func (s *Server) decline(ctx context.Context, cfg *config, iface string, p *dhcp4.Packet, cl client) {
ip, ok := p.IPOption(dhcp4.OptRequestedIP)
if !ok {
return
}
now := s.now()
s.mu.Lock()
defer s.mu.Unlock()
l := s.leases[ip]
if l == nil || !l.Owns(cl.mac, cl.id) {
return
}
l2 := *l
l2.State, l2.End = model.LeaseDeclined, now.Add(cfg.settings.EffectiveDeclineHold())
if err := s.putLease(ctx, &l2); err != nil {
s.log.Error("saving a declined lease failed", "ip", ip, "err", err)
return
}
s.log.Warn("a client declined its address: another device uses it; holding it back", "ip", ip, "client", cl.String(), "interface", iface, "until", l2.End.Format(time.RFC3339))
}
func (s *Server) release(ctx context.Context, iface string, p *dhcp4.Packet, cl client) {
ip := p.CIAddr
now := s.now()
s.mu.Lock()
defer s.mu.Unlock()
l := s.leases[ip]
if l == nil || !l.Owns(cl.mac, cl.id) || !l.ActiveAt(now) {
return
}
l2 := *l
l2.State, l2.End = model.LeaseReleased, now
if err := s.putLease(ctx, &l2); err != nil {
s.log.Error("saving a released lease failed", "ip", ip, "err", err)
}
}
func (s *Server) inform(cfg *config, iface string, p *dhcp4.Packet, cl client) *Outbound {
if dhcp4.IsZero(p.CIAddr) {
return nil
}
sub := cfg.subnetFor(iface, p.CIAddr)
if sub == nil {
return nil
}
sid, err := s.serverAddr(iface, sub)
if err != nil {
return nil
}
var res *model.Reservation
if r := cfg.resByIP[p.CIAddr]; r != nil && cl.matches(r) {
res = r
}
r := p.Reply(dhcp4.Ack)
r.CIAddr = p.CIAddr
s.addOptions(r, p, sub, res, sid, false)
return &Outbound{Packet: r, Dst: p.CIAddr}
}
// ---- options
// Options always sent when configured, even when the client doesn't ask.
var essential = []byte{dhcp4.OptSubnetMask, dhcp4.OptRouters, dhcp4.OptDNSServers, dhcp4.OptDomainName, dhcp4.OptHostName}
// addOptions adds the server identifier, the lease times (lease true) and
// the configured options: the reservation's over the subnet's over the
// settings'. With a parameter request list only what the client asked
// for (and the essentials) is sent, in its order.
func (s *Server) addOptions(r, req *dhcp4.Packet, sub *subnet, res *model.Reservation, sid netip.Addr, lease bool) {
r.Set(dhcp4.OptServerID, dhcp4.IP(sid))
if lease {
lt := uint32(sub.leaseTime / time.Second)
r.Set(dhcp4.OptLeaseTime, dhcp4.U32(lt))
r.Set(dhcp4.OptRenewalTime, dhcp4.U32(lt/2))
r.Set(dhcp4.OptRebindingTime, dhcp4.U32(lt*7/8))
}
opts := sub.options
if res != nil && (len(res.Options) > 0 || res.Hostname != "") {
opts = encodeOptions(res.Options, sub.options)
if res.Hostname != "" {
opts[dhcp4.OptHostName] = []byte(res.Hostname)
}
}
all := map[byte][]byte{dhcp4.OptSubnetMask: dhcp4.IP(model.Mask(sub.Prefix))}
for k, v := range opts {
all[k] = v
}
var order []byte
added := map[byte]bool{}
addCode := func(c byte) {
if _, ok := all[c]; ok && !added[c] {
added[c] = true
order = append(order, c)
}
}
if prl := req.ParamRequest(); len(prl) > 0 {
for _, c := range prl {
addCode(c)
}
for _, c := range essential {
addCode(c)
}
} else {
for c := byte(1); c < 255; c++ {
addCode(c)
}
}
limit := req.MaxMessageSize() - 28 // IP and UDP headers
for _, c := range order {
r.Set(c, all[c])
if r.Len() > limit {
r.Options = r.Options[:len(r.Options)-1]
s.warnOnce(fmt.Sprintf("size %d", c), time.Hour, "a reply would be too large for the client; leaving out options", "first_left_out", dhcp4.ByCode(c).Name, "max_message_size", req.MaxMessageSize())
break
}
}
}
+446
View File
@@ -0,0 +1,446 @@
package dhcpd
import (
"context"
"errors"
"net"
"net/netip"
"strings"
"sync"
"testing"
"time"
"steward/internal/dhcp4"
"steward/internal/model"
"steward/internal/store"
)
type fakeIfaces map[string][]netip.Prefix
func (f fakeIfaces) Addrs(name string) ([]netip.Prefix, error) {
a, ok := f[name]
if !ok {
return nil, errors.New("no interface " + name)
}
return a, nil
}
type fakePinger struct {
mu sync.Mutex
answers map[netip.Addr]bool
pinged []netip.Addr
}
func (f *fakePinger) Ping(_ context.Context, a netip.Addr, _ time.Duration) bool {
f.mu.Lock()
defer f.mu.Unlock()
f.pinged = append(f.pinged, a)
return f.answers[a]
}
type harness struct {
t *testing.T
s *Server
st *store.Store
ping *fakePinger
clock time.Time
}
func pool(s string) model.Pool {
p, err := model.ParsePool(s)
if err != nil {
panic(err)
}
return p
}
func newHarness(t *testing.T, edit func(st *model.State)) *harness {
t.Helper()
db, err := store.Open(":memory:")
if err != nil {
t.Fatal(err)
}
ifs := fakeIfaces{
"eth0": {netip.MustParsePrefix("10.0.10.2/24")},
"eth0.20": {netip.MustParsePrefix("10.0.20.1/24")},
"eth1": {netip.MustParsePrefix("192.168.1.1/24")},
}
h := &harness{t: t, st: db, ping: &fakePinger{answers: map[netip.Addr]bool{}}, clock: time.Date(2026, 10, 14, 12, 0, 0, 0, time.UTC)}
h.s = New(nil, db, ifs, h.ping)
h.s.now = func() time.Time { return h.clock }
st := &model.State{Settings: model.DefaultSettings(),
Subnets: []*model.Subnet{
{ID: 1, Name: "office", Interface: "eth0", Prefix: netip.MustParsePrefix("10.0.10.0/24"), Pools: []model.Pool{pool("10.0.10.100-10.0.10.103")},
Options: []model.OptionValue{{Code: 3, Value: "10.0.10.1"}, {Code: 6, Value: "10.0.10.2"}, {Code: 15, Value: "lan"}, {Code: 42, Value: "10.0.10.2"}}},
{ID: 2, Name: "iot", Interface: "eth0.20", Prefix: netip.MustParsePrefix("10.0.20.0/24"), Pools: []model.Pool{pool("10.0.20.100-10.0.20.200")}, LeaseTime: 3600},
},
Reservations: []*model.Reservation{
{ID: 3, MAC: "aa:00:00:00:00:99", IP: netip.MustParseAddr("10.0.10.20"), Hostname: "nas", Options: []model.OptionValue{{Code: 6, Value: "1.1.1.1"}}},
{ID: 4, MAC: "aa:00:00:00:00:98", IP: netip.MustParseAddr("10.0.10.101"), Hostname: "printer"},
}}
st.Settings.Options = []model.OptionValue{{Code: 6, Value: "9.9.9.9"}}
if edit != nil {
edit(st)
}
h.s.SetConfig(st)
return h
}
func mac(n byte) net.HardwareAddr { return net.HardwareAddr{0xaa, 0, 0, 0, 0, n} }
func (h *harness) send(iface string, p *dhcp4.Packet) *Outbound {
h.t.Helper()
// Through the wire format, as for real.
q, err := dhcp4.Parse(p.Marshal())
if err != nil {
h.t.Fatal(err)
}
out := h.s.Handle(context.Background(), iface, q)
if out != nil {
r, err := dhcp4.Parse(out.Packet.Marshal())
if err != nil {
h.t.Fatal(err)
}
out.Packet = r
}
return out
}
func (h *harness) discover(iface string, m net.HardwareAddr, opts ...dhcp4.Option) *Outbound {
p := dhcp4.NewRequest(dhcp4.Discover, m, 1)
for _, o := range opts {
p.Set(o.Code, o.Data)
}
return h.send(iface, p)
}
// lease runs DISCOVER and REQUEST and returns the ACK.
func (h *harness) lease(iface string, m net.HardwareAddr) *dhcp4.Packet {
h.t.Helper()
off := h.discover(iface, m)
if off == nil || off.Packet.Type() != dhcp4.Offer {
h.t.Fatalf("no offer for %s on %s", m, iface)
}
sid, _ := off.Packet.IPOption(dhcp4.OptServerID)
req := dhcp4.NewRequest(dhcp4.Request, m, 2)
req.Set(dhcp4.OptServerID, dhcp4.IP(sid))
req.Set(dhcp4.OptRequestedIP, dhcp4.IP(off.Packet.YIAddr))
req.Set(dhcp4.OptHostName, []byte("client"+m.String()[15:]))
ack := h.send(iface, req)
if ack == nil || ack.Packet.Type() != dhcp4.Ack {
h.t.Fatalf("no ACK for %s: %+v", m, ack)
}
if ack.Packet.YIAddr != off.Packet.YIAddr {
h.t.Fatalf("ACK %s differs from offer %s", ack.Packet.YIAddr, off.Packet.YIAddr)
}
return ack.Packet
}
func ipOpt(p *dhcp4.Packet, code byte) string {
return dhcp4.Decode(dhcp4.TypeIPs, p.Get(code))
}
func TestOfferAndAck(t *testing.T) {
h := newHarness(t, nil)
off := h.discover("eth0", mac(1))
if off == nil {
t.Fatal("no offer")
}
p := off.Packet
if p.YIAddr.String() != "10.0.10.100" || off.Dst.IsValid() {
t.Errorf("offered %s to %v", p.YIAddr, off.Dst)
}
if ipOpt(p, dhcp4.OptServerID) != "10.0.10.2" || ipOpt(p, dhcp4.OptSubnetMask) != "255.255.255.0" || ipOpt(p, dhcp4.OptRouters) != "10.0.10.1" {
t.Errorf("options: sid %s mask %s routers %s", ipOpt(p, 54), ipOpt(p, 1), ipOpt(p, 3))
}
// The subnet's DNS server wins over the settings'.
if ipOpt(p, dhcp4.OptDNSServers) != "10.0.10.2" || string(p.Get(dhcp4.OptDomainName)) != "lan" {
t.Errorf("dns %s domain %q", ipOpt(p, 6), p.Get(15))
}
if dhcp4.Decode(dhcp4.TypeU32, p.Get(dhcp4.OptLeaseTime)) != "43200" || dhcp4.Decode(dhcp4.TypeU32, p.Get(dhcp4.OptRenewalTime)) != "21600" {
t.Errorf("lease time %v", p.Get(51))
}
ack := h.lease("eth0", mac(1))
l := h.s.Lease(ack.YIAddr)
if l == nil || l.State != model.LeaseActive || l.MAC != mac(1).String() || l.Interface != "eth0" || l.SubnetID != 1 || l.Hostname != "client01" {
t.Fatalf("lease %+v", l)
}
// Saved before the ACK.
saved, _ := h.st.LoadLeases(context.Background())
if len(saved) != 1 || saved[0].IP != ack.YIAddr {
t.Errorf("saved %+v", saved)
}
}
func TestSubnetsPerInterface(t *testing.T) {
h := newHarness(t, nil)
if ip := h.lease("eth0.20", mac(1)).YIAddr; !strings.HasPrefix(ip.String(), "10.0.20.") {
t.Errorf("a client on eth0.20 got %s", ip)
}
// The same MAC on the office VLAN gets an office address.
if ip := h.lease("eth0", mac(1)).YIAddr; !strings.HasPrefix(ip.String(), "10.0.10.") {
t.Errorf("a client on eth0 got %s", ip)
}
// No subnet on eth1: no answer at all.
if out := h.discover("eth1", mac(2)); out != nil {
t.Errorf("answered on eth1: %+v", out)
}
// Asking eth0.20 for an office address: NAK.
req := dhcp4.NewRequest(dhcp4.Request, mac(3), 5)
req.Set(dhcp4.OptRequestedIP, dhcp4.IP(netip.MustParseAddr("10.0.10.100")))
out := h.send("eth0.20", req)
if out == nil || out.Packet.Type() != dhcp4.Nak || !strings.Contains(string(out.Packet.Get(dhcp4.OptMessage)), "does not belong on eth0.20") {
t.Errorf("init-reboot on the wrong VLAN: %+v", out)
}
}
func TestReservations(t *testing.T) {
h := newHarness(t, nil)
ack := h.lease("eth0", mac(0x99))
if ack.YIAddr.String() != "10.0.10.20" || string(ack.Get(dhcp4.OptHostName)) != "nas" || ipOpt(ack, 6) != "1.1.1.1" {
t.Errorf("reserved client got %s host %q dns %s", ack.YIAddr, ack.Get(12), ipOpt(ack, 6))
}
// 10.0.10.101 is reserved for the printer: nobody else gets it, even
// though it is in the pool.
got := map[string]bool{}
for i := byte(1); i <= 3; i++ {
got[h.lease("eth0", mac(i)).YIAddr.String()] = true
}
if got["10.0.10.101"] || !got["10.0.10.100"] || !got["10.0.10.102"] || !got["10.0.10.103"] {
t.Errorf("pool addresses handed out: %v", got)
}
// The pool is now full (the reserved address doesn't count).
if out := h.discover("eth0", mac(4)); out != nil {
t.Errorf("offered %s from a full pool", out.Packet.YIAddr)
}
if h.lease("eth0", mac(0x98)).YIAddr.String() != "10.0.10.101" {
t.Error("the printer didn't get its reservation")
}
// A client with a reservation asking for another address gets a NAK
// that says why.
req := dhcp4.NewRequest(dhcp4.Request, mac(0x99), 7)
req.Set(dhcp4.OptRequestedIP, dhcp4.IP(netip.MustParseAddr("10.0.10.102")))
out := h.send("eth0", req)
if out == nil || out.Packet.Type() != dhcp4.Nak || !strings.Contains(string(out.Packet.Get(dhcp4.OptMessage)), "reservation for 10.0.10.20") {
t.Errorf("got %+v", out)
}
}
func TestReturningClientKeepsAddress(t *testing.T) {
h := newHarness(t, nil)
first := h.lease("eth0", mac(1)).YIAddr
h.lease("eth0", mac(2))
rel := dhcp4.NewRequest(dhcp4.Release, mac(1), 9)
rel.CIAddr = first
if out := h.send("eth0", rel); out != nil {
t.Error("a release was answered")
}
if l := h.s.Lease(first); l.State != model.LeaseReleased {
t.Errorf("after release: %+v", l)
}
h.clock = h.clock.Add(time.Hour)
if again := h.lease("eth0", mac(1)).YIAddr; again != first {
t.Errorf("came back to %s, had %s", again, first)
}
// Expired leases too.
h.clock = h.clock.Add(48 * time.Hour)
if again := h.lease("eth0", mac(1)).YIAddr; again != first {
t.Errorf("after expiry got %s, had %s", again, first)
}
// A new client gets a never-used address before a released one.
if ip := h.lease("eth0", mac(3)).YIAddr; ip == first || ip.String() == "10.0.10.101" {
t.Errorf("new client got %s", ip)
}
}
func TestRenewAndRebind(t *testing.T) {
h := newHarness(t, nil)
ack := h.lease("eth0.20", mac(1))
start := h.s.Lease(ack.YIAddr).Start
h.clock = h.clock.Add(30 * time.Minute)
ren := dhcp4.NewRequest(dhcp4.Request, mac(1), 11)
ren.CIAddr = ack.YIAddr
out := h.send("eth0.20", ren)
if out == nil || out.Packet.Type() != dhcp4.Ack || out.Dst != ack.YIAddr || out.Packet.CIAddr != ack.YIAddr {
t.Fatalf("renewal: %+v", out)
}
l := h.s.Lease(ack.YIAddr)
if l.Hostname != "client01" {
t.Errorf("a renewal without a host name lost it: %q", l.Hostname)
}
if !l.Start.Equal(start) || !l.End.Equal(h.clock.Add(time.Hour)) {
t.Errorf("renewed lease %v - %v", l.Start, l.End)
}
// Another client renewing that address: NAK.
ren2 := dhcp4.NewRequest(dhcp4.Request, mac(2), 12)
ren2.CIAddr = ack.YIAddr
if out := h.send("eth0.20", ren2); out == nil || out.Packet.Type() != dhcp4.Nak {
t.Errorf("someone else's renewal: %+v", out)
}
}
func TestInitReboot(t *testing.T) {
h := newHarness(t, nil)
ip := h.lease("eth0", mac(1)).YIAddr
req := dhcp4.NewRequest(dhcp4.Request, mac(1), 20)
req.Set(dhcp4.OptRequestedIP, dhcp4.IP(ip))
if out := h.send("eth0", req); out == nil || out.Packet.Type() != dhcp4.Ack || out.Dst.IsValid() {
t.Errorf("own address after reboot: %+v", out)
}
// Someone else's address: NAK.
req = dhcp4.NewRequest(dhcp4.Request, mac(2), 21)
req.Set(dhcp4.OptRequestedIP, dhcp4.IP(ip))
if out := h.send("eth0", req); out == nil || out.Packet.Type() != dhcp4.Nak {
t.Errorf("another's address: %+v", out)
}
// A free pool address from a client Steward hasn't seen (say, after
// switching from another server): ACK.
req = dhcp4.NewRequest(dhcp4.Request, mac(3), 22)
req.Set(dhcp4.OptRequestedIP, dhcp4.IP(netip.MustParseAddr("10.0.10.103")))
if out := h.send("eth0", req); out == nil || out.Packet.Type() != dhcp4.Ack {
t.Errorf("free address: %+v", out)
}
// An address outside the pools: NAK.
req = dhcp4.NewRequest(dhcp4.Request, mac(4), 23)
req.Set(dhcp4.OptRequestedIP, dhcp4.IP(netip.MustParseAddr("10.0.10.50")))
if out := h.send("eth0", req); out == nil || out.Packet.Type() != dhcp4.Nak {
t.Errorf("address outside the pools: %+v", out)
}
}
func TestOtherServerChosen(t *testing.T) {
h := newHarness(t, nil)
off := h.discover("eth0", mac(1))
req := dhcp4.NewRequest(dhcp4.Request, mac(1), 2)
req.Set(dhcp4.OptServerID, dhcp4.IP(netip.MustParseAddr("10.0.10.254")))
req.Set(dhcp4.OptRequestedIP, dhcp4.IP(netip.MustParseAddr("10.0.10.77")))
if out := h.send("eth0", req); out != nil {
t.Errorf("answered a request to another server: %+v", out)
}
// The offer was given up: another client can have the address.
if out := h.discover("eth0", mac(2)); out == nil || out.Packet.YIAddr != off.Packet.YIAddr {
t.Errorf("offer not released: %+v", out)
}
}
func TestDeclineAndPingConflict(t *testing.T) {
h := newHarness(t, nil)
ip := h.lease("eth0", mac(1)).YIAddr
dec := dhcp4.NewRequest(dhcp4.Decline, mac(1), 30)
dec.Set(dhcp4.OptRequestedIP, dhcp4.IP(ip))
h.send("eth0", dec)
if l := h.s.Lease(ip); l.State != model.LeaseDeclined || !l.End.Equal(h.clock.Add(24*time.Hour)) {
t.Errorf("declined lease %+v", l)
}
// Not offered again, not even to the client that declined it.
if out := h.discover("eth0", mac(1)); out == nil || out.Packet.YIAddr == ip {
t.Errorf("declined address offered again: %+v", out)
}
// An address that answers a ping is skipped and held back.
h2 := newHarness(t, nil)
h2.ping.answers[netip.MustParseAddr("10.0.10.100")] = true
off := h2.discover("eth0", mac(1))
if off == nil || off.Packet.YIAddr.String() != "10.0.10.102" {
t.Fatalf("offer after a conflict: %+v", off)
}
if l := h2.s.Lease(netip.MustParseAddr("10.0.10.100")); l == nil || l.State != model.LeaseDeclined {
t.Errorf("conflict not recorded: %+v", l)
}
// Reservations aren't pinged (the device may still have its address).
h2.ping.pinged = nil
h2.lease("eth0", mac(0x99))
for _, a := range h2.ping.pinged {
if a.String() == "10.0.10.20" {
t.Error("pinged a reserved address")
}
}
}
func TestSharedNetwork(t *testing.T) {
h := newHarness(t, func(st *model.State) {
st.Subnets = append(st.Subnets, &model.Subnet{ID: 5, Interface: "eth0", Prefix: netip.MustParsePrefix("10.0.11.0/24"), Pools: []model.Pool{pool("10.0.11.10-10.0.11.11")}})
})
got := map[string]bool{}
for i := byte(1); i <= 5; i++ {
got[h.lease("eth0", mac(i)).YIAddr.String()] = true
}
if !got["10.0.11.10"] || !got["10.0.11.11"] {
t.Errorf("second subnet not used: %v", got)
}
// Its clients are answered from the interface's address (10.0.10.2),
// with the second subnet's mask.
l := h.s.Lease(netip.MustParseAddr("10.0.11.10"))
ren := dhcp4.NewRequest(dhcp4.Request, net.HardwareAddr(mustMAC(l.MAC)), 3)
ren.CIAddr = l.IP
out := h.send("eth0", ren)
if out == nil || ipOpt(out.Packet, 54) != "10.0.10.2" || ipOpt(out.Packet, 1) != "255.255.255.0" {
t.Errorf("renewal in the second subnet: %+v", out)
}
}
func mustMAC(s string) net.HardwareAddr {
m, err := net.ParseMAC(s)
if err != nil {
panic(err)
}
return m
}
func TestParameterRequestList(t *testing.T) {
h := newHarness(t, nil)
off := h.discover("eth0", mac(1), dhcp4.Option{Code: dhcp4.OptParamRequest, Data: []byte{42, 1}})
var codes []byte
for _, o := range off.Packet.Options {
codes = append(codes, o.Code)
}
// Type, server ID and lease times; then NTP and mask as asked; then
// the essentials the client didn't ask for.
want := []byte{53, 54, 51, 58, 59, 42, 1, 3, 6, 15}
if string(codes) != string(want) {
t.Errorf("options %v, want %v", codes, want)
}
}
func TestInformAndRelay(t *testing.T) {
h := newHarness(t, nil)
inf := dhcp4.NewRequest(dhcp4.Inform, mac(1), 40)
inf.CIAddr = netip.MustParseAddr("10.0.10.50")
out := h.send("eth0", inf)
if out == nil || out.Packet.Type() != dhcp4.Ack || out.Dst != inf.CIAddr || out.Packet.Has(dhcp4.OptLeaseTime) || ipOpt(out.Packet, 3) != "10.0.10.1" {
t.Errorf("inform: %+v", out)
}
rel := dhcp4.NewRequest(dhcp4.Discover, mac(2), 41)
rel.GIAddr = netip.MustParseAddr("10.0.30.1")
if out := h.send("eth0", rel); out != nil {
t.Error("answered a relayed request")
}
}
func TestNotAuthoritative(t *testing.T) {
h := newHarness(t, func(st *model.State) { st.Settings.NotAuthoritative = true })
req := dhcp4.NewRequest(dhcp4.Request, mac(3), 5)
req.Set(dhcp4.OptRequestedIP, dhcp4.IP(netip.MustParseAddr("192.168.7.7")))
if out := h.send("eth0", req); out != nil {
t.Errorf("NAK without authority: %+v", out)
}
}
func TestDisabledSubnet(t *testing.T) {
h := newHarness(t, func(st *model.State) { st.Subnets[1].Disabled = true })
if out := h.discover("eth0.20", mac(1)); out != nil {
t.Error("a disabled subnet answered")
}
}
func TestPrune(t *testing.T) {
h := newHarness(t, nil)
ip := h.lease("eth0", mac(1)).YIAddr
h.clock = h.clock.Add(40 * 24 * time.Hour)
h.s.Prune(context.Background())
if h.s.Lease(ip) != nil {
t.Error("old lease kept")
}
if ls, _ := h.st.LoadLeases(context.Background()); len(ls) != 0 {
t.Errorf("old lease still saved: %+v", ls)
}
}
+211
View File
@@ -0,0 +1,211 @@
package dhcpd
import (
"context"
"errors"
"net"
"net/netip"
"sort"
"strings"
"sync"
"time"
"steward/internal/dhcp4"
)
// packetConn is what a listener reads from and writes to.
type packetConn interface {
ReadFromUDPAddrPort(b []byte) (int, netip.AddrPort, error)
WriteToUDPAddrPort(b []byte, addr netip.AddrPort) (int, error)
Close() error
}
type listener struct {
conn packetConn
err string
since time.Time
}
type listeners struct {
mu sync.Mutex
open map[string]*listener
// failed are interfaces whose socket couldn't be opened, with why.
failed map[string]*listener
}
// listenFunc opens the socket for an interface (listen_linux.go); tests
// replace it.
var listenFunc = listenInterface
// Run serves DHCP until ctx ends: it opens a socket on every interface
// that has a subnet, and follows configuration changes. Interfaces that
// don't exist yet (a VLAN that comes up later) are retried every 30
// seconds.
func (s *Server) Run(ctx context.Context) {
t := time.NewTicker(30 * time.Second)
defer t.Stop()
prune := time.NewTicker(time.Hour)
defer prune.Stop()
for {
s.reconcile(ctx)
select {
case <-ctx.Done():
s.closeAll()
return
case <-s.changed:
case <-t.C:
case <-prune.C:
s.Prune(ctx)
}
}
}
func (s *Server) reconcile(ctx context.Context) {
want := map[string]bool{}
for iface := range s.cfg.Load().byIface {
want[iface] = true
}
s.listen.mu.Lock()
defer s.listen.mu.Unlock()
if s.listen.open == nil {
s.listen.open, s.listen.failed = map[string]*listener{}, map[string]*listener{}
}
for iface, l := range s.listen.open {
if !want[iface] {
l.conn.Close()
delete(s.listen.open, iface)
s.log.Info("stopped serving DHCP on an interface (no subnets left)", "interface", iface)
}
}
for iface := range s.listen.failed {
if !want[iface] {
delete(s.listen.failed, iface)
}
}
for iface := range want {
if s.listen.open[iface] != nil {
continue
}
conn, err := listenFunc(iface, s.ServerPort)
if err != nil {
if f := s.listen.failed[iface]; f == nil || f.err != err.Error() {
s.log.Warn("can't serve DHCP on an interface; retrying every 30 seconds", "interface", iface, "err", err)
s.listen.failed[iface] = &listener{err: err.Error(), since: s.now()}
}
continue
}
delete(s.listen.failed, iface)
l := &listener{conn: conn, since: s.now()}
s.listen.open[iface] = l
s.log.Info("serving DHCP", "interface", iface, "port", s.ServerPort)
go s.serve(ctx, iface, conn)
}
}
func (s *Server) closeAll() {
s.listen.mu.Lock()
defer s.listen.mu.Unlock()
for iface, l := range s.listen.open {
l.conn.Close()
delete(s.listen.open, iface)
}
}
// serve reads requests from one interface's socket. Each is answered in
// its own goroutine (a ping check takes up to its timeout), at most 32 at
// a time.
func (s *Server) serve(ctx context.Context, iface string, conn packetConn) {
sem := make(chan struct{}, 32)
buf := make([]byte, 4096)
for {
n, src, err := conn.ReadFromUDPAddrPort(buf)
if err != nil {
if errors.Is(err, net.ErrClosed) || ctx.Err() != nil {
return
}
s.warnOnce("read "+iface, time.Minute, "reading a DHCP request failed", "interface", iface, "err", err)
time.Sleep(100 * time.Millisecond)
continue
}
p, err := dhcp4.Parse(append([]byte(nil), buf[:n]...))
if err != nil {
s.warnOnce("parse "+iface+" "+src.Addr().String(), 10*time.Minute, "ignoring a malformed DHCP message", "interface", iface, "from", src, "err", err)
continue
}
sem <- struct{}{}
go func() {
defer func() {
<-sem
if v := recover(); v != nil {
s.log.Error("panic answering a DHCP request", "interface", iface, "panic", v)
}
}()
if out := s.Handle(ctx, iface, p); out != nil {
s.send(conn, iface, out)
}
}()
}
}
func (s *Server) send(conn packetConn, iface string, out *Outbound) {
dst := netip.AddrPortFrom(broadcast, uint16(s.ClientPort))
if out.Dst.IsValid() {
dst = netip.AddrPortFrom(out.Dst, uint16(s.ClientPort))
}
if _, err := conn.WriteToUDPAddrPort(out.Packet.Marshal(), dst); err != nil {
s.warnOnce("send "+iface, time.Minute, "sending a DHCP reply failed", "interface", iface, "to", dst, "err", err)
}
}
// InterfaceStatus says whether DHCP is served on an interface.
type InterfaceStatus struct {
Name string `json:"name"`
Listening bool `json:"listening"`
Error string `json:"error,omitempty"`
Since time.Time `json:"since"`
Addresses []string `json:"addresses"`
Subnets []int64 `json:"subnets"`
}
// Status lists the interfaces that have subnets.
func (s *Server) Status() []InterfaceStatus {
cfg := s.cfg.Load()
s.listen.mu.Lock()
defer s.listen.mu.Unlock()
var out []InterfaceStatus
for iface, subs := range cfg.byIface {
st := InterfaceStatus{Name: iface, Addresses: []string{}, Subnets: []int64{}}
for _, sub := range subs {
st.Subnets = append(st.Subnets, sub.ID)
}
if l := s.listen.open[iface]; l != nil {
st.Listening, st.Since = true, l.since
} else if l := s.listen.failed[iface]; l != nil {
st.Error, st.Since = l.err, l.since
} else {
st.Error = "starting"
}
if addrs, err := s.ifaces.Addrs(iface); err == nil {
for _, a := range addrs {
st.Addresses = append(st.Addresses, a.String())
}
}
out = append(out, st)
}
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
return out
}
// explainListenError adds what to do to a socket error.
func explainListenError(iface string, port int, err error) error {
msg := err.Error()
switch {
case strings.Contains(msg, "no such device"), strings.Contains(msg, "no such network interface"):
return errors.New("there is no interface " + iface + " on this server (yet)")
case strings.Contains(msg, "permission denied") && port < 1024:
return errors.New(msg + " (port 67 needs the CAP_NET_BIND_SERVICE capability; steward.service grants it, so check the installed unit: systemctl cat steward)")
case strings.Contains(msg, "address already in use"):
return errors.New(msg + " (another DHCP server, such as Kea or dnsmasq, is listening on this interface; stop it first)")
}
return err
}
+40
View File
@@ -0,0 +1,40 @@
package dhcpd
import (
"context"
"net"
"strconv"
"syscall"
)
// listenInterface opens a UDP socket on the DHCP server port that only
// sees the interface's traffic (SO_BINDTODEVICE), so a request's
// interface is known, and that may send broadcasts out of it. Sockets for
// several interfaces share the port (SO_REUSEADDR with different
// devices).
func listenInterface(iface string, port int) (packetConn, error) {
if _, err := net.InterfaceByName(iface); err != nil {
return nil, explainListenError(iface, port, err)
}
var serr error
lc := net.ListenConfig{Control: func(network, address string, c syscall.RawConn) error {
err := c.Control(func(fd uintptr) {
if serr = syscall.SetsockoptInt(int(fd), syscall.SOL_SOCKET, syscall.SO_REUSEADDR, 1); serr != nil {
return
}
if serr = syscall.SetsockoptInt(int(fd), syscall.SOL_SOCKET, syscall.SO_BROADCAST, 1); serr != nil {
return
}
serr = syscall.SetsockoptString(int(fd), syscall.SOL_SOCKET, syscall.SO_BINDTODEVICE, iface)
})
if err != nil {
return err
}
return serr
}}
pc, err := lc.ListenPacket(context.Background(), "udp4", "0.0.0.0:"+strconv.Itoa(port))
if err != nil {
return nil, explainListenError(iface, port, err)
}
return pc.(*net.UDPConn), nil
}
+9
View File
@@ -0,0 +1,9 @@
//go:build !linux
package dhcpd
import "errors"
func listenInterface(iface string, port int) (packetConn, error) {
return nil, errors.New("Steward's DHCP server runs on Linux only")
}
+84
View File
@@ -0,0 +1,84 @@
package dhcpd
import (
"context"
"log/slog"
"net"
"net/netip"
"os"
"sync"
"sync/atomic"
"time"
"golang.org/x/net/icmp"
"golang.org/x/net/ipv4"
)
// ICMPPinger pings with an unprivileged ICMP socket (Linux "ping sockets",
// allowed for the groups in net.ipv4.ping_group_range, which systemd opens
// to everyone). When the socket can't be opened, the check is skipped and
// Unavailable says why.
type ICMPPinger struct {
Log *slog.Logger
seq atomic.Uint32
once sync.Once
// Unavailable is why pings can't be sent ("" when they can).
Unavailable atomic.Value
}
// Ping reports whether a answers an echo request within timeout.
func (p *ICMPPinger) Ping(ctx context.Context, a netip.Addr, timeout time.Duration) bool {
conn, err := icmp.ListenPacket("udp4", "0.0.0.0")
if err != nil {
p.once.Do(func() {
msg := "can't send pings, so addresses aren't checked before they are offered: " + err.Error() + " (check net.ipv4.ping_group_range)"
p.Unavailable.Store(msg)
if p.Log != nil {
p.Log.Warn(msg)
}
})
return false
}
defer conn.Close()
seq := int(p.seq.Add(1) & 0xffff)
msg := icmp.Message{Type: ipv4.ICMPTypeEcho, Body: &icmp.Echo{ID: os.Getpid() & 0xffff, Seq: seq, Data: []byte("steward conflict check")}}
b, err := msg.Marshal(nil)
if err != nil {
return false
}
dst := &net.UDPAddr{IP: a.AsSlice()}
if _, err := conn.WriteTo(b, dst); err != nil {
return false
}
deadline := time.Now().Add(timeout)
if d, ok := ctx.Deadline(); ok && d.Before(deadline) {
deadline = d
}
conn.SetReadDeadline(deadline)
buf := make([]byte, 1500)
for {
n, from, err := conn.ReadFrom(buf)
if err != nil {
return false
}
ua, ok := from.(*net.UDPAddr)
if !ok || !ua.IP.Equal(dst.IP) {
continue
}
m, err := icmp.ParseMessage(1, buf[:n])
if err != nil {
continue
}
if e, ok := m.Body.(*icmp.Echo); ok && m.Type == ipv4.ICMPTypeEchoReply && e.Seq == seq {
return true
}
}
}
// Problem says why pings can't be sent ("" when they can).
func (p *ICMPPinger) Problem() string {
if v, ok := p.Unavailable.Load().(string); ok {
return v
}
return ""
}
+371
View File
@@ -0,0 +1,371 @@
// Package dhcpd is Steward's DHCPv4 server: it listens on the interfaces
// that have subnets, answers requests from those subnets only, and keeps
// the leases (docs/design.md, "Answering DHCP").
package dhcpd
import (
"context"
"errors"
"log/slog"
"net"
"net/netip"
"sort"
"strings"
"sync"
"sync/atomic"
"time"
"steward/internal/model"
)
// LeaseStore persists leases.
type LeaseStore interface {
LoadLeases(ctx context.Context) ([]*model.Lease, error)
PutLease(ctx context.Context, l *model.Lease) error
DeleteLease(ctx context.Context, ip netip.Addr) error
PruneLeases(ctx context.Context, before time.Time) (int64, error)
}
// Interfaces reports a network interface's IPv4 addresses with their
// prefix lengths (10.0.10.1/24); an error when it doesn't exist.
type Interfaces interface {
Addrs(name string) ([]netip.Prefix, error)
}
// Pinger checks whether an address answers (conflict detection).
type Pinger interface {
Ping(ctx context.Context, a netip.Addr, timeout time.Duration) bool
}
// How long an offer holds its address for the client.
const offerHold = time.Minute
// Leases that ended this long ago are deleted (until then, a client that
// comes back gets its old address if it is free).
const keepEnded = 30 * 24 * time.Hour
// Server answers DHCP requests.
type Server struct {
log *slog.Logger
store LeaseStore
ifaces Interfaces
pinger Pinger
now func() time.Time
// ServerPort and ClientPort are 67 and 68 (tests use others).
ServerPort, ClientPort int
cfg atomic.Pointer[config]
mu sync.Mutex
leases map[netip.Addr]*model.Lease
offers map[netip.Addr]offer
events events
warned sync.Map // throttled log lines: key -> time.Time
listen listeners
changed chan struct{}
}
type offer struct {
cl client
expires time.Time
}
// New makes a server. ifaces and pinger may be nil (the host's interfaces;
// no ping check).
func New(log *slog.Logger, st LeaseStore, ifaces Interfaces, pinger Pinger) *Server {
if log == nil {
log = slog.Default()
}
if ifaces == nil {
ifaces = &HostInterfaces{}
}
s := &Server{log: log, store: st, ifaces: ifaces, pinger: pinger, now: time.Now, ServerPort: 67, ClientPort: 68,
leases: map[netip.Addr]*model.Lease{}, offers: map[netip.Addr]offer{}, changed: make(chan struct{}, 1)}
s.cfg.Store(compile(&model.State{Settings: model.DefaultSettings()}))
s.events.max = 1000
return s
}
// LoadLeases reads the leases from the store (at start).
func (s *Server) LoadLeases(ctx context.Context) error {
ls, err := s.store.LoadLeases(ctx)
if err != nil {
return err
}
s.mu.Lock()
defer s.mu.Unlock()
for _, l := range ls {
s.leases[l.IP] = l
}
return nil
}
// SetConfig gives the server a new configuration. Listeners follow it.
func (s *Server) SetConfig(st *model.State) {
s.cfg.Store(compile(st))
select {
case s.changed <- struct{}{}:
default:
}
}
// Leases returns a copy of every lease, by address.
func (s *Server) Leases() []*model.Lease {
s.mu.Lock()
defer s.mu.Unlock()
out := make([]*model.Lease, 0, len(s.leases))
for _, l := range s.leases {
c := *l
out = append(out, &c)
}
sort.Slice(out, func(i, j int) bool { return out[i].IP.Less(out[j].IP) })
return out
}
// Lease returns one lease.
func (s *Server) Lease(ip netip.Addr) *model.Lease {
s.mu.Lock()
defer s.mu.Unlock()
if l := s.leases[ip]; l != nil {
c := *l
return &c
}
return nil
}
// ErrNoLease is returned for an address without a lease.
var ErrNoLease = errors.New("no lease for that address")
// DeleteLease forgets a lease: the address is free at once (the client
// may still be using it until it next renews, and then gets a NAK).
func (s *Server) DeleteLease(ctx context.Context, ip netip.Addr) error {
s.mu.Lock()
defer s.mu.Unlock()
if s.leases[ip] == nil {
return ErrNoLease
}
if err := s.store.DeleteLease(ctx, ip); err != nil {
return err
}
delete(s.leases, ip)
delete(s.offers, ip)
return nil
}
// ImportLeases adds leases (from another server), replacing what is known
// for their addresses.
func (s *Server) ImportLeases(ctx context.Context, ls []*model.Lease) error {
s.mu.Lock()
defer s.mu.Unlock()
for _, l := range ls {
if err := s.store.PutLease(ctx, l); err != nil {
return err
}
c := *l
s.leases[l.IP] = &c
}
return nil
}
// Prune deletes leases that ended long ago, and forgets old offers.
func (s *Server) Prune(ctx context.Context) {
now := s.now()
s.mu.Lock()
defer s.mu.Unlock()
cut := now.Add(-keepEnded)
n := 0
for ip, l := range s.leases {
if l.End.Before(cut) {
delete(s.leases, ip)
n++
}
}
for ip, o := range s.offers {
if now.After(o.expires) {
delete(s.offers, ip)
}
}
if n > 0 {
if _, err := s.store.PruneLeases(ctx, cut); err != nil {
s.log.Warn("pruning old leases failed", "err", err)
}
}
}
// putLease saves a lease, then keeps it (s.mu held).
func (s *Server) putLease(ctx context.Context, l *model.Lease) error {
l.Updated = s.now()
if err := s.store.PutLease(ctx, l); err != nil {
return err
}
c := *l
s.leases[l.IP] = &c
return nil
}
// warnOnce logs a warning at most once per interval for a key.
func (s *Server) warnOnce(key string, every time.Duration, msg string, args ...any) {
now := s.now()
if v, ok := s.warned.Load(key); ok && now.Sub(v.(time.Time)) < every {
return
}
s.warned.Store(key, now)
s.log.Warn(msg, args...)
}
// ---- interfaces
// HostInterfaces reads this machine's interfaces (cached briefly, since
// every request asks).
type HostInterfaces struct {
mu sync.Mutex
at time.Time
cache map[string][]netip.Prefix
err error
}
// Addrs implements Interfaces.
func (h *HostInterfaces) Addrs(name string) ([]netip.Prefix, error) {
all, err := h.All()
if err != nil {
return nil, err
}
a, ok := all[name]
if !ok {
return nil, errors.New("no interface " + name)
}
return a, nil
}
// All returns every interface with its IPv4 prefixes.
func (h *HostInterfaces) All() (map[string][]netip.Prefix, error) {
h.mu.Lock()
defer h.mu.Unlock()
if time.Since(h.at) < 5*time.Second && (h.cache != nil || h.err != nil) {
return h.cache, h.err
}
h.at = time.Now()
ifs, err := net.Interfaces()
if err != nil {
h.cache, h.err = nil, err
return nil, err
}
m := map[string][]netip.Prefix{}
for _, i := range ifs {
var ps []netip.Prefix
addrs, _ := i.Addrs()
for _, a := range addrs {
if n, ok := a.(*net.IPNet); ok {
if ip4 := n.IP.To4(); ip4 != nil {
bits, _ := n.Mask.Size()
ps = append(ps, netip.PrefixFrom(netip.AddrFrom4([4]byte(ip4)), bits))
}
}
}
m[i.Name] = ps
}
h.cache, h.err = m, nil
return m, nil
}
// serverAddr is the interface's address to answer a subnet's clients
// from: its address in the subnet, else its first IPv4 address.
func (s *Server) serverAddr(iface string, sub *subnet) (netip.Addr, error) {
addrs, err := s.ifaces.Addrs(iface)
if err != nil {
return netip.Addr{}, err
}
for _, a := range addrs {
if sub != nil && sub.Prefix.Contains(a.Addr()) {
return a.Addr(), nil
}
}
if len(addrs) > 0 {
return addrs[0].Addr(), nil
}
return netip.Addr{}, errors.New("interface " + iface + " has no IPv4 address")
}
// ownAddrs is the set of the interface's addresses.
func (s *Server) ownAddrs(iface string) map[netip.Addr]bool {
addrs, _ := s.ifaces.Addrs(iface)
m := make(map[netip.Addr]bool, len(addrs))
for _, p := range addrs {
m[p.Addr()] = true
}
return m
}
// isOwnAddr reports whether a is one of the interface's addresses.
func (s *Server) isOwnAddr(iface string, a netip.Addr) bool {
addrs, _ := s.ifaces.Addrs(iface)
for _, p := range addrs {
if p.Addr() == a {
return true
}
}
return false
}
// ---- events
// Event is one DHCP message in or out, for the activity log.
type Event struct {
Time time.Time `json:"time"`
Interface string `json:"interface"`
Type string `json:"type"` // DHCPDISCOVER, DHCPOFFER, ...
MAC string `json:"mac"`
IP string `json:"ip,omitempty"`
Hostname string `json:"hostname,omitempty"`
Detail string `json:"detail,omitempty"`
}
type events struct {
mu sync.Mutex
max int
list []Event
counts map[string]uint64
}
func (e *events) add(ev Event) {
e.mu.Lock()
defer e.mu.Unlock()
e.list = append(e.list, ev)
if len(e.list) > e.max {
e.list = append([]Event(nil), e.list[len(e.list)-e.max:]...)
}
if e.counts == nil {
e.counts = map[string]uint64{}
}
e.counts[ev.Type]++
}
// Events returns the latest events, newest first, optionally only those of
// one MAC address or interface.
func (s *Server) Events(limit int, mac, iface string) []Event {
s.events.mu.Lock()
defer s.events.mu.Unlock()
out := []Event{}
for i := len(s.events.list) - 1; i >= 0 && (limit <= 0 || len(out) < limit); i-- {
ev := s.events.list[i]
if mac != "" && !strings.EqualFold(ev.MAC, mac) || iface != "" && ev.Interface != iface {
continue
}
out = append(out, ev)
}
return out
}
// Counts returns how many messages of each type were seen and sent since
// the start.
func (s *Server) Counts() map[string]uint64 {
s.events.mu.Lock()
defer s.events.mu.Unlock()
out := map[string]uint64{}
for k, v := range s.events.counts {
out[k] = v
}
return out
}
+459
View File
@@ -0,0 +1,459 @@
// Package engine changes Steward's configuration. Every change, from the
// API, the CLI or an import, goes through Apply: the change is made on a
// copy of the state, the result is validated as a whole, and only then are
// the changed objects and a history entry saved together and the new state
// handed to the DHCP server. Revert undoes a history entry the same way.
package engine
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"
"sort"
"strings"
"sync"
"time"
"steward/internal/auth"
"steward/internal/dhcp4"
"steward/internal/model"
"steward/internal/store"
"steward/internal/validate"
)
// Kinds of errors, for the API's status codes.
const (
KindBadRequest = "bad_request"
KindNotFound = "not_found"
KindConflict = "conflict"
KindValidation = "validation"
)
// Error is a refused change.
type Error struct {
Kind string `json:"code"`
Message string `json:"message"`
Problems []validate.Problem `json:"problems,omitempty"`
}
func (e *Error) Error() string { return e.Message }
// Errorf makes an Error.
func Errorf(kind, format string, args ...any) *Error {
return &Error{Kind: kind, Message: fmt.Sprintf(format, args...)}
}
// AsError unwraps an Error.
func AsError(err error) (*Error, bool) {
var e *Error
ok := errors.As(err, &e)
return e, ok
}
// Result describes an applied (or previewed) change.
type Result struct {
Applied bool `json:"applied"`
DryRun bool `json:"dry_run,omitempty"`
HistoryID int64 `json:"history_id,omitempty"`
Changes []model.Change `json:"changes"`
Warnings []validate.Problem `json:"warnings,omitempty"`
}
// Engine holds the current configuration.
type Engine struct {
store *store.Store
log *slog.Logger
// Env describes the host's interfaces for validation (nil: skip).
Env func() *validate.Env
// OnChange is called with each new state (the DHCP server's config).
OnChange func(*model.State)
mu sync.Mutex
state *model.State
}
// New loads the configuration.
func New(ctx context.Context, st *store.Store, log *slog.Logger) (*Engine, error) {
if log == nil {
log = slog.Default()
}
s, err := st.LoadState(ctx)
if err != nil {
return nil, err
}
Normalize(s)
return &Engine{store: st, log: log, state: s}, nil
}
// Store is the engine's database.
func (e *Engine) Store() *store.Store { return e.store }
// State returns a copy of the current configuration.
func (e *Engine) State() *model.State {
e.mu.Lock()
defer e.mu.Unlock()
return e.state.Clone()
}
func (e *Engine) env() *validate.Env {
if e.Env == nil {
return nil
}
return e.Env()
}
// Problems validates the current configuration (for the status page).
func (e *Engine) Problems() []validate.Problem {
return validate.Check(e.State(), e.env())
}
// Edit changes a configuration in place. NewID gives IDs for new objects.
type Edit func(st *model.State, newID func() int64) error
// Options say how to apply a change.
type Options struct {
DryRun bool
Via string // web, cli, api, import
}
// Apply makes a change: edit runs on a copy of the configuration, the
// result is validated, and the changed objects are saved with a history
// entry. A change that touches nothing is not recorded.
func (e *Engine) Apply(ctx context.Context, p *auth.Principal, description string, opts Options, edit Edit) (*Result, error) {
e.mu.Lock()
defer e.mu.Unlock()
next := e.state.Clone()
var idErr error
newID := func() int64 {
ids, err := e.store.AllocateIDs(ctx, 1)
if err != nil {
idErr = err
return 0
}
return ids[0]
}
if err := edit(next, newID); err != nil {
return nil, err
}
if idErr != nil {
return nil, idErr
}
return e.commit(ctx, p, description, opts, next, 0)
}
// commit validates next and saves it as the new state.
func (e *Engine) commit(ctx context.Context, p *auth.Principal, description string, opts Options, next *model.State, revertOf int64) (*Result, error) {
Normalize(next)
next.Sort()
changes := Diff(e.state, next)
res := &Result{DryRun: opts.DryRun, Changes: changes}
if len(changes) == 0 {
return res, nil
}
problems := validate.Check(next, e.env())
if validate.HasErrors(problems) {
errs := validate.Errors(problems)
msg := errs[0].Message
if errs[0].Object != "" {
msg = errs[0].Object + ": " + msg
}
if len(errs) > 1 {
msg += fmt.Sprintf(" (and %d more problems)", len(errs)-1)
}
return res, &Error{Kind: KindValidation, Message: msg, Problems: errs}
}
// Only report warnings the change brings; old ones are on the status
// page.
res.Warnings = newProblems(validate.Check(e.state, e.env()), problems)
if opts.DryRun {
return res, nil
}
entry := &model.HistoryEntry{Time: time.Now(), Username: p.Name(), Via: opts.Via, Description: description, Changes: changes, RevertOf: revertOf}
if p != nil && p.User != nil {
entry.UserID = p.User.ID
}
if entry.Description == "" {
entry.Description = Describe(changes)
}
if err := e.store.SaveChanges(ctx, changes, entry); err != nil {
return res, err
}
e.state = next
res.Applied, res.HistoryID = true, entry.ID
e.Audit(ctx, p, "config.change", fmt.Sprintf("history %d", entry.ID), "success", entry.Description)
e.log.Info("configuration changed", "history", entry.ID, "user", p.Name(), "via", opts.Via, "description", entry.Description)
if e.OnChange != nil {
e.OnChange(next.Clone())
}
return res, nil
}
func newProblems(before, after []validate.Problem) []validate.Problem {
seen := map[string]bool{}
for _, p := range before {
seen[p.Code+"\x00"+p.Object+"\x00"+p.Message] = true
}
var out []validate.Problem
for _, p := range after {
if !seen[p.Code+"\x00"+p.Object+"\x00"+p.Message] {
out = append(out, p)
}
}
return out
}
// Revert undoes a history entry, if the objects it changed are still as
// it left them.
func (e *Engine) Revert(ctx context.Context, p *auth.Principal, id int64, opts Options) (*Result, error) {
h, err := e.store.GetHistory(ctx, id)
if errors.Is(err, store.ErrNotFound) {
return nil, Errorf(KindNotFound, "history entry %d not found", id)
}
if err != nil {
return nil, err
}
if h.RevertedBy != 0 {
return nil, Errorf(KindConflict, "history entry %d was already undone by entry %d", id, h.RevertedBy)
}
e.mu.Lock()
defer e.mu.Unlock()
cur := objects(e.state)
next := e.state.Clone()
for i := len(h.Changes) - 1; i >= 0; i-- {
c := h.Changes[i]
now := cur[key{c.Kind, c.ID}]
if !sameJSON(now, c.After) {
what := c.Label
if what == "" {
what = fmt.Sprintf("%s %d", c.Kind, c.ID)
}
return nil, Errorf(KindConflict, "%s %s has changed since entry %d; undo the later changes first, or change it by hand", c.Kind, what, id)
}
if err := put(next, c.Kind, c.ID, c.Before); err != nil {
return nil, err
}
}
return e.commit(ctx, p, fmt.Sprintf("undo %d: %s", h.ID, h.Description), opts, next, h.ID)
}
// Replace makes the configuration next as a whole (imports).
func (e *Engine) Replace(ctx context.Context, p *auth.Principal, description string, opts Options, next *model.State) (*Result, error) {
e.mu.Lock()
defer e.mu.Unlock()
return e.commit(ctx, p, description, opts, next.Clone(), 0)
}
// Audit records an action in the audit log.
func (e *Engine) Audit(ctx context.Context, p *auth.Principal, action, object, result, detail string) {
ev := &model.AuditEvent{Action: action, Object: object, Result: result, Detail: detail}
if p != nil {
ev.Username, ev.Auth, ev.IP = p.Name(), p.Method, p.IP
if p.User != nil {
ev.UserID = p.User.ID
}
if p.Token != nil {
ev.TokenID = p.Token.ID
}
}
if err := e.store.AddAudit(ctx, ev); err != nil {
e.log.Warn("writing the audit log failed", "err", err)
}
}
// ---- diffs
type key struct {
kind string
id int64
}
func objects(st *model.State) map[key]json.RawMessage {
m := map[key]json.RawMessage{{model.KindSettings, 0}: mustJSON(st.Settings)}
for _, s := range st.Subnets {
m[key{model.KindSubnet, s.ID}] = mustJSON(s)
}
for _, r := range st.Reservations {
m[key{model.KindReservation, r.ID}] = mustJSON(r)
}
return m
}
func labels(st *model.State) map[key]string {
m := map[key]string{{model.KindSettings, 0}: "settings"}
for _, s := range st.Subnets {
m[key{model.KindSubnet, s.ID}] = s.Label()
}
for _, r := range st.Reservations {
m[key{model.KindReservation, r.ID}] = r.Label()
}
return m
}
// Diff lists what changes from a to b: settings, then subnets, then
// reservations, each by ID.
func Diff(a, b *model.State) []model.Change {
oa, ob := objects(a), objects(b)
la, lb := labels(a), labels(b)
var keys []key
for k := range oa {
keys = append(keys, k)
}
for k := range ob {
if _, ok := oa[k]; !ok {
keys = append(keys, k)
}
}
order := map[string]int{model.KindSettings: 0, model.KindSubnet: 1, model.KindReservation: 2}
sort.Slice(keys, func(i, j int) bool {
if keys[i].kind != keys[j].kind {
return order[keys[i].kind] < order[keys[j].kind]
}
return keys[i].id < keys[j].id
})
var out []model.Change
for _, k := range keys {
before, after := oa[k], ob[k]
if sameJSON(before, after) {
continue
}
label := lb[k]
if label == "" {
label = la[k]
}
out = append(out, model.Change{Kind: k.kind, ID: k.id, Label: label, Before: before, After: after})
}
return out
}
// Describe sums up changes: "create subnet office (10.0.10.0/24)", or
// "3 changes" for many.
func Describe(cs []model.Change) string {
if len(cs) == 0 {
return "no changes"
}
parts := []string{}
for _, c := range cs {
if c.Kind == model.KindSettings {
parts = append(parts, "update settings")
continue
}
parts = append(parts, c.Action()+" "+c.Kind+" "+c.Label)
}
if len(parts) > 3 {
return fmt.Sprintf("%s and %d more changes", strings.Join(parts[:2], ", "), len(parts)-2)
}
return strings.Join(parts, ", ")
}
func mustJSON(v any) json.RawMessage {
b, err := json.Marshal(v)
if err != nil {
panic(err)
}
return b
}
func sameJSON(a, b json.RawMessage) bool {
if a == nil || b == nil {
return a == nil && b == nil
}
return bytes.Equal(a, b)
}
// put sets (or with nil data deletes) one object in st.
func put(st *model.State, kind string, id int64, data json.RawMessage) error {
switch kind {
case model.KindSettings:
if data == nil {
return fmt.Errorf("settings can't be deleted")
}
var s model.Settings
if err := json.Unmarshal(data, &s); err != nil {
return err
}
st.Settings = s
case model.KindSubnet:
var keep []*model.Subnet
for _, s := range st.Subnets {
if s.ID != id {
keep = append(keep, s)
}
}
if data != nil {
var s model.Subnet
if err := json.Unmarshal(data, &s); err != nil {
return err
}
keep = append(keep, &s)
}
st.Subnets = keep
case model.KindReservation:
var keep []*model.Reservation
for _, r := range st.Reservations {
if r.ID != id {
keep = append(keep, r)
}
}
if data != nil {
var r model.Reservation
if err := json.Unmarshal(data, &r); err != nil {
return err
}
keep = append(keep, &r)
}
st.Reservations = keep
default:
return fmt.Errorf("unknown kind %q", kind)
}
return nil
}
// Normalize fills in option names and writes option values, pools and
// lists in their canonical form, so equal configurations compare equal.
func Normalize(st *model.State) {
st.Settings.Options = normOptions(st.Settings.Options)
if st.Subnets == nil {
st.Subnets = []*model.Subnet{}
}
if st.Reservations == nil {
st.Reservations = []*model.Reservation{}
}
for _, s := range st.Subnets {
s.Options = normOptions(s.Options)
if s.Pools == nil {
s.Pools = []model.Pool{}
}
if s.Prefix.IsValid() && s.Prefix.Addr().Is4() {
s.Prefix = s.Prefix.Masked()
}
sort.SliceStable(s.Pools, func(i, j int) bool { return s.Pools[i].Start.Less(s.Pools[j].Start) })
}
for _, r := range st.Reservations {
r.Options = normOptions(r.Options)
}
}
func normOptions(opts []model.OptionValue) []model.OptionValue {
out := []model.OptionValue{}
for _, o := range opts {
if o.Code > 0 && o.Code < 255 {
def := dhcp4.ByCode(byte(o.Code))
o.Name = def.Name
typ := def.Type
if dhcp4.Known(byte(o.Code)) {
o.Type = ""
} else if o.Type != "" {
typ = o.Type
}
if v, err := dhcp4.Normalize(typ, o.Value); err == nil {
o.Value = v
}
}
out = append(out, o)
}
sort.SliceStable(out, func(i, j int) bool { return out[i].Code < out[j].Code })
return out
}
+140
View File
@@ -0,0 +1,140 @@
package engine
import (
"context"
"net/netip"
"strings"
"testing"
"steward/internal/auth"
"steward/internal/model"
"steward/internal/store"
)
func newEngine(t *testing.T) *Engine {
t.Helper()
st, err := store.Open(":memory:")
if err != nil {
t.Fatal(err)
}
e, err := New(context.Background(), st, nil)
if err != nil {
t.Fatal(err)
}
return e
}
var admin = &auth.Principal{User: &model.User{ID: 1, Username: "admin", Admin: true}, Method: "session"}
func addOffice(t *testing.T, e *Engine) int64 {
t.Helper()
var id int64
res, err := e.Apply(context.Background(), admin, "", Options{Via: "cli"}, func(st *model.State, newID func() int64) error {
p, _ := model.ParsePool("10.0.10.100-10.0.10.199")
id = newID()
st.Subnets = append(st.Subnets, &model.Subnet{ID: id, Name: "office", Interface: "eth0", Prefix: netip.MustParsePrefix("10.0.10.0/24"),
Pools: []model.Pool{p}, Options: []model.OptionValue{{Code: 6, Value: "10.0.10.2,10.0.10.3"}}})
return nil
})
if err != nil || !res.Applied || res.HistoryID == 0 {
t.Fatalf("apply: %+v %v", res, err)
}
return id
}
func TestApplyNormalizesAndRecords(t *testing.T) {
e := newEngine(t)
id := addOffice(t, e)
s := e.State().SubnetByID(id)
if s.Options[0].Value != "10.0.10.2, 10.0.10.3" || s.Options[0].Name != "domain-name-servers" {
t.Errorf("options not normalized: %+v", s.Options)
}
h, _ := e.Store().ListHistory(context.Background(), 10, 0)
if len(h) != 1 || h[0].Description != "create subnet office (10.0.10.0/24)" || h[0].Via != "cli" || h[0].Username != "admin" {
t.Errorf("history %+v", h[0])
}
// Nothing changed: nothing recorded.
res, err := e.Apply(context.Background(), admin, "", Options{}, func(st *model.State, _ func() int64) error { return nil })
if err != nil || res.Applied || len(res.Changes) != 0 {
t.Errorf("empty change: %+v %v", res, err)
}
// Reloading from the database gives the same state.
e2, err := New(context.Background(), e.Store(), nil)
if err != nil {
t.Fatal(err)
}
if d := Diff(e.State(), e2.State()); len(d) != 0 {
t.Errorf("reloaded state differs: %+v", d)
}
}
func TestValidationRefuses(t *testing.T) {
e := newEngine(t)
addOffice(t, e)
_, err := e.Apply(context.Background(), admin, "", Options{}, func(st *model.State, newID func() int64) error {
st.Subnets = append(st.Subnets, &model.Subnet{ID: newID(), Interface: "eth1", Prefix: netip.MustParsePrefix("10.0.0.0/16")})
return nil
})
ee, ok := AsError(err)
if !ok || ee.Kind != KindValidation || !strings.Contains(ee.Message, "overlaps subnet office") {
t.Fatalf("got %v", err)
}
if len(e.State().Subnets) != 1 {
t.Error("refused change was kept")
}
}
func TestDryRun(t *testing.T) {
e := newEngine(t)
res, err := e.Apply(context.Background(), admin, "", Options{DryRun: true}, func(st *model.State, newID func() int64) error {
st.Subnets = append(st.Subnets, &model.Subnet{ID: newID(), Interface: "eth0", Prefix: netip.MustParsePrefix("10.0.10.0/24")})
return nil
})
if err != nil || res.Applied || len(res.Changes) != 1 || len(res.Warnings) != 1 || res.Warnings[0].Code != "subnet_no_pools" {
t.Fatalf("dry run: %+v %v", res, err)
}
if len(e.State().Subnets) != 0 {
t.Error("dry run changed the state")
}
}
func TestRevert(t *testing.T) {
ctx := context.Background()
e := newEngine(t)
var got *model.State
e.OnChange = func(st *model.State) { got = st }
id := addOffice(t, e)
res, err := e.Apply(ctx, admin, "longer leases", Options{}, func(st *model.State, _ func() int64) error {
st.SubnetByID(id).LeaseTime = 86400
return nil
})
if err != nil {
t.Fatal(err)
}
if got == nil || got.SubnetByID(id).LeaseTime != 86400 {
t.Error("OnChange didn't get the new state")
}
// Undoing the creation conflicts: the subnet changed since.
if _, err := e.Revert(ctx, admin, 1, Options{}); err == nil || !strings.Contains(err.Error(), "has changed since entry 1") {
t.Fatalf("revert of an older entry: %v", err)
}
if _, err := e.Revert(ctx, admin, res.HistoryID, Options{}); err != nil {
t.Fatal(err)
}
if e.State().SubnetByID(id).LeaseTime != 0 {
t.Error("revert didn't restore the lease time")
}
if _, err := e.Revert(ctx, admin, res.HistoryID, Options{}); err == nil || !strings.Contains(err.Error(), "already undone") {
t.Errorf("second revert: %v", err)
}
if _, err := e.Revert(ctx, admin, 1, Options{}); err != nil {
t.Fatalf("revert of the creation: %v", err)
}
if len(e.State().Subnets) != 0 {
t.Error("subnet still there")
}
h, _ := e.Store().GetHistory(ctx, 1)
if h.RevertedBy == 0 {
t.Error("entry 1 not marked undone")
}
}
+580
View File
@@ -0,0 +1,580 @@
// Package kea reads a Kea DHCPv4 configuration (kea-dhcp4.conf) and its
// lease file (the memfile CSV), so a Kea setup can move to Steward without
// renumbering anything. What Steward doesn't do is listed, not dropped
// silently.
package kea
import (
"bytes"
"encoding/csv"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/netip"
"sort"
"strconv"
"strings"
"time"
"steward/internal/dhcp4"
"steward/internal/model"
)
// Result is what an import makes of a Kea configuration.
type Result struct {
// State has the settings, subnets and reservations (IDs not set).
State *model.State
// Notes say how things were mapped; Skipped lists what Steward
// doesn't support, so it isn't lost silently.
Notes []string
Skipped []string
}
// Options steer an import.
type Options struct {
// HostInterfaces are this machine's interfaces with their prefixes,
// to find a subnet's interface when Kea doesn't name it.
HostInterfaces map[string][]netip.Prefix
// Interfaces gives the interface of subnets by prefix
// ("10.0.20.0/24" -> "eth0.20"); it wins over the configuration.
Interfaces map[string]string
}
// StripComments removes Kea's comments (#, // and /* */) outside strings.
func StripComments(b []byte) []byte {
var out bytes.Buffer
inStr, esc := false, false
for i := 0; i < len(b); i++ {
c := b[i]
if inStr {
out.WriteByte(c)
switch {
case esc:
esc = false
case c == '\\':
esc = true
case c == '"':
inStr = false
}
continue
}
switch {
case c == '"':
inStr = true
out.WriteByte(c)
case c == '#' || c == '/' && i+1 < len(b) && b[i+1] == '/':
for i < len(b) && b[i] != '\n' {
i++
}
out.WriteByte('\n')
case c == '/' && i+1 < len(b) && b[i+1] == '*':
i += 2
for i+1 < len(b) && !(b[i] == '*' && b[i+1] == '/') {
if b[i] == '\n' {
out.WriteByte('\n')
}
i++
}
i++
default:
out.WriteByte(c)
}
}
return out.Bytes()
}
type obj = map[string]json.RawMessage
type importer struct {
opts Options
res *Result
globals []string // interfaces-config interfaces
}
// ParseConfig reads a kea-dhcp4.conf.
func ParseConfig(data []byte, opts Options) (*Result, error) {
clean := StripComments(data)
if bytes.Contains(clean, []byte("<?include")) {
return nil, fmt.Errorf("the configuration uses <?include ...?>; put the included files' contents in place first")
}
var top obj
if err := json.Unmarshal(clean, &top); err != nil {
return nil, fmt.Errorf("reading the Kea configuration: %w", jsonError(clean, err))
}
raw, ok := top["Dhcp4"]
if !ok {
return nil, fmt.Errorf("no Dhcp4 section: is this a kea-dhcp4.conf?")
}
var d4 obj
if err := json.Unmarshal(raw, &d4); err != nil {
return nil, fmt.Errorf("Dhcp4: %w", err)
}
im := &importer{opts: opts, res: &Result{State: &model.State{Settings: model.DefaultSettings(), Subnets: []*model.Subnet{}, Reservations: []*model.Reservation{}}}}
for k := range top {
if k != "Dhcp4" {
im.skip("the %s section (Steward imports Dhcp4 only)", k)
}
}
if err := im.dhcp4(d4); err != nil {
return nil, err
}
return im.res, nil
}
// jsonError adds the line number to a JSON syntax error.
func jsonError(b []byte, err error) error {
if se, ok := err.(*json.SyntaxError); ok {
line := 1 + bytes.Count(b[:min(int(se.Offset), len(b))], []byte("\n"))
return fmt.Errorf("line %d: %v", line, err)
}
return err
}
func (im *importer) note(format string, args ...any) {
im.res.Notes = append(im.res.Notes, fmt.Sprintf(format, args...))
}
func (im *importer) skip(format string, args ...any) {
im.res.Skipped = append(im.res.Skipped, fmt.Sprintf(format, args...))
}
func get[T any](o obj, key string, into *T) (bool, error) {
raw, ok := o[key]
if !ok {
return false, nil
}
if err := json.Unmarshal(raw, into); err != nil {
return true, fmt.Errorf("%s: %v", key, err)
}
return true, nil
}
// Keys of Dhcp4 that are handled, ignored on purpose, or skipped with a
// note.
var (
handledKeys = map[string]bool{"interfaces-config": true, "valid-lifetime": true, "option-data": true, "subnet4": true, "shared-networks": true,
"reservations": true, "authoritative": true, "decline-probation-period": true}
ignoredKeys = map[string]string{
"lease-database": "Steward keeps leases in its own database", "control-socket": "Steward has its own API",
"loggers": "Steward logs to the journal", "renew-timer": "Steward sends half the lease time",
"rebind-timer": "Steward sends 7/8 of the lease time", "expired-leases-processing": "Steward expires leases itself",
"sanity-checks": "", "multi-threading": "", "server-tag": "", "option-def": "", "calculate-tee-times": "", "t1-percent": "", "t2-percent": "",
"min-valid-lifetime": "", "max-valid-lifetime": "", "host-reservation-identifiers": "", "echo-client-id": "", "match-client-id": "",
"user-context": "", "comment": "", "cache-threshold": "", "dhcp4o6-port": "", "hostname-char-set": "", "hostname-char-replacement": "",
}
)
func (im *importer) dhcp4(d obj) error {
var keys []string
for k := range d {
keys = append(keys, k)
}
sort.Strings(keys)
for _, k := range keys {
if handledKeys[k] {
continue
}
if why, ok := ignoredKeys[k]; ok {
if why != "" {
im.note("%s isn't needed: %s", k, why)
}
continue
}
im.skip("%s (not supported by Steward 0.1)", k)
}
var ic struct {
Interfaces []string `json:"interfaces"`
}
if _, err := get(d, "interfaces-config", &ic); err != nil {
return err
}
for _, i := range ic.Interfaces {
name, _, _ := strings.Cut(i, "/")
if name != "*" && name != "" {
im.globals = append(im.globals, name)
}
}
set := &im.res.State.Settings
var vl int
if ok, err := get(d, "valid-lifetime", &vl); err != nil {
return err
} else if ok {
set.LeaseTime = vl
}
var dpp int
if ok, err := get(d, "decline-probation-period", &dpp); err != nil {
return err
} else if ok {
set.DeclineHold = dpp
}
var authoritative bool
if ok, err := get(d, "authoritative", &authoritative); err != nil {
return err
} else if !ok || !authoritative {
im.note("Kea wasn't authoritative; Steward is by default (it answers requests for addresses that don't belong on an interface with a NAK, so clients start over at once); turn it off in the settings if another DHCP server shares a network")
}
var err error
if set.Options, err = im.options(d, "global"); err != nil {
return err
}
var subnets []obj
if _, err := get(d, "subnet4", &subnets); err != nil {
return err
}
for _, s := range subnets {
if err := im.subnet(s, nil); err != nil {
return err
}
}
var shared []obj
if _, err := get(d, "shared-networks", &shared); err != nil {
return err
}
for _, sh := range shared {
var name string
get(sh, "name", &name)
var subs []obj
if _, err := get(sh, "subnet4", &subs); err != nil {
return fmt.Errorf("shared network %s: %w", name, err)
}
if len(subs) > 1 {
im.note("shared network %s: its %d subnets are served together on one interface, as in Kea", name, len(subs))
}
for _, s := range subs {
if err := im.subnet(s, sh); err != nil {
return err
}
}
}
var global []obj
if _, err := get(d, "reservations", &global); err != nil {
return err
}
for _, r := range global {
if err := im.reservation(r, nil); err != nil {
return err
}
}
return nil
}
// options reads option-data.
func (im *importer) options(o obj, where string) ([]model.OptionValue, error) {
var list []obj
if _, err := get(o, "option-data", &list); err != nil {
return nil, fmt.Errorf("%s: %w", where, err)
}
out := []model.OptionValue{}
for _, od := range list {
var name, data, space string
var code int
csvFormat := true
get(od, "name", &name)
get(od, "code", &code)
get(od, "data", &data)
get(od, "space", &space)
get(od, "csv-format", &csvFormat)
if space != "" && space != "dhcp4" {
im.skip("%s: option %s%d in space %s", where, name, code, space)
continue
}
var def dhcp4.Def
ok := false
if name != "" {
def, ok = dhcp4.Lookup(name)
}
if !ok && code > 0 && code < 255 {
def, ok = dhcp4.ByCode(byte(code)), true
}
if !ok {
im.skip("%s: option %s (Steward doesn't know it by name; set it by number)", where, name)
continue
}
if why, reserved := dhcp4.Reserved[def.Code]; reserved {
im.note("%s: option %s left out: %s", where, def.Name, why)
continue
}
v := model.OptionValue{Code: int(def.Code), Value: data}
if !dhcp4.Known(def.Code) {
v.Type = dhcp4.TypeHex
}
if !csvFormat {
b, err := hex.DecodeString(strings.NewReplacer(":", "", " ", "").Replace(strings.TrimPrefix(strings.ToLower(data), "0x")))
if err != nil {
return nil, fmt.Errorf("%s: option %s: data %q isn't hex (csv-format is false)", where, def.Name, data)
}
typ := def.Type
if v.Type != "" {
typ = v.Type
}
v.Value = dhcp4.Decode(typ, b)
}
typ := def.Type
if v.Type != "" {
typ = v.Type
}
if _, err := dhcp4.Encode(typ, v.Value); err != nil {
return nil, fmt.Errorf("%s: option %s: %v", where, def.Name, err)
}
out = append(out, v)
}
return out, nil
}
func (im *importer) subnet(s obj, shared obj) error {
var prefix, iface, comment string
var lifetime int
get(s, "subnet", &prefix)
pfx, err := netip.ParsePrefix(prefix)
if err != nil || !pfx.Addr().Is4() {
return fmt.Errorf("subnet %q isn't an IPv4 prefix", prefix)
}
where := "subnet " + pfx.String()
sn := &model.Subnet{Prefix: pfx.Masked(), Pools: []model.Pool{}}
if shared != nil {
get(shared, "interface", &iface)
var name string
get(shared, "name", &name)
sn.Comment = "shared network " + name
if ok, _ := get(shared, "valid-lifetime", &lifetime); ok {
sn.LeaseTime = lifetime
}
so, err := im.options(shared, "shared network "+name)
if err != nil {
return err
}
sn.Options = so
}
if ok, _ := get(s, "interface", &iface); ok && iface == "" {
iface = ""
}
if ok, _ := get(s, "valid-lifetime", &lifetime); ok {
sn.LeaseTime = lifetime
}
if lt := im.res.State.Settings.LeaseTime; sn.LeaseTime == lt {
sn.LeaseTime = 0
}
var uc struct {
Comment string `json:"comment"`
}
if get(s, "user-context", &uc); uc.Comment != "" {
comment = uc.Comment
}
get(s, "comment", &comment)
if comment != "" {
sn.Comment = strings.TrimSpace(sn.Comment + " " + comment)
}
opts, err := im.options(s, where)
if err != nil {
return err
}
// The subnet's options over the shared network's.
sn.Options = mergeOptions(sn.Options, opts)
var pools []obj
if _, err := get(s, "pools", &pools); err != nil {
return fmt.Errorf("%s: %w", where, err)
}
for _, p := range pools {
var ps string
get(p, "pool", &ps)
pool, err := model.ParsePool(strings.ReplaceAll(ps, " ", ""))
if err != nil {
return fmt.Errorf("%s: %v", where, err)
}
sn.Pools = append(sn.Pools, pool)
if _, ok := p["option-data"]; ok {
im.skip("%s: options of pool %s (Steward sets options per subnet)", where, pool)
}
if _, ok := p["client-class"]; ok {
im.skip("%s: the client class of pool %s", where, pool)
}
}
for _, k := range []string{"relay", "client-class", "require-client-classes", "next-server", "boot-file-name", "4o6-interface", "4o6-subnet"} {
if _, ok := s[k]; ok {
im.skip("%s: %s", where, k)
}
}
if forced, ok := im.opts.Interfaces[sn.Prefix.String()]; ok {
iface = forced
}
if iface == "" {
iface = im.findInterface(sn.Prefix)
if iface != "" {
im.note("%s: served on %s (Kea didn't name its interface)", where, iface)
}
}
sn.Interface = iface
im.res.State.Subnets = append(im.res.State.Subnets, sn)
var res []obj
if _, err := get(s, "reservations", &res); err != nil {
return fmt.Errorf("%s: %w", where, err)
}
for _, r := range res {
if err := im.reservation(r, sn); err != nil {
return err
}
}
return nil
}
// findInterface guesses a subnet's interface: the host's interface with an
// address in it, else the only interface Kea listened on.
func (im *importer) findInterface(p netip.Prefix) string {
for name, addrs := range im.opts.HostInterfaces {
for _, a := range addrs {
if p.Contains(a.Addr()) {
return name
}
}
}
if len(im.globals) == 1 {
return im.globals[0]
}
return ""
}
func mergeOptions(base, over []model.OptionValue) []model.OptionValue {
out := []model.OptionValue{}
seen := map[int]bool{}
for _, o := range over {
seen[o.Code] = true
}
for _, o := range base {
if !seen[o.Code] {
out = append(out, o)
}
}
return append(out, over...)
}
func (im *importer) reservation(r obj, sn *model.Subnet) error {
var hw, cid, ip, host string
get(r, "hw-address", &hw)
get(r, "client-id", &cid)
get(r, "ip-address", &ip)
get(r, "hostname", &host)
where := "reservation " + ip
if host != "" {
where = "reservation " + host
}
if ip == "" {
im.skip("%s: no ip-address (Steward reservations always fix the address)", where)
return nil
}
a, err := netip.ParseAddr(ip)
if err != nil {
return fmt.Errorf("%s: %q isn't an address", where, ip)
}
res := &model.Reservation{IP: a, Hostname: host, Options: []model.OptionValue{}}
if hw != "" {
if res.MAC, err = model.NormalizeMAC(hw); err != nil {
return fmt.Errorf("%s: %v", where, err)
}
}
if cid != "" {
if res.ClientID, err = model.NormalizeClientID(cid); err != nil {
return fmt.Errorf("%s: %v", where, err)
}
}
if res.MAC == "" && res.ClientID == "" {
im.skip("%s: identified by %s (Steward knows clients by MAC address or client id)", where, firstKey(r, "duid", "circuit-id", "flex-id"))
return nil
}
if res.Options, err = im.options(r, where); err != nil {
return err
}
for _, k := range []string{"client-classes", "next-server", "boot-file-name", "server-hostname"} {
if _, ok := r[k]; ok {
im.skip("%s: %s", where, k)
}
}
im.res.State.Reservations = append(im.res.State.Reservations, res)
return nil
}
func firstKey(o obj, keys ...string) string {
for _, k := range keys {
if _, ok := o[k]; ok {
return k
}
}
return "something else"
}
// ParseLeases reads a Kea memfile lease file (kea-leases4.csv). The file
// is a log: later lines for an address replace earlier ones. Only leases
// that haven't ended are returned (declined ones too).
func ParseLeases(r io.Reader, now time.Time) ([]*model.Lease, error) {
cr := csv.NewReader(r)
cr.FieldsPerRecord = -1
header, err := cr.Read()
if err != nil {
return nil, fmt.Errorf("reading the lease file: %w", err)
}
col := map[string]int{}
for i, h := range header {
col[strings.TrimSpace(h)] = i
}
for _, need := range []string{"address", "hwaddr", "expire", "valid_lifetime"} {
if _, ok := col[need]; !ok {
return nil, fmt.Errorf("the lease file has no %s column: is it a Kea DHCPv4 memfile (kea-leases4.csv)?", need)
}
}
field := func(rec []string, name string) string {
if i, ok := col[name]; ok && i < len(rec) {
return strings.TrimSpace(rec[i])
}
return ""
}
byIP := map[netip.Addr]*model.Lease{}
line := 1
for {
rec, err := cr.Read()
line++
if err == io.EOF {
break
}
if err != nil {
return nil, fmt.Errorf("lease file line %d: %w", line, err)
}
a, err := netip.ParseAddr(field(rec, "address"))
if err != nil || !a.Is4() {
continue
}
exp, _ := strconv.ParseInt(field(rec, "expire"), 10, 64)
life, _ := strconv.ParseInt(field(rec, "valid_lifetime"), 10, 64)
end := time.Unix(exp, 0).UTC()
l := &model.Lease{IP: a, State: model.LeaseActive, Start: end.Add(-time.Duration(life) * time.Second), End: end, Updated: now,
Hostname: unescape(field(rec, "hostname"))}
if m, err := model.NormalizeMAC(field(rec, "hwaddr")); err == nil {
l.MAC = m
}
if c := field(rec, "client_id"); c != "" {
if id, err := model.NormalizeClientID(c); err == nil {
l.ClientID = id
}
}
switch field(rec, "state") {
case "1":
l.State = model.LeaseDeclined
case "2", "3":
l.State = model.LeaseReleased
}
byIP[a] = l
}
var out []*model.Lease
for _, l := range byIP {
if l.MAC == "" && l.State != model.LeaseDeclined {
continue
}
if l.End.After(now) || l.State == model.LeaseReleased {
out = append(out, l)
}
}
sort.Slice(out, func(i, j int) bool { return out[i].IP.Less(out[j].IP) })
return out, nil
}
// unescape undoes Kea's escaping of commas in the lease file.
func unescape(s string) string { return strings.ReplaceAll(s, "&#x2c", ",") }
+170
View File
@@ -0,0 +1,170 @@
package kea
import (
"net/netip"
"strings"
"testing"
"time"
"steward/internal/model"
)
const sample = `
# Kea for the house
{
"Dhcp4": {
"interfaces-config": { "interfaces": [ "eth0", "eth0.20/10.0.20.1" ] },
"lease-database": { "type": "memfile", "persist": true, "name": "/var/lib/kea/kea-leases4.csv" },
"valid-lifetime": 43200,
"renew-timer": 21600,
"authoritative": true,
"option-data": [
{ "name": "domain-name-servers", "data": "10.0.10.2, 10.0.10.3" },
{ "name": "domain-name", "data": "lan" }, // our domain
{ "name": "domain-search", "data": "lan, home.arpa" }
],
"hooks-libraries": [ { "library": "/usr/lib/kea/hooks/libdhcp_lease_cmds.so" } ],
/* the office */
"subnet4": [
{
"id": 1,
"subnet": "10.0.10.0/24",
"pools": [ { "pool": "10.0.10.100 - 10.0.10.199" } ],
"option-data": [
{ "name": "routers", "data": "10.0.10.1" },
{ "code": 42, "data": "0a000a02", "csv-format": false }
],
"reservations": [
{ "hw-address": "AA-BB-CC-00-00-01", "ip-address": "10.0.10.20", "hostname": "nas" },
{ "client-id": "01:aa:bb:cc:00:00:02", "ip-address": "10.0.10.21", "hostname": "printer",
"option-data": [ { "name": "domain-name-servers", "data": "1.1.1.1" } ] },
{ "duid": "00:01:02", "ip-address": "10.0.10.22" }
]
}
],
"shared-networks": [
{
"name": "iot",
"interface": "eth0.20",
"valid-lifetime": 3600,
"option-data": [ { "name": "routers", "data": "10.0.20.1" } ],
"subnet4": [
{ "id": 2, "subnet": "10.0.20.0/24", "pools": [ { "pool": "10.0.20.0/25" } ], "relay": { "ip-addresses": [ "10.0.20.254" ] } },
{ "id": 3, "subnet": "10.0.21.0/24", "pools": [ { "pool": "10.0.21.10-10.0.21.20" } ],
"option-data": [ { "name": "routers", "data": "10.0.21.1" } ] }
]
}
]
},
"Logging": { "loggers": [] }
}
`
func TestParseConfig(t *testing.T) {
res, err := ParseConfig([]byte(sample), Options{HostInterfaces: map[string][]netip.Prefix{"eth0": {netip.MustParsePrefix("10.0.10.2/24")}}})
if err != nil {
t.Fatal(err)
}
st := res.State
if st.Settings.LeaseTime != 43200 || len(st.Settings.Options) != 3 {
t.Errorf("settings %+v", st.Settings)
}
if len(st.Subnets) != 3 {
t.Fatalf("subnets %+v", st.Subnets)
}
office := st.Subnets[0]
if office.Interface != "eth0" || office.Pools[0].String() != "10.0.10.100-10.0.10.199" || office.LeaseTime != 0 {
t.Errorf("office %+v", office)
}
if len(office.Options) != 2 || office.Options[1].Code != 42 || office.Options[1].Value != "10.0.10.2" {
t.Errorf("office options %+v", office.Options)
}
iot, iot2 := st.Subnets[1], st.Subnets[2]
if iot.Interface != "eth0.20" || iot.LeaseTime != 3600 || iot.Pools[0].String() != "10.0.20.0-10.0.20.127" || iot.Options[0].Value != "10.0.20.1" {
t.Errorf("iot %+v", iot)
}
if iot2.Interface != "eth0.20" || len(iot2.Options) != 1 || iot2.Options[0].Value != "10.0.21.1" {
t.Errorf("iot2 %+v %+v", iot2, iot2.Options)
}
if len(st.Reservations) != 2 || st.Reservations[0].MAC != "aa:bb:cc:00:00:01" || st.Reservations[1].ClientID != "01aabbcc000002" ||
st.Reservations[1].Options[0].Value != "1.1.1.1" {
t.Errorf("reservations %+v %+v", st.Reservations[0], st.Reservations[1])
}
skipped := strings.Join(res.Skipped, "\n")
for _, want := range []string{"hooks-libraries", "Logging", "duid", "relay"} {
if !strings.Contains(skipped, want) {
t.Errorf("skipped list lacks %s:\n%s", want, skipped)
}
}
notes := strings.Join(res.Notes, "\n")
for _, want := range []string{"renew-timer", "shared network iot"} {
if !strings.Contains(notes, want) {
t.Errorf("notes lack %s:\n%s", want, notes)
}
}
}
func TestInterfaceOverride(t *testing.T) {
cfg := `{"Dhcp4": {"interfaces-config": {"interfaces": ["*"]}, "subnet4": [{"subnet": "192.168.5.0/24", "pools": [{"pool": "192.168.5.10-192.168.5.20"}]}]}}`
res, err := ParseConfig([]byte(cfg), Options{})
if err != nil {
t.Fatal(err)
}
if res.State.Subnets[0].Interface != "" {
t.Errorf("guessed %q", res.State.Subnets[0].Interface)
}
res, _ = ParseConfig([]byte(cfg), Options{Interfaces: map[string]string{"192.168.5.0/24": "br0"}})
if res.State.Subnets[0].Interface != "br0" {
t.Errorf("override: %q", res.State.Subnets[0].Interface)
}
}
func TestConfigErrors(t *testing.T) {
for in, want := range map[string]string{
`{"Dhcp6": {}}`: "no Dhcp4",
"{\n\"Dhcp4\": {\n\"subnet4\": [\n}}": "line 4",
`{"Dhcp4": {}} <?include "/etc/kea/more.json"?>`: "include",
`{"Dhcp4": {"subnet4": [{"subnet": "2001:db8::/64"}]}}`: "IPv4",
`{"Dhcp4": {"option-data": [{"name": "routers", "data": "x"}]}}`: "routers",
} {
if _, err := ParseConfig([]byte(in), Options{}); err == nil || !strings.Contains(err.Error(), want) {
t.Errorf("%q: %v", in, err)
}
}
}
func TestStripComments(t *testing.T) {
in := "{\"a\": \"x // not a comment # nor this\", # comment\n \"b\": /* gone */ 1 // tail\n}"
got := string(StripComments([]byte(in)))
if !strings.Contains(got, `"x // not a comment # nor this"`) || strings.Contains(got, "gone") || strings.Contains(got, "tail") {
t.Errorf("got %q", got)
}
}
func TestParseLeases(t *testing.T) {
now := time.Unix(1_800_000_000, 0)
csv := `address,hwaddr,client_id,valid_lifetime,expire,subnet_id,fqdn_fwd,fqdn_rev,hostname,state,user_context,pool_id
10.0.10.100,aa:bb:cc:00:00:05,01:aa:bb:cc:00:00:05,43200,1800010000,1,0,0,laptop,0,,0
10.0.10.101,aa:bb:cc:00:00:06,,43200,1799990000,1,0,0,old,0,,0
10.0.10.100,aa:bb:cc:00:00:05,01:aa:bb:cc:00:00:05,43200,1800040000,1,0,0,laptop&#x2cwork,0,,0
10.0.10.102,,,86400,1800050000,1,0,0,,1,,0
10.0.10.103,aa:bb:cc:00:00:07,,43200,1799000000,1,0,0,gone,3,,0
`
ls, err := ParseLeases(strings.NewReader(csv), now)
if err != nil {
t.Fatal(err)
}
if len(ls) != 3 {
t.Fatalf("leases %+v", ls)
}
l := ls[0]
if l.IP.String() != "10.0.10.100" || l.State != model.LeaseActive || l.End.Unix() != 1800040000 || l.Hostname != "laptop,work" || l.ClientID != "01aabbcc000005" {
t.Errorf("lease %+v", l)
}
if ls[1].State != model.LeaseDeclined || ls[2].State != model.LeaseReleased {
t.Errorf("states %s %s", ls[1].State, ls[2].State)
}
if _, err := ParseLeases(strings.NewReader("a,b\n1,2\n"), now); err == nil {
t.Error("a file without Kea's columns was accepted")
}
}
+123
View File
@@ -0,0 +1,123 @@
package model
import (
"encoding/json"
"time"
)
// Grant gives access to one resource (see internal/auth).
type Grant struct {
Resource string `json:"resource"` // config, leases, audit, *
Access string `json:"access"` // read or write (write implies read)
}
// User is a person or a service account.
type User struct {
ID int64 `json:"id"`
Username string `json:"username"`
PasswordHash string `json:"-"`
HasPassword bool `json:"has_password"`
Admin bool `json:"admin"`
Grants []Grant `json:"grants"`
Disabled bool `json:"disabled"`
Comment string `json:"comment"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
LastLoginAt *time.Time `json:"last_login_at,omitempty"`
}
// Token is an API token. The secret is shown once, when it is made; only
// its hash is stored.
type Token struct {
ID int64 `json:"id"`
UserID int64 `json:"user_id"`
Username string `json:"username,omitempty"`
Name string `json:"name"`
PublicID string `json:"public_id"`
SecretHash string `json:"-"`
// Grants restrict the token to part of its user's permissions; nil
// means all of them.
Grants []Grant `json:"grants"`
ExpiresAt *time.Time `json:"expires_at,omitempty"`
CreatedAt time.Time `json:"created_at"`
LastUsedAt *time.Time `json:"last_used_at,omitempty"`
LastUsedIP string `json:"last_used_ip,omitempty"`
RevokedAt *time.Time `json:"revoked_at,omitempty"`
}
// Session is a web UI login.
type Session struct {
IDHash string `json:"-"`
UserID int64 `json:"user_id"`
CSRFToken string `json:"-"`
CreatedAt time.Time `json:"created_at"`
ExpiresAt time.Time `json:"expires_at"`
LastSeenAt time.Time `json:"last_seen_at"`
IP string `json:"ip"`
UserAgent string `json:"user_agent"`
}
// AuditEvent records an action: logins, users and tokens, changes, lease
// deletions.
type AuditEvent struct {
ID int64 `json:"id"`
Time time.Time `json:"time"`
UserID int64 `json:"user_id,omitempty"`
Username string `json:"username,omitempty"`
Auth string `json:"auth,omitempty"` // session, token, socket
TokenID int64 `json:"token_id,omitempty"`
IP string `json:"ip,omitempty"`
Action string `json:"action"`
Object string `json:"object,omitempty"`
Result string `json:"result"` // success, failure, denied
Detail string `json:"detail,omitempty"`
}
// Object kinds of changes.
const (
KindSettings = "settings"
KindSubnet = "subnet"
KindReservation = "reservation"
)
// Change is one object's change: Before is nil for a create, After is nil
// for a delete. Settings have ID 0.
type Change struct {
Kind string `json:"kind"`
ID int64 `json:"id"`
Label string `json:"label"`
Before json.RawMessage `json:"before,omitempty"`
After json.RawMessage `json:"after,omitempty"`
}
// Action describes a change in a word.
func (c Change) Action() string {
switch {
case c.Before == nil:
return "create"
case c.After == nil:
return "delete"
}
return "update"
}
// Inverse is the change that undoes c.
func (c Change) Inverse() Change {
c.Before, c.After = c.After, c.Before
return c
}
// HistoryEntry is a saved configuration change.
type HistoryEntry struct {
ID int64 `json:"id"`
Time time.Time `json:"time"`
UserID int64 `json:"user_id,omitempty"`
Username string `json:"username"`
Via string `json:"via"` // web, cli, api
Description string `json:"description"`
Changes []Change `json:"changes"`
// RevertOf is the entry this one undid; RevertedBy the entry that
// undid this one.
RevertOf int64 `json:"revert_of,omitempty"`
RevertedBy int64 `json:"reverted_by,omitempty"`
}
+336
View File
@@ -0,0 +1,336 @@
// Package model holds Steward's data: the configuration (State), leases,
// users and tokens, the history of changes and the audit log.
package model
import (
"encoding/json"
"fmt"
"net"
"net/netip"
"sort"
"strings"
"time"
)
// Defaults for settings that are 0 or unset.
const (
DefaultLeaseTime = 12 * 60 * 60 // seconds
DefaultPingTimeout = 500 // milliseconds
DefaultDeclineHold = 24 * 60 * 60 // seconds
MinLeaseTime = 60
MaxLeaseTime = 365 * 24 * 60 * 60
)
// State is the whole configuration. It is small and changed as a whole
// (internal/engine).
type State struct {
Settings Settings `json:"settings"`
Subnets []*Subnet `json:"subnets"`
Reservations []*Reservation `json:"reservations"`
}
// Settings are the server-wide settings and the options every subnet
// starts from.
type Settings struct {
// LeaseTime is the default lease time in seconds (0: DefaultLeaseTime).
LeaseTime int `json:"lease_time"`
// Options apply to every subnet; a subnet's or a reservation's own
// options of the same code win.
Options []OptionValue `json:"options"`
// PingCheck pings an address before offering it, and skips it if
// something answers. PingTimeout is how long to wait, in ms.
PingCheck bool `json:"ping_check"`
PingTimeout int `json:"ping_timeout_ms"`
// DeclineHold is how long an address a client declined (or that
// answered a ping) is held back, in seconds.
DeclineHold int `json:"decline_hold"`
// NotAuthoritative stops Steward from refusing (DHCPNAK) requests for
// addresses that don't belong on the interface, for networks where
// another DHCP server shares the segment. Off by default.
NotAuthoritative bool `json:"not_authoritative"`
}
// DefaultSettings are the settings of a new database.
func DefaultSettings() Settings {
return Settings{LeaseTime: DefaultLeaseTime, PingCheck: true, PingTimeout: DefaultPingTimeout, DeclineHold: DefaultDeclineHold, Options: []OptionValue{}}
}
// EffectiveLeaseTime is the lease time with its default.
func (s Settings) EffectiveLeaseTime() int {
if s.LeaseTime > 0 {
return s.LeaseTime
}
return DefaultLeaseTime
}
// EffectivePingTimeout is the ping timeout with its default.
func (s Settings) EffectivePingTimeout() time.Duration {
ms := s.PingTimeout
if ms <= 0 {
ms = DefaultPingTimeout
}
return time.Duration(ms) * time.Millisecond
}
// EffectiveDeclineHold is the decline hold with its default.
func (s Settings) EffectiveDeclineHold() time.Duration {
sec := s.DeclineHold
if sec <= 0 {
sec = DefaultDeclineHold
}
return time.Duration(sec) * time.Second
}
// OptionValue is an option set in the configuration: its code and its
// value as text ("10.0.10.1, 10.0.10.2"). Name is the catalog's name,
// filled in for display. Type is only stored for options the catalog
// doesn't know (default hex).
type OptionValue struct {
Code int `json:"code"`
Name string `json:"name,omitempty"`
Type string `json:"type,omitempty"`
Value string `json:"value"`
}
// Subnet is an IPv4 network served on one interface.
type Subnet struct {
ID int64 `json:"id"`
// Name is a label for people ("office", "iot"); optional, unique.
Name string `json:"name"`
// Interface is the network interface the subnet is served on.
Interface string `json:"interface"`
Prefix netip.Prefix `json:"prefix"`
Pools []Pool `json:"pools"`
// LeaseTime overrides the settings' lease time (0: inherit).
LeaseTime int `json:"lease_time"`
Options []OptionValue `json:"options"`
// Disabled subnets keep their configuration and leases but get no
// answers.
Disabled bool `json:"disabled"`
Comment string `json:"comment"`
}
// Label names the subnet in messages: "office (10.0.10.0/24)".
func (s *Subnet) Label() string {
if s.Name != "" {
return s.Name + " (" + s.Prefix.String() + ")"
}
return s.Prefix.String()
}
// Pool is a range of addresses handed out to any client.
type Pool struct {
Start netip.Addr `json:"start"`
End netip.Addr `json:"end"`
}
func (p Pool) String() string { return p.Start.String() + "-" + p.End.String() }
// Contains reports whether a is in the pool.
func (p Pool) Contains(a netip.Addr) bool {
return a.Is4() && p.Start.Compare(a) <= 0 && a.Compare(p.End) <= 0
}
// Size is the number of addresses in the pool.
func (p Pool) Size() int {
if !p.Start.Is4() || !p.End.Is4() || p.End.Less(p.Start) {
return 0
}
return int(U32(p.End)-U32(p.Start)) + 1
}
// ParsePool reads "10.0.10.100-10.0.10.199" (spaces around the dash are
// fine) or a prefix, "10.0.10.128/25".
func ParsePool(s string) (Pool, error) {
s = strings.TrimSpace(s)
if a, b, ok := strings.Cut(s, "-"); ok {
start, err1 := netip.ParseAddr(strings.TrimSpace(a))
end, err2 := netip.ParseAddr(strings.TrimSpace(b))
if err1 != nil || err2 != nil || !start.Is4() || !end.Is4() {
return Pool{}, fmt.Errorf("pool %q: write it as FIRST-LAST, with IPv4 addresses", s)
}
if end.Less(start) {
return Pool{}, fmt.Errorf("pool %q ends before it starts", s)
}
return Pool{Start: start, End: end}, nil
}
pfx, err := netip.ParsePrefix(s)
if err != nil || !pfx.Addr().Is4() {
return Pool{}, fmt.Errorf("pool %q: write it as FIRST-LAST (10.0.10.100-10.0.10.199) or as a prefix", s)
}
pfx = pfx.Masked()
return Pool{Start: pfx.Addr(), End: Last(pfx)}, nil
}
// U32 is an IPv4 address as a number.
func U32(a netip.Addr) uint32 {
b := a.As4()
return uint32(b[0])<<24 | uint32(b[1])<<16 | uint32(b[2])<<8 | uint32(b[3])
}
// FromU32 is the address of a number.
func FromU32(v uint32) netip.Addr {
return netip.AddrFrom4([4]byte{byte(v >> 24), byte(v >> 16), byte(v >> 8), byte(v)})
}
// Last is the last address of a prefix (its broadcast address).
func Last(p netip.Prefix) netip.Addr {
p = p.Masked()
return FromU32(U32(p.Addr()) | (1<<(32-p.Bits()) - 1))
}
// Mask is the prefix's netmask.
func Mask(p netip.Prefix) netip.Addr {
return FromU32(^uint32(0) << (32 - p.Bits()))
}
// Reservation gives a client a fixed address. The client is known by its
// MAC address, or by its client identifier (option 61) when ClientID is
// set. The subnet is the one whose prefix holds the address.
type Reservation struct {
ID int64 `json:"id"`
MAC string `json:"mac,omitempty"`
ClientID string `json:"client_id,omitempty"`
IP netip.Addr `json:"ip"`
// Hostname is sent to the client (option 12).
Hostname string `json:"hostname"`
Options []OptionValue `json:"options"`
Comment string `json:"comment"`
}
// Label names the reservation in messages.
func (r *Reservation) Label() string {
who := r.MAC
if r.ClientID != "" {
who = "client id " + r.ClientID
}
if r.Hostname != "" {
return r.Hostname + " (" + r.IP.String() + ", " + who + ")"
}
return r.IP.String() + " (" + who + ")"
}
// NormalizeMAC writes a MAC address as aa:bb:cc:dd:ee:ff. It accepts
// colons, dashes, dots (Cisco) or no separators.
func NormalizeMAC(s string) (string, error) {
s = strings.TrimSpace(s)
if len(s) == 12 && !strings.ContainsAny(s, ":-.") {
var parts []string
for i := 0; i < 12; i += 2 {
parts = append(parts, s[i:i+2])
}
s = strings.Join(parts, ":")
}
hw, err := net.ParseMAC(s)
if err != nil || len(hw) != 6 {
return "", fmt.Errorf("%q is not a MAC address (write it as aa:bb:cc:dd:ee:ff)", s)
}
return hw.String(), nil
}
// NormalizeClientID writes a client identifier as lower-case hex without
// separators. It accepts hex with colons or dashes.
func NormalizeClientID(s string) (string, error) {
h := strings.ToLower(strings.NewReplacer(":", "", "-", "", " ", "").Replace(strings.TrimSpace(s)))
if h == "" || len(h)%2 != 0 || strings.Trim(h, "0123456789abcdef") != "" {
return "", fmt.Errorf("%q is not a client identifier in hex (01:aa:bb:cc:dd:ee:ff)", s)
}
return h, nil
}
// SubnetByID finds a subnet.
func (st *State) SubnetByID(id int64) *Subnet {
for _, s := range st.Subnets {
if s.ID == id {
return s
}
}
return nil
}
// SubnetFor returns the subnet whose prefix holds a (the first, in
// order), or nil.
func (st *State) SubnetFor(a netip.Addr) *Subnet {
for _, s := range st.Subnets {
if s.Prefix.Contains(a) {
return s
}
}
return nil
}
// ReservationByID finds a reservation.
func (st *State) ReservationByID(id int64) *Reservation {
for _, r := range st.Reservations {
if r.ID == id {
return r
}
}
return nil
}
// FindSubnet finds a subnet by ID, name or prefix.
func (st *State) FindSubnet(ref string) *Subnet {
ref = strings.TrimSpace(ref)
var id int64
if _, err := fmt.Sscan(ref, &id); err == nil && fmt.Sprint(id) == ref {
if s := st.SubnetByID(id); s != nil {
return s
}
}
for _, s := range st.Subnets {
if strings.EqualFold(s.Name, ref) && s.Name != "" {
return s
}
}
if p, err := netip.ParsePrefix(ref); err == nil {
for _, s := range st.Subnets {
if s.Prefix == p.Masked() {
return s
}
}
}
return nil
}
// Clone returns a deep copy.
func (st *State) Clone() *State {
b, err := json.Marshal(st)
if err != nil {
panic(err)
}
var out State
if err := json.Unmarshal(b, &out); err != nil {
panic(err)
}
return &out
}
// Sort puts subnets and reservations in display order: subnets by
// interface then address, reservations by address.
func (st *State) Sort() {
sort.SliceStable(st.Subnets, func(i, j int) bool {
a, b := st.Subnets[i], st.Subnets[j]
if a.Interface != b.Interface {
return a.Interface < b.Interface
}
return a.Prefix.Addr().Less(b.Prefix.Addr())
})
sort.SliceStable(st.Reservations, func(i, j int) bool {
return st.Reservations[i].IP.Less(st.Reservations[j].IP)
})
}
// Interfaces lists the interfaces subnets are served on (enabled ones).
func (st *State) Interfaces() []string {
seen := map[string]bool{}
var out []string
for _, s := range st.Subnets {
if !s.Disabled && s.Interface != "" && !seen[s.Interface] {
seen[s.Interface] = true
out = append(out, s.Interface)
}
}
sort.Strings(out)
return out
}
+66
View File
@@ -0,0 +1,66 @@
package model
import (
"net/netip"
"time"
)
// Lease states.
const (
// LeaseActive: the client holds the address until End.
LeaseActive = "active"
// LeaseReleased: the client gave the address back. The row stays so
// the client gets the same address next time, if it is still free.
LeaseReleased = "released"
// LeaseExpired: the lease ran out (the state is computed: an active
// lease past its End).
LeaseExpired = "expired"
// LeaseDeclined: a client said the address is in use by someone else
// (DHCPDECLINE), or it answered a ping. It isn't handed out until End.
LeaseDeclined = "declined"
)
// Lease is an address handed out, or held back.
type Lease struct {
IP netip.Addr `json:"ip"`
SubnetID int64 `json:"subnet_id"`
MAC string `json:"mac"`
ClientID string `json:"client_id,omitempty"`
// Hostname is the name the client sent (option 12 or 81).
Hostname string `json:"hostname"`
State string `json:"state"`
Start time.Time `json:"start"`
End time.Time `json:"end"`
// Interface is where the client was last seen.
Interface string `json:"interface"`
Updated time.Time `json:"updated"`
}
// StateAt is the lease's state at a time (an active lease past its end
// has expired).
func (l *Lease) StateAt(now time.Time) string {
if l.State == LeaseActive && !now.Before(l.End) {
return LeaseExpired
}
return l.State
}
// ActiveAt reports whether the lease holds its address at a time.
func (l *Lease) ActiveAt(now time.Time) bool {
return l.State == LeaseActive && now.Before(l.End)
}
// HeldAt reports whether the address can't be handed out to another
// client at a time: an active or declined lease that hasn't ended.
func (l *Lease) HeldAt(now time.Time) bool {
return (l.State == LeaseActive || l.State == LeaseDeclined) && now.Before(l.End)
}
// Owns reports whether the lease belongs to a client: by client
// identifier when both have one, else by MAC address.
func (l *Lease) Owns(mac, clientID string) bool {
if l.ClientID != "" && clientID != "" {
return l.ClientID == clientID
}
return l.MAC != "" && l.MAC == mac
}
+281
View File
@@ -0,0 +1,281 @@
package server
import (
"context"
"crypto/subtle"
"errors"
"net/http"
"strings"
"sync"
"time"
"steward/internal/api"
"steward/internal/auth"
"steward/internal/model"
"steward/internal/store"
"steward/internal/version"
)
const (
sessionCookie = "steward_session"
csrfHeader = "X-CSRF-Token"
)
// rateLimiter limits failed logins and token attempts per key (address or
// user name) over a sliding window.
type rateLimiter struct {
mu sync.Mutex
hits map[string][]time.Time
}
func newRateLimiter() *rateLimiter { return &rateLimiter{hits: map[string][]time.Time{}} }
const (
failWindow = 15 * time.Minute
failsPerIP = 20
failsPerKey = 8
)
func (l *rateLimiter) recent(key string, now time.Time) []time.Time {
var keep []time.Time
for _, t := range l.hits[key] {
if now.Sub(t) < failWindow {
keep = append(keep, t)
}
}
l.hits[key] = keep
return keep
}
func (l *rateLimiter) blocked(key string, max int) bool {
l.mu.Lock()
defer l.mu.Unlock()
return len(l.recent(key, time.Now())) >= max
}
func (l *rateLimiter) fail(keys ...string) {
l.mu.Lock()
defer l.mu.Unlock()
now := time.Now()
for _, k := range keys {
l.hits[k] = append(l.recent(k, now), now)
}
}
func (l *rateLimiter) reset(key string) {
l.mu.Lock()
defer l.mu.Unlock()
delete(l.hits, key)
}
func (l *rateLimiter) cleanup() {
l.mu.Lock()
defer l.mu.Unlock()
now := time.Now()
for k := range l.hits {
if len(l.recent(k, now)) == 0 {
delete(l.hits, k)
}
}
}
var errUnauthenticated = &httpError{http.StatusUnauthorized, "unauthenticated", "authentication required: log in, or send an API token as 'Authorization: Bearer steward_...'"}
// authenticate identifies the caller: the local socket's peer, a bearer
// token or a session cookie (with the CSRF token for changes).
func (s *Server) authenticate(r *http.Request) (*auth.Principal, *model.Session, error) {
ip := s.clientIP(r)
if pr := peerOf(r.Context()); pr != nil && r.Header.Get("Authorization") == "" {
if trustedUID(pr.UID) {
return socketPrincipal(pr, r.UserAgent()), nil, nil
}
return nil, nil, socketDenied(pr)
}
if h := r.Header.Get("Authorization"); h != "" {
scheme, tok, _ := strings.Cut(h, " ")
if !strings.EqualFold(scheme, "Bearer") {
return nil, nil, &httpError{http.StatusUnauthorized, "unauthenticated", "unsupported authorization scheme (use Bearer with an API token)"}
}
if s.limiter.blocked("ip:"+ip, failsPerIP) {
return nil, nil, &httpError{http.StatusTooManyRequests, "rate_limited", "too many failed attempts; try again later"}
}
p, err := s.tokenPrincipal(r.Context(), strings.TrimSpace(tok), ip, r.UserAgent())
if err != nil {
s.limiter.fail("ip:" + ip)
s.eng.Audit(r.Context(), &auth.Principal{IP: ip, Method: "token"}, "auth.token_failed", "", "failure", err.Error())
return nil, nil, &httpError{http.StatusUnauthorized, "unauthenticated", "invalid API token: " + err.Error()}
}
return p, nil, nil
}
c, err := r.Cookie(sessionCookie)
if err != nil || c.Value == "" {
return nil, nil, errUnauthenticated
}
hash := auth.HashSecret(c.Value)
se, err := s.store.GetSession(r.Context(), hash)
if err != nil {
return nil, nil, &httpError{http.StatusUnauthorized, "session_expired", "your session has expired; please log in again"}
}
now := time.Now()
if now.After(se.ExpiresAt) || now.Sub(se.LastSeenAt) > s.cfg.SessionIdle {
s.store.DeleteSession(r.Context(), hash)
return nil, nil, &httpError{http.StatusUnauthorized, "session_expired", "your session has expired; please log in again"}
}
u, err := s.store.GetUser(r.Context(), se.UserID)
if err != nil || u.Disabled {
s.store.DeleteSession(r.Context(), hash)
return nil, nil, &httpError{http.StatusUnauthorized, "session_expired", "your account is not available; please log in again"}
}
if r.Method != http.MethodGet && r.Method != http.MethodHead {
got := r.Header.Get(csrfHeader)
if got == "" || subtle.ConstantTimeCompare([]byte(got), []byte(se.CSRFToken)) != 1 {
return nil, nil, &httpError{http.StatusForbidden, "csrf", "missing or invalid " + csrfHeader + " header"}
}
}
if now.Sub(se.LastSeenAt) > time.Minute {
s.store.TouchSession(r.Context(), hash, now)
}
return &auth.Principal{User: u, Method: "session", IP: ip, Agent: r.UserAgent()}, se, nil
}
func (s *Server) tokenPrincipal(ctx context.Context, tok, ip, agent string) (*auth.Principal, error) {
pid, secret, err := auth.ParseToken(tok)
if err != nil {
return nil, err
}
t, err := s.store.GetTokenByPublicID(ctx, pid)
if errors.Is(err, store.ErrNotFound) {
return nil, errors.New("unknown token")
}
if err != nil {
return nil, err
}
if !auth.CheckSecret(secret, t.SecretHash) {
return nil, errors.New("wrong token secret")
}
if t.RevokedAt != nil {
return nil, errors.New("the token was revoked")
}
now := time.Now()
if t.ExpiresAt != nil && now.After(*t.ExpiresAt) {
return nil, errors.New("the token has expired")
}
u, err := s.store.GetUser(ctx, t.UserID)
if err != nil {
return nil, err
}
if u.Disabled {
return nil, errors.New("the token's user is disabled")
}
if t.LastUsedAt == nil || now.Sub(*t.LastUsedAt) > time.Minute || t.LastUsedIP != ip {
s.store.TouchToken(ctx, t.ID, ip)
}
return &auth.Principal{User: u, Token: t, Method: "token", IP: ip, Agent: agent}, nil
}
// authed wraps a handler with authentication and error handling.
func (s *Server) authed(h handler) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, s.cfg.MaxBodyBytes)
p, se, err := s.authenticate(r)
if err != nil {
s.writeErr(w, r, err, nil)
return
}
ctx := context.WithValue(r.Context(), keyPrincipal, p)
if se != nil {
ctx = context.WithValue(ctx, keySession, se)
}
r = r.WithContext(ctx)
if err := h(w, r, p); err != nil {
s.writeErr(w, r, err, nil)
}
}
}
// public wraps an unauthenticated handler.
func (s *Server) public(h func(w http.ResponseWriter, r *http.Request) error) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, 1<<20)
if err := h(w, r); err != nil {
s.writeErr(w, r, err, nil)
}
}
}
func (s *Server) cookieSecure(r *http.Request) bool {
switch s.cfg.CookieSecure {
case "always":
return true
case "never":
return false
}
return s.isHTTPS(r)
}
func (s *Server) login(w http.ResponseWriter, r *http.Request) error {
var in api.Login
if err := decodeJSON(r, &in); err != nil {
return err
}
ip := s.clientIP(r)
name := strings.TrimSpace(in.Username)
userKey := "user:" + strings.ToLower(name)
who := &auth.Principal{User: &model.User{Username: name}, IP: ip, Method: "session"}
if s.limiter.blocked("ip:"+ip, failsPerIP) || s.limiter.blocked(userKey, failsPerKey) {
s.eng.Audit(r.Context(), who, "auth.login_blocked", "user "+name, "denied", "too many failed attempts")
return &httpError{http.StatusTooManyRequests, "rate_limited", "too many failed login attempts; wait a few minutes and try again"}
}
u, err := s.store.GetUserByName(r.Context(), name)
hash := ""
if err == nil {
hash = u.PasswordHash
}
ok := auth.CheckPassword(hash, in.Password)
if err != nil || !ok || u.Disabled || u.PasswordHash == "" {
s.limiter.fail("ip:"+ip, userKey)
reason := "wrong username or password"
if err == nil && u.Disabled && ok {
reason = "account disabled"
}
s.eng.Audit(r.Context(), who, "auth.login_failed", "user "+name, "failure", reason)
return &httpError{http.StatusUnauthorized, "login_failed", "wrong username or password"}
}
s.limiter.reset(userKey)
id, csrf := auth.NewSessionID()
now := time.Now()
se := &model.Session{IDHash: auth.HashSecret(id), UserID: u.ID, CSRFToken: csrf, CreatedAt: now, ExpiresAt: now.Add(s.cfg.SessionLifetime), LastSeenAt: now, IP: ip, UserAgent: r.UserAgent()}
if err := s.store.CreateSession(r.Context(), se); err != nil {
return err
}
s.store.TouchLogin(r.Context(), u.ID)
http.SetCookie(w, &http.Cookie{Name: sessionCookie, Value: id, Path: "/", HttpOnly: true, Secure: s.cookieSecure(r), SameSite: http.SameSiteStrictMode, Expires: se.ExpiresAt})
p := &auth.Principal{User: u, Method: "session", IP: ip, Agent: r.UserAgent()}
s.eng.Audit(r.Context(), p, "auth.login", "user "+u.Username, "success", "")
writeJSON(w, http.StatusOK, s.whoamiOf(p, se))
return nil
}
func (s *Server) logout(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
if se, ok := r.Context().Value(keySession).(*model.Session); ok {
s.store.DeleteSession(r.Context(), se.IDHash)
}
http.SetCookie(w, &http.Cookie{Name: sessionCookie, Value: "", Path: "/", HttpOnly: true, Secure: s.cookieSecure(r), SameSite: http.SameSiteStrictMode, MaxAge: -1})
s.eng.Audit(r.Context(), p, "auth.logout", "user "+p.Name(), "success", "")
w.WriteHeader(http.StatusNoContent)
return nil
}
func (s *Server) whoamiOf(p *auth.Principal, se *model.Session) api.Whoami {
out := api.Whoami{User: p.User, Admin: p.IsAdmin(), Method: p.Method, Permissions: p.Permissions(), Build: version.Info()}
if se != nil {
out.CSRFToken = se.CSRFToken
}
return out
}
func (s *Server) whoami(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
se, _ := r.Context().Value(keySession).(*model.Session)
writeJSON(w, http.StatusOK, s.whoamiOf(p, se))
return nil
}
+601
View File
@@ -0,0 +1,601 @@
package server
import (
"encoding/json"
"errors"
"fmt"
"net/http"
"net/netip"
"sort"
"strconv"
"strings"
"steward/internal/api"
"steward/internal/auth"
"steward/internal/dhcp4"
"steward/internal/engine"
"steward/internal/model"
"steward/internal/store"
)
// change applies a configuration change and answers with what changed.
// object, if set, returns the object to include from the new state.
func (s *Server) change(w http.ResponseWriter, r *http.Request, p *auth.Principal, description string, edit engine.Edit, object func(st *model.State) any) error {
if err := require(p, auth.ResConfig, true); err != nil {
return err
}
res, err := s.eng.Apply(r.Context(), p, description, engine.Options{DryRun: queryBool(r, "dry_run"), Via: via(r, p)}, edit)
if err != nil {
s.writeErr(w, r, err, res)
return nil
}
s.respondChange(w, r, res, object, r.Method == http.MethodPost)
return nil
}
// respondChange answers a change: 201 when it created something.
func (s *Server) respondChange(w http.ResponseWriter, r *http.Request, res *engine.Result, object func(st *model.State) any, creates bool) {
out := api.MutationResponse{Applied: res.Applied, DryRun: res.DryRun, HistoryID: res.HistoryID, Changes: res.Changes, Warnings: res.Warnings}
if out.Changes == nil {
out.Changes = []model.Change{}
}
if object != nil && res.Applied {
if v := object(s.eng.State()); v != nil {
out.Object, _ = json.Marshal(v)
}
}
status := http.StatusOK
if res.Applied && creates {
status = http.StatusCreated
}
writeJSON(w, status, out)
}
// optionsFrom reads options given by name or number.
func optionsFrom(in []api.OptionInput) ([]model.OptionValue, error) {
out := []model.OptionValue{}
for _, o := range in {
def, ok := dhcp4.Lookup(o.Option)
if !ok {
return nil, badRequest("unknown option %q: use a name from 'steward option list' or a number from 1 to 254", o.Option)
}
if why, reserved := dhcp4.Reserved[def.Code]; reserved {
return nil, badRequest("option %s (%d) can't be set: %s", o.Option, def.Code, why)
}
v := model.OptionValue{Code: int(def.Code), Value: o.Value}
if !dhcp4.Known(def.Code) {
v.Type = o.Type
if v.Type == "" {
v.Type = dhcp4.TypeHex
}
} else if o.Type != "" && o.Type != def.Type {
return nil, badRequest("option %s has type %s; a type can only be given for options Steward doesn't know", def.Name, def.Type)
}
out = append(out, v)
}
return out, nil
}
// ---- settings
func (s *Server) getSettings(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
if err := require(p, auth.ResConfig, false); err != nil {
return err
}
writeJSON(w, http.StatusOK, s.eng.State().Settings)
return nil
}
func (s *Server) updateSettings(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
var in api.SettingsInput
if err := decodeJSON(r, &in); err != nil {
return err
}
var opts []model.OptionValue
if in.Options != nil {
var err error
if opts, err = optionsFrom(*in.Options); err != nil {
return err
}
}
return s.change(w, r, p, "", func(st *model.State, _ func() int64) error {
set := &st.Settings
if in.LeaseTime != nil {
set.LeaseTime = *in.LeaseTime
}
if in.Options != nil {
set.Options = opts
}
if in.PingCheck != nil {
set.PingCheck = *in.PingCheck
}
if in.PingTimeout != nil {
set.PingTimeout = *in.PingTimeout
}
if in.DeclineHold != nil {
set.DeclineHold = *in.DeclineHold
}
if in.NotAuthoritative != nil {
set.NotAuthoritative = *in.NotAuthoritative
}
return nil
}, func(st *model.State) any { return st.Settings })
}
// ---- subnets
func (s *Server) presentSubnet(sn *model.Subnet, st *model.State, leases []*model.Lease) api.Subnet {
out := api.Subnet{Subnet: sn}
now := s.nowFn()
for _, p := range sn.Pools {
out.Usage.PoolSize += p.Size()
}
reserved := map[netip.Addr]bool{}
for _, res := range st.Reservations {
if sn.Prefix.Contains(res.IP) {
out.Usage.Reservations++
reserved[res.IP] = true
for _, p := range sn.Pools {
if p.Contains(res.IP) {
out.Usage.PoolSize-- // reserved addresses aren't handed out
}
}
}
}
for _, l := range leases {
if !sn.Prefix.Contains(l.IP) {
continue
}
switch {
case reserved[l.IP] && l.ActiveAt(now):
out.Usage.ReservationsLeased++
case l.ActiveAt(now):
out.Usage.Leased++
case l.HeldAt(now):
out.Usage.Declined++
}
}
return out
}
func (s *Server) listSubnets(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
if err := require(p, auth.ResConfig, false); err != nil {
return err
}
st := s.eng.State()
leases := s.dhcp.Leases()
out := []api.Subnet{}
for _, sn := range st.Subnets {
out = append(out, s.presentSubnet(sn, st, leases))
}
writeJSON(w, http.StatusOK, out)
return nil
}
// subnetRef finds a subnet by ID, name or prefix (10.0.10.0/24 or
// 10.0.10.0_24 in a path).
func subnetRef(st *model.State, ref string) (*model.Subnet, error) {
if sn := st.FindSubnet(strings.Replace(ref, "_", "/", 1)); sn != nil {
return sn, nil
}
return nil, notFound("no subnet %q (give its ID, name or prefix)", ref)
}
func (s *Server) getSubnet(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
if err := require(p, auth.ResConfig, false); err != nil {
return err
}
st := s.eng.State()
sn, err := subnetRef(st, r.PathValue("ref"))
if err != nil {
return err
}
writeJSON(w, http.StatusOK, s.presentSubnet(sn, st, s.dhcp.Leases()))
return nil
}
// applySubnetInput sets the given fields.
func applySubnetInput(sn *model.Subnet, in *api.SubnetInput) error {
if in.Name != nil {
sn.Name = strings.TrimSpace(*in.Name)
}
if in.Interface != nil {
sn.Interface = strings.TrimSpace(*in.Interface)
}
if in.Prefix != nil {
pfx, err := netip.ParsePrefix(strings.TrimSpace(*in.Prefix))
if err != nil {
return badRequest("prefix %q: write the network as 10.0.10.0/24", *in.Prefix)
}
sn.Prefix = pfx
}
if in.Pools != nil {
sn.Pools = []model.Pool{}
for _, ps := range *in.Pools {
pool, err := model.ParsePool(ps)
if err != nil {
return badRequest("%v", err)
}
sn.Pools = append(sn.Pools, pool)
}
}
if in.LeaseTime != nil {
sn.LeaseTime = *in.LeaseTime
}
if in.Options != nil {
opts, err := optionsFrom(*in.Options)
if err != nil {
return err
}
sn.Options = opts
}
if in.Disabled != nil {
sn.Disabled = *in.Disabled
}
if in.Comment != nil {
sn.Comment = *in.Comment
}
return nil
}
func (s *Server) createSubnet(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
var in api.SubnetInput
if err := decodeJSON(r, &in); err != nil {
return err
}
if in.Prefix == nil || in.Interface == nil {
return badRequest("a subnet needs its prefix and its interface")
}
var id int64
return s.change(w, r, p, "", func(st *model.State, newID func() int64) error {
sn := &model.Subnet{Pools: []model.Pool{}, Options: []model.OptionValue{}}
if err := applySubnetInput(sn, &in); err != nil {
return err
}
id = newID()
sn.ID = id
st.Subnets = append(st.Subnets, sn)
return nil
}, func(st *model.State) any { return st.SubnetByID(id) })
}
func (s *Server) updateSubnet(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
var in api.SubnetInput
if err := decodeJSON(r, &in); err != nil {
return err
}
var id int64
return s.change(w, r, p, "", func(st *model.State, _ func() int64) error {
sn, err := subnetRef(st, r.PathValue("ref"))
if err != nil {
return err
}
id = sn.ID
return applySubnetInput(sn, &in)
}, func(st *model.State) any { return st.SubnetByID(id) })
}
func (s *Server) deleteSubnet(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
return s.change(w, r, p, "", func(st *model.State, _ func() int64) error {
sn, err := subnetRef(st, r.PathValue("ref"))
if err != nil {
return err
}
var keep []*model.Subnet
for _, x := range st.Subnets {
if x.ID != sn.ID {
keep = append(keep, x)
}
}
st.Subnets = keep
// Its reservations go with it (they can't be anywhere else).
var kr []*model.Reservation
for _, res := range st.Reservations {
if !sn.Prefix.Contains(res.IP) || st.SubnetFor(res.IP) != nil {
kr = append(kr, res)
}
}
st.Reservations = kr
return nil
}, nil)
}
// ---- reservations
func (s *Server) presentReservation(res *model.Reservation, st *model.State) api.Reservation {
out := api.Reservation{Reservation: res}
if sn := st.SubnetFor(res.IP); sn != nil {
out.SubnetID, out.SubnetName = sn.ID, sn.Name
if out.SubnetName == "" {
out.SubnetName = sn.Prefix.String()
}
}
if l := s.dhcp.Lease(res.IP); l != nil && l.ActiveAt(s.nowFn()) {
out.Lease = s.presentLease(l, st)
}
return out
}
func (s *Server) listReservations(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
if err := require(p, auth.ResConfig, false); err != nil {
return err
}
st := s.eng.State()
var only *model.Subnet
if ref := r.URL.Query().Get("subnet"); ref != "" {
var err error
if only, err = subnetRef(st, ref); err != nil {
return err
}
}
out := []api.Reservation{}
for _, res := range st.Reservations {
if only != nil && !only.Prefix.Contains(res.IP) {
continue
}
out = append(out, s.presentReservation(res, st))
}
writeJSON(w, http.StatusOK, out)
return nil
}
// reservationRef finds a reservation by ID, address or MAC address.
func reservationRef(st *model.State, ref string) (*model.Reservation, error) {
if id, err := strconv.ParseInt(ref, 10, 64); err == nil {
if res := st.ReservationByID(id); res != nil {
return res, nil
}
}
if a, err := netip.ParseAddr(ref); err == nil {
for _, res := range st.Reservations {
if res.IP == a {
return res, nil
}
}
}
if m, err := model.NormalizeMAC(ref); err == nil {
var found []*model.Reservation
for _, res := range st.Reservations {
if res.MAC == m {
found = append(found, res)
}
}
if len(found) == 1 {
return found[0], nil
}
if len(found) > 1 {
return nil, badRequest("%s has %d reservations; give the address or the ID", m, len(found))
}
}
return nil, notFound("no reservation %q (give its ID, address or MAC address)", ref)
}
func (s *Server) getReservation(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
if err := require(p, auth.ResConfig, false); err != nil {
return err
}
st := s.eng.State()
res, err := reservationRef(st, r.PathValue("ref"))
if err != nil {
return err
}
writeJSON(w, http.StatusOK, s.presentReservation(res, st))
return nil
}
func applyReservationInput(res *model.Reservation, in *api.ReservationInput) error {
if in.MAC != nil {
res.MAC = ""
if strings.TrimSpace(*in.MAC) != "" {
m, err := model.NormalizeMAC(*in.MAC)
if err != nil {
return badRequest("%v", err)
}
res.MAC = m
}
}
if in.ClientID != nil {
res.ClientID = ""
if strings.TrimSpace(*in.ClientID) != "" {
id, err := model.NormalizeClientID(*in.ClientID)
if err != nil {
return badRequest("%v", err)
}
res.ClientID = id
}
}
if in.IP != nil {
a, err := netip.ParseAddr(strings.TrimSpace(*in.IP))
if err != nil || !a.Is4() {
return badRequest("%q is not an IPv4 address", *in.IP)
}
res.IP = a
}
if in.Hostname != nil {
res.Hostname = strings.TrimSpace(*in.Hostname)
}
if in.Options != nil {
opts, err := optionsFrom(*in.Options)
if err != nil {
return err
}
res.Options = opts
}
if in.Comment != nil {
res.Comment = *in.Comment
}
return nil
}
func (s *Server) createReservation(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
var in api.ReservationInput
if err := decodeJSON(r, &in); err != nil {
return err
}
if in.IP == nil {
return badRequest("a reservation needs an address")
}
var id int64
return s.change(w, r, p, "", func(st *model.State, newID func() int64) error {
res := &model.Reservation{Options: []model.OptionValue{}}
if err := applyReservationInput(res, &in); err != nil {
return err
}
id = newID()
res.ID = id
st.Reservations = append(st.Reservations, res)
return nil
}, func(st *model.State) any {
if res := st.ReservationByID(id); res != nil {
return s.presentReservation(res, st)
}
return nil
})
}
func (s *Server) updateReservation(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
var in api.ReservationInput
if err := decodeJSON(r, &in); err != nil {
return err
}
var id int64
return s.change(w, r, p, "", func(st *model.State, _ func() int64) error {
res, err := reservationRef(st, r.PathValue("ref"))
if err != nil {
return err
}
id = res.ID
return applyReservationInput(res, &in)
}, func(st *model.State) any {
if res := st.ReservationByID(id); res != nil {
return s.presentReservation(res, st)
}
return nil
})
}
func (s *Server) deleteReservation(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
return s.change(w, r, p, "", func(st *model.State, _ func() int64) error {
res, err := reservationRef(st, r.PathValue("ref"))
if err != nil {
return err
}
var keep []*model.Reservation
for _, x := range st.Reservations {
if x.ID != res.ID {
keep = append(keep, x)
}
}
st.Reservations = keep
return nil
}, nil)
}
// ---- history
func (s *Server) listHistory(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
if err := require(p, auth.ResConfig, false); err != nil {
return err
}
hs, err := s.store.ListHistory(r.Context(), queryInt(r, "limit", 100), int64(queryInt(r, "before", 0)))
if err != nil {
return err
}
writeJSON(w, http.StatusOK, hs)
return nil
}
func (s *Server) getHistory(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
if err := require(p, auth.ResConfig, false); err != nil {
return err
}
id, err := pathID(r, "id")
if err != nil {
return err
}
h, err := s.store.GetHistory(r.Context(), id)
if errors.Is(err, store.ErrNotFound) {
return notFound("history entry %d not found", id)
}
if err != nil {
return err
}
writeJSON(w, http.StatusOK, h)
return nil
}
func (s *Server) revertHistory(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
if err := require(p, auth.ResConfig, true); err != nil {
return err
}
id, err := pathID(r, "id")
if err != nil {
return err
}
res, err := s.eng.Revert(r.Context(), p, id, engine.Options{DryRun: queryBool(r, "dry_run"), Via: via(r, p)})
if err != nil {
s.writeErr(w, r, err, res)
return nil
}
s.respondChange(w, r, res, nil, false)
return nil
}
// ---- catalog and interfaces
func (s *Server) catalog(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
out := api.Catalog{Types: dhcp4.Types, Reserved: map[int]string{}, Roles: map[string][]string{}}
for _, d := range dhcp4.Catalog {
out.Options = append(out.Options, api.OptionDef{Code: int(d.Code), Name: d.Name, Type: d.Type, Help: d.Help, Common: d.Common})
}
for c, why := range dhcp4.Reserved {
out.Reserved[int(c)] = why
}
for n, gs := range auth.Roles {
out.Roles[n] = grantStrings(gs)
}
writeJSON(w, http.StatusOK, out)
return nil
}
func (s *Server) interfaces(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
if err := require(p, auth.ResConfig, false); err != nil {
return err
}
all, err := s.ifaces.All()
if err != nil {
return fmt.Errorf("reading the interfaces: %w", err)
}
st := s.eng.State()
out := []api.HostInterface{}
for name, ps := range all {
hi := api.HostInterface{Name: name, Addresses: []string{}, Subnets: []int64{}}
for _, a := range ps {
hi.Addresses = append(hi.Addresses, a.String())
}
for _, sn := range st.Subnets {
if sn.Interface == name {
hi.Subnets = append(hi.Subnets, sn.ID)
}
}
out = append(out, hi)
}
// Subnets may name interfaces that don't exist (yet).
for _, sn := range st.Subnets {
if _, ok := all[sn.Interface]; !ok && sn.Interface != "" {
found := false
for i := range out {
if out[i].Name == sn.Interface {
out[i].Subnets = append(out[i].Subnets, sn.ID)
found = true
}
}
if !found {
out = append(out, api.HostInterface{Name: sn.Interface, Addresses: []string{}, Subnets: []int64{sn.ID}})
}
}
}
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
writeJSON(w, http.StatusOK, out)
return nil
}
+149
View File
@@ -0,0 +1,149 @@
package server
import (
"fmt"
"net/http"
"sort"
"strings"
"time"
"steward/internal/api"
"steward/internal/auth"
"steward/internal/engine"
"steward/internal/kea"
"steward/internal/model"
)
// KeaImport is the body of POST /import/kea.
type KeaImport struct {
// Config is the kea-dhcp4.conf text; Leases the lease files' texts
// (kea-leases4.csv, and kea-leases4.csv.2 if there is one, older
// first).
Config string `json:"config"`
Leases []string `json:"leases,omitempty"`
// Interfaces gives subnets' interfaces by prefix, for subnets Kea
// doesn't tie to one.
Interfaces map[string]string `json:"interfaces,omitempty"`
// Replace replaces Steward's configuration when it already has
// subnets or reservations.
Replace bool `json:"replace,omitempty"`
}
// importKea: POST /import/kea makes Steward's configuration Kea's, and
// adds Kea's current leases (?dry_run=1 shows what it would do).
func (s *Server) importKea(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
if err := require(p, auth.ResConfig, true); err != nil {
return err
}
if err := require(p, auth.ResLeases, true); err != nil {
return err
}
var in KeaImport
if err := decodeJSON(r, &in); err != nil {
return err
}
host, _ := s.ifaces.All()
res, err := kea.ParseConfig([]byte(in.Config), kea.Options{HostInterfaces: host, Interfaces: in.Interfaces})
if err != nil {
return badRequest("%v", err)
}
var missing []string
for _, sn := range res.State.Subnets {
if sn.Interface == "" {
missing = append(missing, sn.Prefix.String())
}
}
if len(missing) > 0 {
return badRequest("Kea doesn't say which interface serves %s, and no interface here has an address in it; give it: --interface %s=IFACE", strings.Join(missing, ", "), missing[0])
}
cur := s.eng.State()
if (len(cur.Subnets) > 0 || len(cur.Reservations) > 0) && !in.Replace {
return conflict("Steward already has %d subnets and %d reservations; import with replace (--replace) to replace them with Kea's", len(cur.Subnets), len(cur.Reservations))
}
// A subnet with the same prefix, or a reservation for the same address,
// keeps its ID, so importing again shows only what differs. Kea has no
// subnet names: Steward's stay.
ids, err := s.store.AllocateIDs(r.Context(), len(res.State.Subnets)+len(res.State.Reservations))
if err != nil {
return err
}
for i, sn := range res.State.Subnets {
sn.ID = ids[i]
for _, old := range cur.Subnets {
if old.Prefix == sn.Prefix {
sn.ID, sn.Name = old.ID, old.Name
if sn.Comment == "" {
sn.Comment = old.Comment
}
}
}
}
for i, rv := range res.State.Reservations {
rv.ID = ids[len(res.State.Subnets)+i]
for _, old := range cur.Reservations {
if old.IP == rv.IP {
rv.ID = old.ID
if rv.Comment == "" {
rv.Comment = old.Comment
}
}
}
}
// Steward's own settings that Kea has no word for stay.
res.State.Settings.PingCheck = cur.Settings.PingCheck
res.State.Settings.PingTimeout = cur.Settings.PingTimeout
res.State.Settings.NotAuthoritative = cur.Settings.NotAuthoritative
var leases []*model.Lease
skippedLeases := 0
now := time.Now()
for i, text := range in.Leases {
ls, err := kea.ParseLeases(strings.NewReader(text), now)
if err != nil {
return badRequest("lease file %d: %v", i+1, err)
}
leases = append(leases, ls...)
}
// A later file's lease for an address replaces an earlier one's.
byIP := map[string]*model.Lease{}
for _, l := range leases {
sn := res.State.SubnetFor(l.IP)
if sn == nil {
skippedLeases++
continue
}
l.SubnetID, l.Interface = sn.ID, sn.Interface
byIP[l.IP.String()] = l
}
leases = leases[:0]
for _, l := range byIP {
leases = append(leases, l)
}
sort.Slice(leases, func(i, j int) bool { return leases[i].IP.Less(leases[j].IP) })
if skippedLeases > 0 {
res.Notes = append(res.Notes, fmt.Sprintf("%d leases are for addresses outside the imported subnets and were left out", skippedLeases))
}
result, err := s.eng.Replace(r.Context(), p, "import from Kea", engine.Options{DryRun: queryBool(r, "dry_run"), Via: via(r, p)}, res.State)
if err != nil {
s.writeErr(w, r, err, result)
return nil
}
if result.Applied && len(leases) > 0 {
if err := s.dhcp.ImportLeases(r.Context(), leases); err != nil {
return fmt.Errorf("the configuration was imported, but saving the leases failed: %w", err)
}
s.eng.Audit(r.Context(), p, "lease.import", "kea", "success", fmt.Sprintf("%d leases", len(leases)))
}
out := api.ImportResult{MutationResponse: api.MutationResponse{Applied: result.Applied, DryRun: result.DryRun, HistoryID: result.HistoryID, Changes: result.Changes, Warnings: result.Warnings},
Leases: len(leases), Notes: res.Notes, Skipped: res.Skipped}
if out.Changes == nil {
out.Changes = []model.Change{}
}
if out.Notes == nil {
out.Notes = []string{}
}
if out.Skipped == nil {
out.Skipped = []string{}
}
writeJSON(w, http.StatusOK, out)
return nil
}
+207
View File
@@ -0,0 +1,207 @@
package server
import (
"errors"
"net/http"
"net/netip"
"strings"
"time"
"steward/internal/api"
"steward/internal/auth"
"steward/internal/dhcpd"
"steward/internal/model"
"steward/internal/store"
"steward/internal/validate"
"steward/internal/version"
)
func (s *Server) nowFn() time.Time { return time.Now() }
func (s *Server) presentLease(l *model.Lease, st *model.State) *api.Lease {
c := *l
c.State = l.StateAt(s.nowFn())
out := &api.Lease{Lease: &c}
if sn := st.SubnetFor(l.IP); sn != nil {
out.SubnetName = sn.Name
if out.SubnetName == "" {
out.SubnetName = sn.Prefix.String()
}
}
for _, r := range st.Reservations {
if r.IP == l.IP {
out.Reservation, out.ReservedHostname = r.ID, r.Hostname
}
}
return out
}
// listLeases: GET /leases?subnet=&state=&interface=&q= (q matches the
// address, MAC address or host name).
func (s *Server) listLeases(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
if err := require(p, auth.ResLeases, false); err != nil {
return err
}
st := s.eng.State()
q := r.URL.Query()
var only *model.Subnet
if ref := q.Get("subnet"); ref != "" {
var err error
if only, err = subnetRef(st, ref); err != nil {
return err
}
}
state, iface, search := q.Get("state"), q.Get("interface"), strings.ToLower(q.Get("q"))
out := []*api.Lease{}
for _, l := range s.dhcp.Leases() {
pl := s.presentLease(l, st)
if only != nil && !only.Prefix.Contains(l.IP) || state != "" && pl.State != state || iface != "" && l.Interface != iface {
continue
}
if search != "" && !strings.Contains(l.IP.String(), search) && !strings.Contains(l.MAC, search) &&
!strings.Contains(strings.ToLower(l.Hostname), search) && !strings.Contains(strings.ToLower(pl.ReservedHostname), search) {
continue
}
out = append(out, pl)
}
writeJSON(w, http.StatusOK, out)
return nil
}
func leaseIP(r *http.Request) (netip.Addr, error) {
a, err := netip.ParseAddr(r.PathValue("ip"))
if err != nil || !a.Is4() {
return netip.Addr{}, badRequest("%q is not an IPv4 address", r.PathValue("ip"))
}
return a, nil
}
func (s *Server) getLease(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
if err := require(p, auth.ResLeases, false); err != nil {
return err
}
ip, err := leaseIP(r)
if err != nil {
return err
}
l := s.dhcp.Lease(ip)
if l == nil {
return notFound("no lease for %s", ip)
}
writeJSON(w, http.StatusOK, s.presentLease(l, s.eng.State()))
return nil
}
// deleteLease forgets a lease, freeing the address.
func (s *Server) deleteLease(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
if err := require(p, auth.ResLeases, true); err != nil {
return err
}
ip, err := leaseIP(r)
if err != nil {
return err
}
l := s.dhcp.Lease(ip)
if err := s.dhcp.DeleteLease(r.Context(), ip); err != nil {
if errors.Is(err, dhcpd.ErrNoLease) {
return notFound("no lease for %s", ip)
}
return err
}
s.eng.Audit(r.Context(), p, "lease.delete", "lease "+ip.String(), "success", l.MAC+" "+l.Hostname)
w.WriteHeader(http.StatusNoContent)
return nil
}
// reserveLease makes a lease's address the client's reservation.
func (s *Server) reserveLease(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
ip, err := leaseIP(r)
if err != nil {
return err
}
var in struct {
Hostname *string `json:"hostname,omitempty"`
Comment string `json:"comment,omitempty"`
}
if err := decodeOptional(r, &in); err != nil {
return err
}
l := s.dhcp.Lease(ip)
if l == nil || l.MAC == "" {
return notFound("no lease for %s", ip)
}
host := l.Hostname
if in.Hostname != nil {
host = *in.Hostname
} else if !validate.ValidHostname(host) {
host = ""
}
var id int64
return s.change(w, r, p, "reserve "+ip.String()+" for "+l.MAC, func(st *model.State, newID func() int64) error {
id = newID()
st.Reservations = append(st.Reservations, &model.Reservation{ID: id, MAC: l.MAC, IP: ip, Hostname: host, Options: []model.OptionValue{}, Comment: in.Comment})
return nil
}, func(st *model.State) any {
if res := st.ReservationByID(id); res != nil {
return s.presentReservation(res, st)
}
return nil
})
}
func (s *Server) listEvents(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
if err := require(p, auth.ResLeases, false); err != nil {
return err
}
evs := s.dhcp.Events(queryInt(r, "limit", 200), r.URL.Query().Get("mac"), r.URL.Query().Get("interface"))
out := make([]api.Event, len(evs))
for i, e := range evs {
out[i] = api.Event(e)
}
writeJSON(w, http.StatusOK, out)
return nil
}
// ---- status and audit
func (s *Server) status(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
if !p.Can(auth.ResConfig, false) && !p.Can(auth.ResLeases, false) {
return forbidden("permission denied: this needs config:read or leases:read")
}
st := s.eng.State()
leases := s.dhcp.Leases()
out := api.Status{Build: version.Info(), Started: s.started, Interfaces: []api.InterfaceStatus{}, Subnets: []api.Subnet{},
Problems: s.eng.Problems(), Leases: map[string]int{}, Messages: s.dhcp.Counts(), Database: s.store.Path()}
if out.Problems == nil {
out.Problems = []validate.Problem{}
}
for _, is := range s.dhcp.Status() {
out.Interfaces = append(out.Interfaces, api.InterfaceStatus(is))
}
for _, sn := range st.Subnets {
out.Subnets = append(out.Subnets, s.presentSubnet(sn, st, leases))
}
now := s.nowFn()
for _, l := range leases {
out.Leases[l.StateAt(now)]++
}
if s.cfg.PingProblem != nil && st.Settings.PingCheck {
out.PingProblem = s.cfg.PingProblem()
}
writeJSON(w, http.StatusOK, out)
return nil
}
func (s *Server) listAudit(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
if err := require(p, auth.ResAudit, false); err != nil {
return err
}
q := r.URL.Query()
evs, err := s.store.ListAudit(r.Context(), store.AuditFilter{Username: q.Get("user"), Action: q.Get("action"), Result: q.Get("result"),
Search: q.Get("q"), Before: int64(queryInt(r, "before", 0)), Limit: queryInt(r, "limit", 200)})
if err != nil {
return err
}
writeJSON(w, http.StatusOK, evs)
return nil
}
+404
View File
@@ -0,0 +1,404 @@
package server
import (
"errors"
"net/http"
"strings"
"time"
"steward/internal/api"
"steward/internal/auth"
"steward/internal/config"
"steward/internal/model"
"steward/internal/store"
)
func (s *Server) changeOwnPassword(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
if p.Token != nil {
return forbidden("passwords can't be changed with an API token; log in to the web UI")
}
if p.User.ID == 0 {
return badRequest("%s on the local socket isn't a Steward user; use 'steward user update USER --password'", p.Name())
}
var in api.PasswordChange
if err := decodeJSON(r, &in); err != nil {
return err
}
u, err := s.store.GetUser(r.Context(), p.User.ID)
if err != nil {
return err
}
if u.PasswordHash != "" && !auth.CheckPassword(u.PasswordHash, in.Current) {
s.eng.Audit(r.Context(), p, "user.password", "user "+u.Username, "failure", "wrong current password")
return &httpError{http.StatusForbidden, "wrong_password", "the current password is wrong"}
}
hash, err := auth.HashPassword(in.Password)
if err != nil {
return badRequest("%v", err)
}
u.PasswordHash = hash
if err := s.store.UpdateUser(r.Context(), u); err != nil {
return err
}
keep := ""
if se, ok := r.Context().Value(keySession).(*model.Session); ok {
keep = se.IDHash
}
s.store.DeleteUserSessions(r.Context(), u.ID, keep)
s.eng.Audit(r.Context(), p, "user.password", "user "+u.Username, "success", "")
w.WriteHeader(http.StatusNoContent)
return nil
}
func grantsFrom(strs []string, role string) ([]model.Grant, bool, error) {
var out []model.Grant
set := false
if role != "" {
gs, ok := auth.Roles[role]
if !ok {
return nil, false, badRequest("unknown role %q (use %s)", role, strings.Join(auth.RoleNames(), ", "))
}
out = append(out, gs...)
set = true
}
for _, g := range strs {
gr, err := auth.ParseGrant(g)
if err != nil {
return nil, false, badRequest("%v", err)
}
out = append(out, gr)
set = true
}
if set && out == nil {
out = []model.Grant{}
}
return out, set, nil
}
func grantStrings(gs []model.Grant) []string {
out := []string{}
for _, g := range gs {
out = append(out, auth.FormatGrant(g))
}
return out
}
func presentToken(t *model.Token) api.Token {
out := api.Token{Token: t, GrantStrings: grantStrings(t.Grants)}
if t.Grants == nil {
out.GrantStrings = []string{"(all of the user's)"}
}
return out
}
func (s *Server) createTokenFor(w http.ResponseWriter, r *http.Request, p *auth.Principal, u *model.User) error {
var in api.TokenInput
if err := decodeJSON(r, &in); err != nil {
return err
}
if strings.TrimSpace(in.Name) == "" {
return badRequest("a token needs a name")
}
grants, set, err := grantsFrom(in.Grants, in.Role)
if err != nil {
return err
}
var exp *time.Time
switch {
case in.ExpiresAt != nil:
if in.ExpiresAt.Before(time.Now()) {
return badRequest("expires_at is in the past")
}
exp = in.ExpiresAt
case in.ExpiresIn != "":
d, err := config.ParseDuration(in.ExpiresIn)
if err != nil || d <= 0 {
return badRequest("invalid expires_in %q (use e.g. 90d or 720h)", in.ExpiresIn)
}
t := time.Now().Add(d)
exp = &t
}
// A restricted token can only make tokens no wider than itself.
if p.Token != nil && p.Token.Grants != nil {
if !set {
grants, set = p.Token.Grants, true
} else if !auth.Narrower(grants, p.Token.Grants) {
return forbidden("a restricted token can only create tokens with a subset of its own grants")
}
}
secret, pid, hash := auth.NewToken()
t := &model.Token{UserID: u.ID, Name: strings.TrimSpace(in.Name), PublicID: pid, SecretHash: hash, ExpiresAt: exp}
if set {
t.Grants = grants
}
if err := s.store.CreateToken(r.Context(), t); err != nil {
return err
}
t.Username = u.Username
s.eng.Audit(r.Context(), p, "token.create", "token "+u.Username+"/"+t.Name, "success", strings.Join(grantStrings(t.Grants), " "))
out := presentToken(t)
out.Secret = secret
writeJSON(w, http.StatusCreated, out)
return nil
}
func (s *Server) createOwnToken(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
if p.User.ID == 0 {
return badRequest("%s on the local socket isn't a Steward user and has no tokens; make the token for a user (steward token create NAME --user admin)", p.Name())
}
return s.createTokenFor(w, r, p, p.User)
}
func (s *Server) listTokens(w http.ResponseWriter, r *http.Request, userID int64) error {
ts, err := s.store.ListTokens(r.Context(), userID)
if err != nil {
return err
}
out := []api.Token{}
for _, t := range ts {
if t.RevokedAt != nil && !queryBool(r, "all") {
continue
}
out = append(out, presentToken(t))
}
writeJSON(w, http.StatusOK, out)
return nil
}
func (s *Server) listOwnTokens(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
if p.User.ID == 0 {
writeJSON(w, http.StatusOK, []api.Token{})
return nil
}
return s.listTokens(w, r, p.User.ID)
}
func (s *Server) listAllTokens(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
if err := requireAdmin(p); err != nil {
return err
}
return s.listTokens(w, r, 0)
}
func (s *Server) revokeToken(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
id, err := pathID(r, "id")
if err != nil {
return err
}
t, err := s.store.GetToken(r.Context(), id)
if err != nil || t.UserID != p.User.ID && !p.IsAdmin() {
return notFound("token %d not found", id)
}
if err := s.store.RevokeToken(r.Context(), id); err != nil {
if errors.Is(err, store.ErrNotFound) {
return conflict("token %d is already revoked", id)
}
return err
}
s.eng.Audit(r.Context(), p, "token.revoke", "token "+t.Username+"/"+t.Name, "success", "")
w.WriteHeader(http.StatusNoContent)
return nil
}
func presentUser(u *model.User) api.User {
return api.User{User: u, GrantStrings: grantStrings(u.Grants)}
}
func (s *Server) listUsers(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
if err := requireAdmin(p); err != nil {
return err
}
us, err := s.store.ListUsers(r.Context())
if err != nil {
return err
}
out := []api.User{}
for _, u := range us {
out = append(out, presentUser(u))
}
writeJSON(w, http.StatusOK, out)
return nil
}
// ValidUsername checks a user name.
func ValidUsername(n string) error {
if len(n) < 1 || len(n) > 64 {
return badRequest("user names have 1 to 64 characters")
}
for _, c := range n {
if !(c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' || strings.ContainsRune("._-@", c)) {
return badRequest("user names may have letters, digits, '.', '_', '-' and '@'")
}
}
return nil
}
func applyUserInput(u *model.User, in *api.UserInput) error {
role := ""
if in.Role != nil {
role = *in.Role
}
var strs []string
if in.Grants != nil {
strs = *in.Grants
}
grants, set, err := grantsFrom(strs, role)
if err != nil {
return err
}
if set || in.Grants != nil {
if grants == nil {
grants = []model.Grant{}
}
u.Grants = grants
}
if in.Admin != nil {
u.Admin = *in.Admin
}
if in.Disabled != nil {
u.Disabled = *in.Disabled
}
if in.Comment != nil {
u.Comment = *in.Comment
}
if in.Password != nil {
if *in.Password == "" {
u.PasswordHash = "" // tokens only
} else {
h, err := auth.HashPassword(*in.Password)
if err != nil {
return badRequest("%v", err)
}
u.PasswordHash = h
}
}
return nil
}
func (s *Server) createUser(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
if err := requireAdmin(p); err != nil {
return err
}
var in api.UserInput
if err := decodeJSON(r, &in); err != nil {
return err
}
if in.Username == nil {
return badRequest("a user needs a username")
}
u := &model.User{Username: strings.TrimSpace(*in.Username)}
if err := ValidUsername(u.Username); err != nil {
return err
}
if err := applyUserInput(u, &in); err != nil {
return err
}
if err := s.store.CreateUser(r.Context(), u); err != nil {
if errors.Is(err, store.ErrConflict) {
return conflict("user %s already exists", u.Username)
}
return err
}
s.eng.Audit(r.Context(), p, "user.create", "user "+u.Username, "success", strings.Join(grantStrings(u.Grants), " "))
writeJSON(w, http.StatusCreated, presentUser(u))
return nil
}
func (s *Server) userByPath(r *http.Request) (*model.User, error) {
id, err := pathID(r, "id")
if err != nil {
return nil, err
}
u, err := s.store.GetUser(r.Context(), id)
if errors.Is(err, store.ErrNotFound) {
return nil, notFound("user %d not found", id)
}
return u, err
}
func (s *Server) updateUser(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
if err := requireAdmin(p); err != nil {
return err
}
u, err := s.userByPath(r)
if err != nil {
return err
}
var in api.UserInput
if err := decodeJSON(r, &in); err != nil {
return err
}
wasAdmin := u.Admin && !u.Disabled
if in.Username != nil {
u.Username = strings.TrimSpace(*in.Username)
if err := ValidUsername(u.Username); err != nil {
return err
}
}
if err := applyUserInput(u, &in); err != nil {
return err
}
if wasAdmin && (!u.Admin || u.Disabled) {
if n, err := s.store.CountAdmins(r.Context()); err == nil && n <= 1 {
return conflict("%s is the last administrator; make someone else an administrator first", u.Username)
}
}
if err := s.store.UpdateUser(r.Context(), u); err != nil {
if errors.Is(err, store.ErrConflict) {
return conflict("user %s already exists", u.Username)
}
return err
}
if u.Disabled || in.Password != nil {
s.store.DeleteUserSessions(r.Context(), u.ID, "")
}
s.eng.Audit(r.Context(), p, "user.update", "user "+u.Username, "success", strings.Join(grantStrings(u.Grants), " "))
writeJSON(w, http.StatusOK, presentUser(u))
return nil
}
func (s *Server) deleteUser(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
if err := requireAdmin(p); err != nil {
return err
}
u, err := s.userByPath(r)
if err != nil {
return err
}
if u.ID == p.User.ID {
return conflict("you can't delete yourself")
}
if u.Admin && !u.Disabled {
if n, err := s.store.CountAdmins(r.Context()); err == nil && n <= 1 {
return conflict("%s is the last administrator", u.Username)
}
}
if err := s.store.DeleteUser(r.Context(), u.ID); err != nil {
return err
}
s.eng.Audit(r.Context(), p, "user.delete", "user "+u.Username, "success", "")
w.WriteHeader(http.StatusNoContent)
return nil
}
func (s *Server) listUserTokens(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
u, err := s.userByPath(r)
if err != nil {
return err
}
if u.ID != p.User.ID && !p.IsAdmin() {
return forbidden("only administrators can see other users' tokens")
}
return s.listTokens(w, r, u.ID)
}
func (s *Server) createUserToken(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
u, err := s.userByPath(r)
if err != nil {
return err
}
if u.ID != p.User.ID && !p.IsAdmin() {
return forbidden("only administrators can make tokens for other users")
}
return s.createTokenFor(w, r, p, u)
}
+192
View File
@@ -0,0 +1,192 @@
package server
import (
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"strconv"
"strings"
"steward/internal/api"
"steward/internal/auth"
"steward/internal/engine"
"steward/internal/store"
)
// httpError is an error with an HTTP status.
type httpError struct {
status int
code string
msg string
}
func (e *httpError) Error() string { return e.msg }
func badRequest(format string, args ...any) error {
return &httpError{http.StatusBadRequest, "bad_request", fmt.Sprintf(format, args...)}
}
func notFound(format string, args ...any) error {
return &httpError{http.StatusNotFound, "not_found", fmt.Sprintf(format, args...)}
}
func forbidden(format string, args ...any) error {
return &httpError{http.StatusForbidden, "forbidden", fmt.Sprintf(format, args...)}
}
func conflict(format string, args ...any) error {
return &httpError{http.StatusConflict, "conflict", fmt.Sprintf(format, args...)}
}
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
w.WriteHeader(status)
enc := json.NewEncoder(w)
enc.SetEscapeHTML(false)
enc.Encode(v)
}
func writeError(w http.ResponseWriter, status int, code, msg string) {
writeJSON(w, status, api.ErrorBody{Error: api.ErrorInfo{Code: code, Message: msg}})
}
func engineStatus(kind string) int {
switch kind {
case engine.KindBadRequest:
return http.StatusBadRequest
case engine.KindNotFound:
return http.StatusNotFound
case engine.KindConflict:
return http.StatusConflict
case engine.KindValidation:
return http.StatusUnprocessableEntity
}
return http.StatusInternalServerError
}
// writeErr writes an error response. result, if given, is the preview of
// a refused change.
func (s *Server) writeErr(w http.ResponseWriter, r *http.Request, err error, result any) {
var he *httpError
if errors.As(err, &he) {
writeError(w, he.status, he.code, he.msg)
return
}
if ee, ok := engine.AsError(err); ok {
body := api.ErrorBody{Error: api.ErrorInfo{Code: ee.Kind, Message: ee.Message, Problems: ee.Problems}}
if result != nil {
body.Error.Result, _ = json.Marshal(result)
}
writeJSON(w, engineStatus(ee.Kind), body)
return
}
if errors.Is(err, store.ErrNotFound) {
writeError(w, http.StatusNotFound, "not_found", "not found")
return
}
if errors.Is(err, store.ErrConflict) {
writeError(w, http.StatusConflict, "conflict", "already exists")
return
}
s.log.Error("request failed", "method", r.Method, "path", r.URL.Path, "err", err)
writeError(w, http.StatusInternalServerError, "internal", "internal server error: "+err.Error())
}
// handler is an authenticated API handler.
type handler func(w http.ResponseWriter, r *http.Request, p *auth.Principal) error
// decodeJSON reads a JSON body into v, refusing unknown fields.
func decodeJSON(r *http.Request, v any) error {
ct := r.Header.Get("Content-Type")
if ct != "" && !strings.HasPrefix(ct, "application/json") {
return badRequest("expected a JSON body (Content-Type: application/json)")
}
dec := json.NewDecoder(r.Body)
dec.DisallowUnknownFields()
if err := dec.Decode(v); err != nil {
if errors.Is(err, io.EOF) {
return badRequest("the request body is empty")
}
var mbe *http.MaxBytesError
if errors.As(err, &mbe) {
return &httpError{http.StatusRequestEntityTooLarge, "too_large", "the request body is too large"}
}
return badRequest("invalid JSON: %v", err)
}
return nil
}
// decodeOptional is decodeJSON, but an empty body leaves v alone.
func decodeOptional(r *http.Request, v any) error {
b, err := io.ReadAll(r.Body)
if err != nil {
return err
}
if strings.TrimSpace(string(b)) == "" {
return nil
}
dec := json.NewDecoder(strings.NewReader(string(b)))
dec.DisallowUnknownFields()
if err := dec.Decode(v); err != nil {
return badRequest("invalid JSON: %v", err)
}
return nil
}
func pathID(r *http.Request, name string) (int64, error) {
v := r.PathValue(name)
id, err := strconv.ParseInt(v, 10, 64)
if err != nil || id <= 0 {
return 0, badRequest("invalid %s %q", name, v)
}
return id, nil
}
func queryBool(r *http.Request, name string) bool {
switch strings.ToLower(r.URL.Query().Get(name)) {
case "1", "true", "yes", "on":
return true
}
return false
}
func queryInt(r *http.Request, name string, def int) int {
if v := r.URL.Query().Get(name); v != "" {
if n, err := strconv.Atoi(v); err == nil {
return n
}
}
return def
}
func require(p *auth.Principal, resource string, write bool) error {
if p.Can(resource, write) {
return nil
}
access := "read"
if write {
access = "write"
}
return forbidden("permission denied: this needs %s:%s", resource, access)
}
func requireAdmin(p *auth.Principal) error {
if !p.IsAdmin() {
return forbidden("this needs an administrator (and not a restricted token)")
}
return nil
}
// via names where a request came from, for the history.
func via(r *http.Request, p *auth.Principal) string {
switch {
case strings.Contains(r.UserAgent(), "steward-cli"):
return "cli"
case p.Method == "session":
return "web"
}
return "api"
}
+119
View File
@@ -0,0 +1,119 @@
package server
import (
"bytes"
"crypto/sha256"
"encoding/hex"
"io/fs"
"net/http"
"strings"
"time"
"steward/internal/api"
"steward/internal/version"
)
func (s *Server) routes() {
m := s.mux
p := api.Prefix
m.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "version": version.String()})
})
// Authentication, users and tokens
m.HandleFunc("POST "+p+"/auth/login", s.public(s.login))
m.HandleFunc("POST "+p+"/auth/logout", s.authed(s.logout))
m.HandleFunc("GET "+p+"/auth/whoami", s.authed(s.whoami))
m.HandleFunc("POST "+p+"/me/password", s.authed(s.changeOwnPassword))
m.HandleFunc("GET "+p+"/me/tokens", s.authed(s.listOwnTokens))
m.HandleFunc("POST "+p+"/me/tokens", s.authed(s.createOwnToken))
m.HandleFunc("GET "+p+"/tokens", s.authed(s.listAllTokens))
m.HandleFunc("DELETE "+p+"/tokens/{id}", s.authed(s.revokeToken))
m.HandleFunc("GET "+p+"/users", s.authed(s.listUsers))
m.HandleFunc("POST "+p+"/users", s.authed(s.createUser))
m.HandleFunc("PATCH "+p+"/users/{id}", s.authed(s.updateUser))
m.HandleFunc("DELETE "+p+"/users/{id}", s.authed(s.deleteUser))
m.HandleFunc("GET "+p+"/users/{id}/tokens", s.authed(s.listUserTokens))
m.HandleFunc("POST "+p+"/users/{id}/tokens", s.authed(s.createUserToken))
// Configuration
m.HandleFunc("GET "+p+"/settings", s.authed(s.getSettings))
m.HandleFunc("PATCH "+p+"/settings", s.authed(s.updateSettings))
m.HandleFunc("GET "+p+"/subnets", s.authed(s.listSubnets))
m.HandleFunc("POST "+p+"/subnets", s.authed(s.createSubnet))
m.HandleFunc("GET "+p+"/subnets/{ref}", s.authed(s.getSubnet))
m.HandleFunc("PATCH "+p+"/subnets/{ref}", s.authed(s.updateSubnet))
m.HandleFunc("DELETE "+p+"/subnets/{ref}", s.authed(s.deleteSubnet))
m.HandleFunc("GET "+p+"/reservations", s.authed(s.listReservations))
m.HandleFunc("POST "+p+"/reservations", s.authed(s.createReservation))
m.HandleFunc("GET "+p+"/reservations/{ref}", s.authed(s.getReservation))
m.HandleFunc("PATCH "+p+"/reservations/{ref}", s.authed(s.updateReservation))
m.HandleFunc("DELETE "+p+"/reservations/{ref}", s.authed(s.deleteReservation))
m.HandleFunc("GET "+p+"/history", s.authed(s.listHistory))
m.HandleFunc("GET "+p+"/history/{id}", s.authed(s.getHistory))
m.HandleFunc("POST "+p+"/history/{id}/revert", s.authed(s.revertHistory))
m.HandleFunc("POST "+p+"/import/kea", s.authed(s.importKea))
m.HandleFunc("GET "+p+"/catalog", s.authed(s.catalog))
m.HandleFunc("GET "+p+"/interfaces", s.authed(s.interfaces))
// Leases and activity
m.HandleFunc("GET "+p+"/leases", s.authed(s.listLeases))
m.HandleFunc("GET "+p+"/leases/{ip}", s.authed(s.getLease))
m.HandleFunc("DELETE "+p+"/leases/{ip}", s.authed(s.deleteLease))
m.HandleFunc("POST "+p+"/leases/{ip}/reserve", s.authed(s.reserveLease))
m.HandleFunc("GET "+p+"/events", s.authed(s.listEvents))
// Status and audit
m.HandleFunc("GET "+p+"/status", s.authed(s.status))
m.HandleFunc("GET "+p+"/audit", s.authed(s.listAudit))
m.HandleFunc(p+"/", func(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusNotFound, "not_found", "no such API endpoint: "+r.Method+" "+r.URL.Path)
})
// Web UI
if s.web != nil {
files := http.FileServer(http.FS(s.web))
etags := contentETags(s.web)
m.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet && r.Method != http.MethodHead {
w.Header().Set("Allow", "GET, HEAD")
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
if r.URL.Path == "/" || r.URL.Path == "/index.html" {
b, err := fs.ReadFile(s.web, "index.html")
if err != nil {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("ETag", etags["index.html"])
http.ServeContent(w, r, "index.html", time.Time{}, bytes.NewReader(b))
return
}
if strings.HasPrefix(r.URL.Path, "/assets/") {
w.Header().Set("Cache-Control", "no-cache")
if tag, ok := etags[strings.TrimPrefix(r.URL.Path, "/")]; ok {
w.Header().Set("ETag", tag)
}
files.ServeHTTP(w, r)
return
}
http.NotFound(w, r)
})
}
}
// contentETags gives every embedded file an ETag from its content.
func contentETags(fsys fs.FS) map[string]string {
tags := map[string]string{}
fs.WalkDir(fsys, ".", func(path string, d fs.DirEntry, err error) error {
if err != nil || d.IsDir() {
return nil
}
b, err := fs.ReadFile(fsys, path)
if err != nil {
return nil
}
sum := sha256.Sum256(b)
tags[path] = `"` + hex.EncodeToString(sum[:12]) + `"`
return nil
})
return tags
}
+429
View File
@@ -0,0 +1,429 @@
// Package server is Steward's HTTP API and serves the web UI.
package server
import (
"context"
"crypto/tls"
"errors"
"fmt"
"io/fs"
"log/slog"
"net"
"net/http"
"net/netip"
"os"
"strconv"
"strings"
"sync"
"syscall"
"time"
"steward/internal/auth"
"steward/internal/dhcpd"
"steward/internal/engine"
"steward/internal/store"
"steward/internal/version"
)
// Config configures the HTTP server.
type Config struct {
Listen []string
TLSCertFile string
TLSKeyFile string
TrustedProxies []netip.Prefix
SessionLifetime time.Duration
SessionIdle time.Duration
// CookieSecure is auto (secure over HTTPS), always or never.
CookieSecure string
MaxBodyBytes int64
// Socket is the local unix socket ("" for none).
Socket string
// PingProblem says why the ping check can't run (nil: always fine).
PingProblem func() string
}
// Interfaces lists the host's interfaces.
type Interfaces interface {
All() (map[string][]netip.Prefix, error)
}
// Server is the HTTP server.
type Server struct {
cfg Config
eng *engine.Engine
dhcp *dhcpd.Server
ifaces Interfaces
store *store.Store
log *slog.Logger
mux *http.ServeMux
limiter *rateLimiter
web fs.FS
started time.Time
certs *certReloader
mu sync.Mutex
srvs []*http.Server
}
// New makes a server. web holds the web UI (nil: API only).
func New(cfg Config, eng *engine.Engine, dhcp *dhcpd.Server, ifaces Interfaces, log *slog.Logger, web fs.FS) *Server {
if cfg.SessionLifetime == 0 {
cfg.SessionLifetime = 12 * time.Hour
}
if cfg.SessionIdle == 0 {
cfg.SessionIdle = 2 * time.Hour
}
if cfg.MaxBodyBytes == 0 {
cfg.MaxBodyBytes = 8 << 20
}
if cfg.CookieSecure == "" {
cfg.CookieSecure = "auto"
}
if log == nil {
log = slog.Default()
}
s := &Server{cfg: cfg, eng: eng, dhcp: dhcp, ifaces: ifaces, store: eng.Store(), log: log, mux: http.NewServeMux(), limiter: newRateLimiter(), web: web, started: time.Now()}
s.routes()
return s
}
// Handler returns the HTTP handler with all middleware.
func (s *Server) Handler() http.Handler {
return s.recoverer(s.securityHeaders(s.logRequests(s.mux)))
}
func lowPort(addr string) bool {
_, port, err := net.SplitHostPort(addr)
if err != nil {
return false
}
n, err := strconv.Atoi(port)
return err == nil && n > 0 && n < 1024
}
// Serve listens until ctx ends. ready is called once listening.
func (s *Server) Serve(ctx context.Context, ready func()) error {
if len(s.cfg.Listen) == 0 {
return errors.New("no listen addresses configured")
}
var tlsCfg *tls.Config
if s.cfg.TLSCertFile != "" {
cr, err := newCertReloader(s.cfg.TLSCertFile, s.cfg.TLSKeyFile, s.log)
if err != nil {
return err
}
s.certs = cr
tlsCfg = &tls.Config{MinVersion: tls.VersionTLS12, GetCertificate: cr.get, NextProtos: []string{"h2", "http/1.1"}}
}
errc := make(chan error, len(s.cfg.Listen)+1)
var listeners []net.Listener
for _, addr := range s.cfg.Listen {
ln, err := net.Listen("tcp", addr)
if err != nil {
for _, l := range listeners {
l.Close()
}
if errors.Is(err, syscall.EACCES) && lowPort(addr) {
return fmt.Errorf("listening on %s: %w (ports below 1024 need CAP_NET_BIND_SERVICE, which steward.service grants)", addr, err)
}
return fmt.Errorf("listening on %s: %w", addr, err)
}
listeners = append(listeners, ln)
}
for i, ln := range listeners {
srv := &http.Server{
Handler: s.Handler(),
ReadHeaderTimeout: 10 * time.Second,
ReadTimeout: 2 * time.Minute,
WriteTimeout: 5 * time.Minute,
IdleTimeout: 2 * time.Minute,
TLSConfig: tlsCfg,
ErrorLog: slog.NewLogLogger(s.log.Handler(), slog.LevelWarn),
}
s.mu.Lock()
s.srvs = append(s.srvs, srv)
s.mu.Unlock()
scheme := "http"
if tlsCfg != nil {
scheme = "https"
ln = tls.NewListener(ln, tlsCfg)
} else if host, _, err := net.SplitHostPort(s.cfg.Listen[i]); err == nil {
if ip, err := netip.ParseAddr(host); host == "" || err == nil && !ip.IsLoopback() {
s.log.Warn("serving plain HTTP on a non-loopback address: passwords and API tokens travel unencrypted; configure tls or a TLS reverse proxy", "addr", s.cfg.Listen[i])
}
}
s.log.Info("listening", "addr", s.cfg.Listen[i], "url", scheme+"://"+ln.Addr().String())
go func(srv *http.Server, ln net.Listener) {
if err := srv.Serve(ln); !errors.Is(err, http.ErrServerClosed) {
errc <- err
}
}(srv, ln)
}
s.serveSocket(errc)
if ready != nil {
ready()
}
go s.housekeeping(ctx)
select {
case <-ctx.Done():
case err := <-errc:
s.shutdown()
return err
}
s.shutdown()
return nil
}
func (s *Server) serveSocket(errc chan error) {
if s.cfg.Socket == "" {
return
}
ln, err := listenSocket(s.cfg.Socket)
if err != nil {
s.log.Warn("not listening on the local socket", "path", s.cfg.Socket, "err", err)
return
}
srv := &http.Server{
Handler: s.Handler(),
ConnContext: socketConnContext,
ReadHeaderTimeout: 10 * time.Second,
WriteTimeout: 5 * time.Minute,
IdleTimeout: 2 * time.Minute,
ErrorLog: slog.NewLogLogger(s.log.Handler(), slog.LevelWarn),
}
s.mu.Lock()
s.srvs = append(s.srvs, srv)
s.mu.Unlock()
s.log.Info("listening", "socket", s.cfg.Socket)
go func() {
if err := srv.Serve(ln); !errors.Is(err, http.ErrServerClosed) {
errc <- err
}
}()
}
func (s *Server) shutdown() {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
s.mu.Lock()
defer s.mu.Unlock()
for _, srv := range s.srvs {
srv.Shutdown(ctx)
}
if s.cfg.Socket != "" {
os.Remove(s.cfg.Socket)
}
}
func (s *Server) housekeeping(ctx context.Context) {
t := time.NewTicker(10 * time.Minute)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case <-t.C:
if err := s.store.PurgeSessions(ctx, time.Now()); err != nil {
s.log.Warn("purging sessions failed", "err", err)
}
s.limiter.cleanup()
if s.certs != nil {
s.certs.reloadIfChanged()
}
}
}
}
// ReloadTLS rereads the certificate (on SIGHUP).
func (s *Server) ReloadTLS() {
if s.certs != nil {
s.certs.reload()
}
}
// certReloader serves a certificate that is reread when its file changes.
type certReloader struct {
certFile, keyFile string
log *slog.Logger
mu sync.RWMutex
cert *tls.Certificate
modTime time.Time
}
func newCertReloader(certFile, keyFile string, log *slog.Logger) (*certReloader, error) {
cr := &certReloader{certFile: certFile, keyFile: keyFile, log: log}
if err := cr.load(); err != nil {
return nil, err
}
return cr, nil
}
func (cr *certReloader) load() error {
cert, err := tls.LoadX509KeyPair(cr.certFile, cr.keyFile)
if err != nil {
return fmt.Errorf("loading the TLS certificate: %w", err)
}
fi, _ := os.Stat(cr.certFile)
cr.mu.Lock()
cr.cert = &cert
if fi != nil {
cr.modTime = fi.ModTime()
}
cr.mu.Unlock()
return nil
}
func (cr *certReloader) reload() {
if err := cr.load(); err != nil {
cr.log.Error("reloading the TLS certificate failed", "err", err)
return
}
cr.log.Info("TLS certificate reloaded", "file", cr.certFile)
}
func (cr *certReloader) reloadIfChanged() {
fi, err := os.Stat(cr.certFile)
if err != nil {
return
}
cr.mu.RLock()
changed := fi.ModTime().After(cr.modTime)
cr.mu.RUnlock()
if changed {
cr.reload()
}
}
func (cr *certReloader) get(*tls.ClientHelloInfo) (*tls.Certificate, error) {
cr.mu.RLock()
defer cr.mu.RUnlock()
return cr.cert, nil
}
// ---- middleware
type ctxKey int
const (
keyPrincipal ctxKey = iota
keySession
)
func (s *Server) recoverer(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if v := recover(); v != nil {
if v == http.ErrAbortHandler {
panic(v)
}
s.log.Error("panic serving a request", "path", r.URL.Path, "panic", v)
writeError(w, http.StatusInternalServerError, "internal", "internal server error")
}
}()
next.ServeHTTP(w, r)
})
}
func (s *Server) securityHeaders(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
h := w.Header()
h.Set("Server", "Steward/"+version.Short())
h.Set("X-Content-Type-Options", "nosniff")
h.Set("X-Frame-Options", "DENY")
h.Set("Referrer-Policy", "no-referrer")
h.Set("Content-Security-Policy", "default-src 'self'; img-src 'self' data:; style-src 'self'; script-src 'self'; connect-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'")
h.Set("Cross-Origin-Opener-Policy", "same-origin")
if s.isHTTPS(r) {
h.Set("Strict-Transport-Security", "max-age=31536000")
}
next.ServeHTTP(w, r)
})
}
type statusWriter struct {
http.ResponseWriter
status int
}
func (w *statusWriter) WriteHeader(code int) {
w.status = code
w.ResponseWriter.WriteHeader(code)
}
func (w *statusWriter) Unwrap() http.ResponseWriter { return w.ResponseWriter }
func (s *Server) logRequests(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
sw := &statusWriter{ResponseWriter: w, status: 200}
next.ServeHTTP(sw, r)
if r.URL.Path == "/healthz" || strings.HasPrefix(r.URL.Path, "/assets/") {
return
}
user := ""
if p, ok := r.Context().Value(keyPrincipal).(*auth.Principal); ok {
user = p.Name()
}
s.log.Debug("http", "method", r.Method, "path", r.URL.Path, "status", sw.status, "duration", time.Since(start).Round(time.Millisecond), "ip", s.clientIP(r), "user", user)
})
}
// clientIP is the client's address, from X-Forwarded-For when the request
// came through a trusted proxy.
func (s *Server) clientIP(r *http.Request) string {
if peerOf(r.Context()) != nil {
return "local"
}
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
host = r.RemoteAddr
}
peer, err := netip.ParseAddr(host)
if err != nil || !s.trusted(peer) {
return host
}
parts := strings.Split(r.Header.Get("X-Forwarded-For"), ",")
for i := len(parts) - 1; i >= 0; i-- {
a, err := netip.ParseAddr(strings.TrimSpace(parts[i]))
if err != nil {
return host
}
if !s.trusted(a) {
return a.String()
}
}
return host
}
func (s *Server) trusted(a netip.Addr) bool {
a = a.Unmap()
for _, p := range s.cfg.TrustedProxies {
if p.Contains(a) {
return true
}
}
return false
}
// isHTTPS reports whether the request reached Steward over HTTPS: TLS to
// stewardd, the local socket, or a trusted proxy that says so (the last
// X-Forwarded-Proto value).
func (s *Server) isHTTPS(r *http.Request) bool {
if r.TLS != nil || peerOf(r.Context()) != nil {
return true
}
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
return false
}
peer, err := netip.ParseAddr(host)
if err != nil || !s.trusted(peer) {
return false
}
vals := r.Header.Values("X-Forwarded-Proto")
if len(vals) == 0 {
return false
}
parts := strings.Split(strings.Join(vals, ","), ",")
return strings.EqualFold(strings.TrimSpace(parts[len(parts)-1]), "https")
}
+337
View File
@@ -0,0 +1,337 @@
package server
import (
"bytes"
"context"
"encoding/json"
"errors"
"io"
"net"
"net/http"
"net/http/cookiejar"
"net/http/httptest"
"net/netip"
"strings"
"testing"
"steward/internal/api"
"steward/internal/auth"
"steward/internal/dhcp4"
"steward/internal/dhcpd"
"steward/internal/engine"
"steward/internal/model"
"steward/internal/store"
)
type hostIfaces map[string][]netip.Prefix
func (h hostIfaces) Addrs(name string) ([]netip.Prefix, error) {
a, ok := h[name]
if !ok {
return nil, errors.New("no interface " + name)
}
return a, nil
}
func (h hostIfaces) All() (map[string][]netip.Prefix, error) { return h, nil }
type env struct {
t *testing.T
ts *httptest.Server
srv *Server
dhcp *dhcpd.Server
eng *engine.Engine
token string // the admin's API token
}
func newEnv(t *testing.T) *env {
t.Helper()
ctx := context.Background()
st, err := store.Open(":memory:")
if err != nil {
t.Fatal(err)
}
hash, _ := auth.HashPassword("secret-pass-1")
admin := &model.User{Username: "admin", PasswordHash: hash, Admin: true}
if err := st.CreateUser(ctx, admin); err != nil {
t.Fatal(err)
}
tok, pid, sh := auth.NewToken()
st.CreateToken(ctx, &model.Token{UserID: admin.ID, Name: "test", PublicID: pid, SecretHash: sh})
eng, err := engine.New(ctx, st, nil)
if err != nil {
t.Fatal(err)
}
ifs := hostIfaces{"eth0": {netip.MustParsePrefix("10.0.10.2/24")}, "eth0.20": {netip.MustParsePrefix("10.0.20.1/24")}}
d := dhcpd.New(nil, st, ifs, nil)
eng.OnChange = d.SetConfig
srv := New(Config{}, eng, d, ifs, nil, nil)
ts := httptest.NewServer(srv.Handler())
t.Cleanup(ts.Close)
return &env{t: t, ts: ts, srv: srv, dhcp: d, eng: eng, token: tok}
}
// call makes a request with a token; it returns the status and decodes
// the body into out (if given).
func (e *env) call(token, method, path string, body any, out any) int {
e.t.Helper()
var rd io.Reader
if body != nil {
b, _ := json.Marshal(body)
rd = bytes.NewReader(b)
}
req, _ := http.NewRequest(method, e.ts.URL+api.Prefix+path, rd)
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
e.t.Fatal(err)
}
defer resp.Body.Close()
b, _ := io.ReadAll(resp.Body)
if out != nil && len(b) > 0 {
if err := json.Unmarshal(b, out); err != nil {
e.t.Fatalf("%s %s: %v: %s", method, path, err, b)
}
}
return resp.StatusCode
}
func ptr[T any](v T) *T { return &v }
func TestLoginSessionAndCSRF(t *testing.T) {
e := newEnv(t)
jar, _ := cookiejar.New(nil)
c := &http.Client{Jar: jar}
post := func(path, body, csrf string) *http.Response {
req, _ := http.NewRequest("POST", e.ts.URL+api.Prefix+path, strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
if csrf != "" {
req.Header.Set("X-CSRF-Token", csrf)
}
resp, err := c.Do(req)
if err != nil {
t.Fatal(err)
}
return resp
}
if r := post("/auth/login", `{"username":"admin","password":"wrong"}`, ""); r.StatusCode != 401 {
t.Errorf("wrong password: %d", r.StatusCode)
}
r := post("/auth/login", `{"username":"admin","password":"secret-pass-1"}`, "")
var me api.Whoami
json.NewDecoder(r.Body).Decode(&me)
if r.StatusCode != 200 || me.CSRFToken == "" || !me.Admin {
t.Fatalf("login: %d %+v", r.StatusCode, me)
}
body := `{"prefix":"10.0.10.0/24","interface":"eth0","pools":["10.0.10.100-10.0.10.199"]}`
if r := post("/subnets", body, ""); r.StatusCode != 403 {
t.Errorf("change without CSRF token: %d", r.StatusCode)
}
if r := post("/subnets", body, me.CSRFToken); r.StatusCode != 201 {
b, _ := io.ReadAll(r.Body)
t.Errorf("change with CSRF token: %d %s", r.StatusCode, b)
}
h, _ := e.srv.store.ListHistory(context.Background(), 1, 0)
if len(h) != 1 || h[0].Via != "web" || h[0].Username != "admin" {
t.Errorf("history %+v", h)
}
}
func TestConfigFlow(t *testing.T) {
e := newEnv(t)
var mr api.MutationResponse
// A dry run changes nothing.
sub := api.SubnetInput{Name: ptr("office"), Prefix: ptr("10.0.10.0/24"), Interface: ptr("eth0"), Pools: &[]string{"10.0.10.100-10.0.10.199"},
Options: &[]api.OptionInput{{Option: "routers", Value: "10.0.10.1"}, {Option: "domain-name-servers", Value: "10.0.10.2,10.0.10.3"}}}
if code := e.call(e.token, "POST", "/subnets?dry_run=1", sub, &mr); code != 200 || mr.Applied || len(mr.Changes) != 1 {
t.Fatalf("dry run: %d %+v", code, mr)
}
if code := e.call(e.token, "POST", "/subnets", sub, &mr); code != 201 || !mr.Applied {
t.Fatalf("create: %d %+v", code, mr)
}
var sn model.Subnet
json.Unmarshal(mr.Object, &sn)
if sn.ID == 0 || sn.Options[1].Value != "10.0.10.2, 10.0.10.3" {
t.Errorf("created %+v", sn)
}
// An invalid change is refused with the problem.
var eb api.ErrorBody
bad := api.SubnetInput{Prefix: ptr("10.0.0.0/16"), Interface: ptr("eth1")}
if code := e.call(e.token, "POST", "/subnets", bad, &eb); code != 422 || eb.Error.Problems[0].Code != "subnet_overlap" {
t.Errorf("overlap: %d %+v", code, eb)
}
if code := e.call(e.token, "POST", "/subnets", api.SubnetInput{Prefix: ptr("10.0.30.0/24"), Interface: ptr("eth0"), Options: &[]api.OptionInput{{Option: "dhcp-lease-time", Value: "1"}}}, &eb); code != 400 || !strings.Contains(eb.Error.Message, "unknown option") {
t.Errorf("unknown option: %d %+v", code, eb)
}
if code := e.call(e.token, "POST", "/subnets", api.SubnetInput{Prefix: ptr("10.0.30.0/24"), Interface: ptr("eth0"), Options: &[]api.OptionInput{{Option: "51", Value: "1"}}}, &eb); code != 400 || !strings.Contains(eb.Error.Message, "set the lease time instead") {
t.Errorf("reserved option: %d %+v", code, eb)
}
// Reservations; by name, address or MAC.
res := api.ReservationInput{MAC: ptr("AA-BB-CC-00-00-01"), IP: ptr("10.0.10.20"), Hostname: ptr("nas")}
if code := e.call(e.token, "POST", "/reservations", res, &mr); code != 201 {
t.Fatalf("reservation: %d %+v", code, mr)
}
var rv api.Reservation
if code := e.call(e.token, "GET", "/reservations/aa:bb:cc:00:00:01", nil, &rv); code != 200 || rv.IP.String() != "10.0.10.20" || rv.SubnetName != "office" {
t.Errorf("get reservation: %d %+v", code, rv)
}
if code := e.call(e.token, "PATCH", "/subnets/office", api.SubnetInput{LeaseTime: ptr(3600)}, &mr); code != 200 || !mr.Applied {
t.Errorf("update by name: %d %+v", code, mr)
}
var subs []api.Subnet
e.call(e.token, "GET", "/subnets", nil, &subs)
if len(subs) != 1 || subs[0].LeaseTime != 3600 || subs[0].Usage.PoolSize != 100 || subs[0].Usage.Reservations != 1 {
t.Errorf("subnets %+v", subs[0])
}
// Undo the lease time change.
var hs []model.HistoryEntry
e.call(e.token, "GET", "/history", nil, &hs)
var rr api.MutationResponse
if code := e.call(e.token, "POST", "/history/"+itoa(hs[0].ID)+"/revert", nil, &rr); code != 200 || !rr.Applied || rr.DryRun {
t.Fatalf("revert: %d %+v", code, rr)
}
if e.eng.State().Subnets[0].LeaseTime != 0 {
t.Error("revert didn't undo the lease time")
}
// Deleting the subnet takes its reservation along.
var dr api.MutationResponse
if code := e.call(e.token, "DELETE", "/subnets/10.0.10.0_24", nil, &dr); code != 200 || len(dr.Changes) != 2 {
t.Errorf("delete: %d %+v", code, dr)
}
}
func itoa(n int64) string { b, _ := json.Marshal(n); return string(b) }
func TestLeasesThroughDHCP(t *testing.T) {
e := newEnv(t)
var mr api.MutationResponse
e.call(e.token, "POST", "/subnets", api.SubnetInput{Name: ptr("iot"), Prefix: ptr("10.0.20.0/24"), Interface: ptr("eth0.20"), Pools: &[]string{"10.0.20.100-10.0.20.110"}}, &mr)
// A client gets an address.
mac := net.HardwareAddr{0xaa, 0, 0, 0, 0, 1}
disc := dhcp4.NewRequest(dhcp4.Discover, mac, 1)
off := e.dhcp.Handle(context.Background(), "eth0.20", disc)
if off == nil {
t.Fatal("no offer")
}
req := dhcp4.NewRequest(dhcp4.Request, mac, 2)
req.Set(dhcp4.OptServerID, dhcp4.IP(netip.MustParseAddr("10.0.20.1")))
req.Set(dhcp4.OptRequestedIP, dhcp4.IP(off.Packet.YIAddr))
req.Set(dhcp4.OptHostName, []byte("sensor"))
if ack := e.dhcp.Handle(context.Background(), "eth0.20", req); ack == nil || ack.Packet.Type() != dhcp4.Ack {
t.Fatal("no ack")
}
var leases []api.Lease
if code := e.call(e.token, "GET", "/leases?q=sensor", nil, &leases); code != 200 || len(leases) != 1 || leases[0].SubnetName != "iot" || leases[0].State != "active" {
t.Fatalf("leases: %d %+v", code, leases)
}
ip := leases[0].IP.String()
// Make it a reservation.
if code := e.call(e.token, "POST", "/leases/"+ip+"/reserve", nil, &mr); code != 201 || !mr.Applied {
t.Fatalf("reserve: %d %+v", code, mr)
}
var rv api.Reservation
json.Unmarshal(mr.Object, &rv)
if rv.MAC != mac.String() || rv.Hostname != "sensor" || rv.Lease == nil {
t.Errorf("reservation from lease %+v", rv)
}
var st api.Status
if code := e.call(e.token, "GET", "/status", nil, &st); code != 200 || st.Leases["active"] != 1 || st.Subnets[0].Usage.ReservationsLeased != 1 || st.Messages["DHCPACK"] != 1 {
t.Errorf("status: %d %+v", code, st)
}
var evs []api.Event
e.call(e.token, "GET", "/events?mac="+mac.String(), nil, &evs)
if len(evs) != 4 || evs[0].Type != "DHCPACK" {
t.Errorf("events %+v", evs)
}
if code := e.call(e.token, "DELETE", "/leases/"+ip, nil, nil); code != 204 {
t.Errorf("delete lease: %d", code)
}
if code := e.call(e.token, "DELETE", "/leases/"+ip, nil, nil); code != 404 {
t.Errorf("delete again: %d", code)
}
var audit []model.AuditEvent
e.call(e.token, "GET", "/audit?action=lease.", nil, &audit)
if len(audit) != 1 || audit[0].Object != "lease "+ip {
t.Errorf("audit %+v", audit)
}
}
func TestPermissions(t *testing.T) {
e := newEnv(t)
var tok api.Token
if code := e.call(e.token, "POST", "/me/tokens", api.TokenInput{Name: "monitor", Role: "leases"}, &tok); code != 201 || tok.Secret == "" {
t.Fatalf("token: %d %+v", code, tok)
}
if code := e.call(tok.Secret, "GET", "/leases", nil, nil); code != 200 {
t.Errorf("leases with a leases:read token: %d", code)
}
var eb api.ErrorBody
if code := e.call(tok.Secret, "POST", "/subnets", api.SubnetInput{Prefix: ptr("10.0.10.0/24"), Interface: ptr("eth0")}, &eb); code != 403 || !strings.Contains(eb.Error.Message, "config:write") {
t.Errorf("change with a read token: %d %+v", code, eb)
}
if code := e.call(tok.Secret, "GET", "/users", nil, nil); code != 403 {
t.Errorf("users with a restricted token: %d", code)
}
// A restricted token can't make a wider one.
if code := e.call(tok.Secret, "POST", "/me/tokens", api.TokenInput{Name: "wider", Role: "operator"}, nil); code != 403 {
t.Errorf("wider token: %d", code)
}
if code := e.call("", "GET", "/status", nil, nil); code != 401 {
t.Errorf("no token: %d", code)
}
if code := e.call("steward_aaaaaaaaaaaaaaaa_bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", "GET", "/status", nil, nil); code != 401 {
t.Errorf("bad token: %d", code)
}
// The last administrator stays.
var users []api.User
e.call(e.token, "GET", "/users", nil, &users)
if code := e.call(e.token, "PATCH", "/users/"+itoa(users[0].ID), api.UserInput{Admin: ptr(false)}, &eb); code != 409 {
t.Errorf("demoting the last admin: %d", code)
}
}
func TestImportKea(t *testing.T) {
e := newEnv(t)
cfg := `{"Dhcp4": {
"interfaces-config": {"interfaces": ["eth0", "eth0.20"]},
"subnet4": [{"subnet": "10.0.10.0/24", "pools": [{"pool": "10.0.10.100 - 10.0.10.199"}],
"option-data": [{"name": "routers", "data": "10.0.10.1"}],
"reservations": [{"hw-address": "aa:bb:cc:00:00:01", "ip-address": "10.0.10.20", "hostname": "nas"}]},
{"subnet": "172.16.0.0/24", "pools": [{"pool": "172.16.0.10-172.16.0.20"}]}],
"hooks-libraries": []}}`
leases := "address,hwaddr,client_id,valid_lifetime,expire,subnet_id,fqdn_fwd,fqdn_rev,hostname,state,user_context\n" +
"10.0.10.150,aa:bb:cc:00:00:09,,43200,4000000000,1,0,0,tv,0,\n10.9.9.9,aa:bb:cc:00:00:0a,,43200,4000000000,9,0,0,x,0,\n"
var eb api.ErrorBody
if code := e.call(e.token, "POST", "/import/kea", KeaImport{Config: cfg, Leases: []string{leases}}, &eb); code != 400 || !strings.Contains(eb.Error.Message, "172.16.0.0/24") {
t.Fatalf("subnet without an interface: %d %+v", code, eb)
}
in := KeaImport{Config: cfg, Leases: []string{leases}, Interfaces: map[string]string{"172.16.0.0/24": "eth0.20"}}
var ir api.ImportResult
if code := e.call(e.token, "POST", "/import/kea", in, &ir); code != 200 || !ir.Applied || ir.Leases != 1 || len(ir.Changes) != 3 {
t.Fatalf("import: %d %+v", code, ir)
}
if !strings.Contains(strings.Join(ir.Skipped, " "), "hooks-libraries") || !strings.Contains(strings.Join(ir.Notes, " "), "outside the imported subnets") {
t.Errorf("notes %v skipped %v", ir.Notes, ir.Skipped)
}
if l := e.dhcp.Lease(netip.MustParseAddr("10.0.10.150")); l == nil || l.Hostname != "tv" || l.Interface != "eth0" {
t.Errorf("imported lease %+v", l)
}
// Importing again needs replace.
if code := e.call(e.token, "POST", "/import/kea", in, &eb); code != 409 {
t.Errorf("second import: %d", code)
}
in.Replace = true
var again api.ImportResult
if code := e.call(e.token, "POST", "/import/kea?dry_run=1", in, &again); code != 200 || again.Applied || len(again.Changes) != 0 {
t.Errorf("the same import again changes something: %d %+v", code, again)
}
// A name given in Steward survives a new import.
e.call(e.token, "PATCH", "/subnets/10.0.10.0_24", api.SubnetInput{Name: ptr("office")}, nil)
var third api.ImportResult
if code := e.call(e.token, "POST", "/import/kea", in, &third); code != 200 || len(third.Changes) != 0 || e.eng.State().FindSubnet("office") == nil {
t.Errorf("import over a named subnet: %d %+v", code, third)
}
}
+130
View File
@@ -0,0 +1,130 @@
package server
// The local unix socket (socket in steward.yaml, /run/steward/steward.sock by
// default). It serves the same API as the network listeners, but a request
// on it without an Authorization header is trusted by who sent it: the
// kernel reports the connecting process's user (SO_PEERCRED), and root and
// stewardd's own user get administrator rights without a token. Both can
// already do everything (read the database, run 'stewardd user', stop the
// service), so this adds convenience, not power: 'sudo steward status'
// works on the server with nothing configured, and a lost token or
// password never locks out root.
//
// Anyone else who can reach the socket must send an API token, which then
// gives that token's permissions, as on the network. The socket file is
// mode 0600 in a 0750 directory, so normally only root and the steward user
// can connect at all; the user check doesn't rely on that.
import (
"context"
"fmt"
"io/fs"
"net"
"net/http"
"os"
"os/user"
"path/filepath"
"strconv"
"syscall"
"steward/internal/auth"
"steward/internal/model"
)
// peer is the process at the other end of a unix socket connection.
type peer struct {
UID int
PID int
}
type peerKey struct{}
// peerOf returns the socket peer of a request, or nil for network
// requests.
func peerOf(ctx context.Context) *peer {
p, _ := ctx.Value(peerKey{}).(*peer)
return p
}
// socketConnContext records the peer's credentials on each connection to
// the unix socket.
func socketConnContext(ctx context.Context, c net.Conn) context.Context {
uc, ok := c.(*net.UnixConn)
if !ok {
return ctx
}
p := &peer{UID: -1}
if raw, err := uc.SyscallConn(); err == nil {
raw.Control(func(fd uintptr) {
if cred, err := syscall.GetsockoptUcred(int(fd), syscall.SOL_SOCKET, syscall.SO_PEERCRED); err == nil {
p.UID, p.PID = int(cred.Uid), int(cred.Pid)
}
})
}
// Even when the credentials can't be read, the request is marked as a
// socket request (with no trusted user).
return context.WithValue(ctx, peerKey{}, p)
}
// trustedUID reports whether a socket peer gets administrator rights
// without a token: root and the user stewardd runs as.
func trustedUID(uid int) bool {
return uid == 0 || uid == os.Getuid()
}
// socketPrincipal is the administrator for a trusted socket peer. It isn't
// a Steward user (ID 0), so it has no tokens or password; the audit log and
// history show it by its system user name, e.g. "root", with method
// "socket".
func socketPrincipal(p *peer, agent string) *auth.Principal {
name := "uid " + strconv.Itoa(p.UID)
if u, err := user.LookupId(strconv.Itoa(p.UID)); err == nil {
name = u.Username
}
return &auth.Principal{User: &model.User{ID: 0, Username: name, Admin: true}, Method: "socket", IP: "local", Agent: agent}
}
// listenSocket creates the unix socket, replacing a stale one left by a
// previous run.
func listenSocket(path string) (net.Listener, error) {
if len(path) > 107 {
return nil, fmt.Errorf("the path is %d bytes long; unix sockets allow at most 107", len(path))
}
// Under systemd the directory is RuntimeDirectory=steward; elsewhere
// make it.
if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil {
return nil, err
}
if fi, err := os.Lstat(path); err == nil {
if fi.Mode()&fs.ModeSocket == 0 {
return nil, fmt.Errorf("%s exists and isn't a socket", path)
}
// A socket nobody answers on is left over from a previous run.
if c, err := net.Dial("unix", path); err == nil {
c.Close()
return nil, fmt.Errorf("%s is in use by another stewardd", path)
}
os.Remove(path)
}
ln, err := net.Listen("unix", path)
if err != nil {
return nil, err
}
if err := os.Chmod(path, 0o600); err != nil {
ln.Close()
return nil, err
}
return ln, nil
}
// socketDenied explains a refused tokenless request on the socket.
func socketDenied(p *peer) error {
who := "an unknown user"
if p.UID >= 0 {
who = "uid " + strconv.Itoa(p.UID)
if u, err := user.LookupId(strconv.Itoa(p.UID)); err == nil {
who = u.Username
}
}
return &httpError{http.StatusUnauthorized, "unauthenticated", "the local socket gives administrator rights without a token only to root and stewardd's own user, not to " + who + "; send an API token (--token), or run the command as root"}
}
+65
View File
@@ -0,0 +1,65 @@
package store
import (
"context"
"database/sql"
"encoding/json"
"errors"
"steward/internal/model"
)
func scanHistory(sc interface{ Scan(...any) error }) (*model.HistoryEntry, error) {
e := &model.HistoryEntry{}
var t, changes string
var uid, revertOf, revertedBy sql.NullInt64
if err := sc.Scan(&e.ID, &t, &uid, &e.Username, &e.Via, &e.Description, &changes, &revertOf, &revertedBy); err != nil {
return nil, err
}
e.Time = parseTS(t)
e.UserID, e.RevertOf, e.RevertedBy = uid.Int64, revertOf.Int64, revertedBy.Int64
if err := json.Unmarshal([]byte(changes), &e.Changes); err != nil {
return nil, err
}
return e, nil
}
const historyCols = `id, time, user_id, username, via, description, changes, revert_of, reverted_by`
// ListHistory lists entries, newest first; before > 0 pages back.
func (s *Store) ListHistory(ctx context.Context, limit int, before int64) ([]*model.HistoryEntry, error) {
if limit <= 0 || limit > 500 {
limit = 100
}
q := `SELECT ` + historyCols + ` FROM history`
var args []any
if before > 0 {
q += ` WHERE id < ?`
args = append(args, before)
}
q += ` ORDER BY id DESC LIMIT ?`
args = append(args, limit)
rows, err := s.db.QueryContext(ctx, q, args...)
if err != nil {
return nil, err
}
defer rows.Close()
out := []*model.HistoryEntry{}
for rows.Next() {
e, err := scanHistory(rows)
if err != nil {
return nil, err
}
out = append(out, e)
}
return out, rows.Err()
}
// GetHistory loads one entry.
func (s *Store) GetHistory(ctx context.Context, id int64) (*model.HistoryEntry, error) {
e, err := scanHistory(s.db.QueryRowContext(ctx, `SELECT `+historyCols+` FROM history WHERE id = ?`, id))
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
return e, err
}
+79
View File
@@ -0,0 +1,79 @@
package store
import (
"context"
"net/netip"
"time"
"steward/internal/model"
)
const leaseCols = `ip, subnet_id, mac, client_id, hostname, state, start, end, interface, updated`
// LoadLeases reads every lease.
func (s *Store) LoadLeases(ctx context.Context) ([]*model.Lease, error) {
rows, err := s.db.QueryContext(ctx, `SELECT `+leaseCols+` FROM leases`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []*model.Lease
for rows.Next() {
l := &model.Lease{}
var ip, start, end, updated string
if err := rows.Scan(&ip, &l.SubnetID, &l.MAC, &l.ClientID, &l.Hostname, &l.State, &start, &end, &l.Interface, &updated); err != nil {
return nil, err
}
l.IP, _ = netip.ParseAddr(ip)
l.Start, l.End, l.Updated = parseTS(start), parseTS(end), parseTS(updated)
out = append(out, l)
}
return out, rows.Err()
}
// PutLease writes a lease (replacing the address's previous one). It
// returns once the write is durable enough that a crash doesn't lose it:
// the DHCP server answers the client only afterwards.
func (s *Store) PutLease(ctx context.Context, l *model.Lease) error {
return s.PutLeases(ctx, []*model.Lease{l})
}
// PutLeases writes several leases in one transaction (imports).
func (s *Store) PutLeases(ctx context.Context, ls []*model.Lease) error {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
for _, l := range ls {
if _, err := tx.ExecContext(ctx, `INSERT INTO leases (`+leaseCols+`) VALUES (?,?,?,?,?,?,?,?,?,?)
ON CONFLICT(ip) DO UPDATE SET subnet_id = excluded.subnet_id, mac = excluded.mac, client_id = excluded.client_id, hostname = excluded.hostname,
state = excluded.state, start = excluded.start, end = excluded.end, interface = excluded.interface, updated = excluded.updated`,
l.IP.String(), l.SubnetID, l.MAC, l.ClientID, l.Hostname, l.State, ts(l.Start), ts(l.End), l.Interface, ts(l.Updated)); err != nil {
return err
}
}
return tx.Commit()
}
// DeleteLease removes the lease of an address.
func (s *Store) DeleteLease(ctx context.Context, ip netip.Addr) error {
res, err := s.db.ExecContext(ctx, `DELETE FROM leases WHERE ip = ?`, ip.String())
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrNotFound
}
return nil
}
// PruneLeases deletes leases that ended before a time and aren't active
// (they are kept a while so returning clients get their old address).
func (s *Store) PruneLeases(ctx context.Context, before time.Time) (int64, error) {
res, err := s.db.ExecContext(ctx, `DELETE FROM leases WHERE end < ?`, ts(before))
if err != nil {
return 0, err
}
return res.RowsAffected()
}
+182
View File
@@ -0,0 +1,182 @@
package store
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"net/netip"
"steward/internal/model"
)
const settingsKey = "settings"
// LoadState reads the whole configuration.
func (s *Store) LoadState(ctx context.Context) (*model.State, error) {
st := &model.State{Settings: model.DefaultSettings(), Subnets: []*model.Subnet{}, Reservations: []*model.Reservation{}}
if v, err := s.GetMeta(ctx, settingsKey); err != nil {
return nil, err
} else if v != "" {
if err := json.Unmarshal([]byte(v), &st.Settings); err != nil {
return nil, fmt.Errorf("reading settings: %w", err)
}
}
if st.Settings.Options == nil {
st.Settings.Options = []model.OptionValue{}
}
rows, err := s.db.QueryContext(ctx, `SELECT id, name, interface, prefix, pools, lease_time, options, disabled, comment FROM subnets ORDER BY id`)
if err != nil {
return nil, err
}
defer rows.Close()
for rows.Next() {
sn := &model.Subnet{}
var prefix, pools, opts string
var disabled int
if err := rows.Scan(&sn.ID, &sn.Name, &sn.Interface, &prefix, &pools, &sn.LeaseTime, &opts, &disabled, &sn.Comment); err != nil {
return nil, err
}
sn.Prefix, _ = netip.ParsePrefix(prefix)
sn.Disabled = disabled != 0
if err := json.Unmarshal([]byte(pools), &sn.Pools); err != nil {
return nil, fmt.Errorf("subnet %d pools: %w", sn.ID, err)
}
if err := json.Unmarshal([]byte(opts), &sn.Options); err != nil {
return nil, fmt.Errorf("subnet %d options: %w", sn.ID, err)
}
st.Subnets = append(st.Subnets, sn)
}
if err := rows.Err(); err != nil {
return nil, err
}
rows, err = s.db.QueryContext(ctx, `SELECT id, mac, client_id, ip, hostname, options, comment FROM reservations ORDER BY id`)
if err != nil {
return nil, err
}
defer rows.Close()
for rows.Next() {
r := &model.Reservation{}
var ip, opts string
if err := rows.Scan(&r.ID, &r.MAC, &r.ClientID, &ip, &r.Hostname, &opts, &r.Comment); err != nil {
return nil, err
}
r.IP, _ = netip.ParseAddr(ip)
if err := json.Unmarshal([]byte(opts), &r.Options); err != nil {
return nil, fmt.Errorf("reservation %d options: %w", r.ID, err)
}
st.Reservations = append(st.Reservations, r)
}
if err := rows.Err(); err != nil {
return nil, err
}
for _, sn := range st.Subnets {
if sn.Pools == nil {
sn.Pools = []model.Pool{}
}
if sn.Options == nil {
sn.Options = []model.OptionValue{}
}
}
for _, r := range st.Reservations {
if r.Options == nil {
r.Options = []model.OptionValue{}
}
}
st.Sort()
return st, nil
}
// SaveChanges writes configuration changes and their history entry in one
// transaction. The entry gets its ID and, when it undoes another, that
// entry is marked reverted.
func (s *Store) SaveChanges(ctx context.Context, changes []model.Change, e *model.HistoryEntry) error {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
for _, c := range changes {
if err := applyChange(ctx, tx, c); err != nil {
return fmt.Errorf("saving %s %d: %w", c.Kind, c.ID, err)
}
}
if e != nil {
var uid any
if e.UserID != 0 {
uid = e.UserID
}
var revertOf any
if e.RevertOf != 0 {
revertOf = e.RevertOf
}
res, err := tx.ExecContext(ctx, `INSERT INTO history (time, user_id, username, via, description, changes, revert_of) VALUES (?,?,?,?,?,?,?)`,
ts(e.Time), uid, e.Username, e.Via, e.Description, jsonText(e.Changes), revertOf)
if err != nil {
return err
}
if e.ID, err = res.LastInsertId(); err != nil {
return err
}
if e.RevertOf != 0 {
if _, err := tx.ExecContext(ctx, `UPDATE history SET reverted_by = ? WHERE id = ?`, e.ID, e.RevertOf); err != nil {
return err
}
}
}
return tx.Commit()
}
func applyChange(ctx context.Context, tx *sql.Tx, c model.Change) error {
switch c.Kind {
case model.KindSettings:
if c.After == nil {
return fmt.Errorf("settings can't be deleted")
}
_, err := tx.ExecContext(ctx, `INSERT INTO meta (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value`, settingsKey, string(c.After))
return err
case model.KindSubnet:
if c.After == nil {
_, err := tx.ExecContext(ctx, `DELETE FROM subnets WHERE id = ?`, c.ID)
return err
}
var sn model.Subnet
if err := json.Unmarshal(c.After, &sn); err != nil {
return err
}
_, err := tx.ExecContext(ctx, `INSERT INTO subnets (id, name, interface, prefix, pools, lease_time, options, disabled, comment) VALUES (?,?,?,?,?,?,?,?,?)
ON CONFLICT(id) DO UPDATE SET name = excluded.name, interface = excluded.interface, prefix = excluded.prefix, pools = excluded.pools,
lease_time = excluded.lease_time, options = excluded.options, disabled = excluded.disabled, comment = excluded.comment`,
sn.ID, sn.Name, sn.Interface, sn.Prefix.String(), jsonText(nonNil(sn.Pools)), sn.LeaseTime, jsonText(nonNilOpts(sn.Options)), boolInt(sn.Disabled), sn.Comment)
return err
case model.KindReservation:
if c.After == nil {
_, err := tx.ExecContext(ctx, `DELETE FROM reservations WHERE id = ?`, c.ID)
return err
}
var r model.Reservation
if err := json.Unmarshal(c.After, &r); err != nil {
return err
}
_, err := tx.ExecContext(ctx, `INSERT INTO reservations (id, mac, client_id, ip, hostname, options, comment) VALUES (?,?,?,?,?,?,?)
ON CONFLICT(id) DO UPDATE SET mac = excluded.mac, client_id = excluded.client_id, ip = excluded.ip, hostname = excluded.hostname,
options = excluded.options, comment = excluded.comment`,
r.ID, r.MAC, r.ClientID, r.IP.String(), r.Hostname, jsonText(nonNilOpts(r.Options)), r.Comment)
return err
}
return fmt.Errorf("unknown kind %q", c.Kind)
}
func nonNil(p []model.Pool) []model.Pool {
if p == nil {
return []model.Pool{}
}
return p
}
func nonNilOpts(o []model.OptionValue) []model.OptionValue {
if o == nil {
return []model.OptionValue{}
}
return o
}
+321
View File
@@ -0,0 +1,321 @@
// Package store keeps Steward's data in SQLite: the configuration, leases,
// the history of changes, users, tokens, sessions and the audit log.
//
// The configuration is loaded into a model.State as a whole and saved as
// changes (model.Change); leases are read once at start and written one by
// one as the DHCP server hands them out.
package store
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
_ "modernc.org/sqlite"
)
// ErrNotFound is returned when a row does not exist.
var ErrNotFound = errors.New("not found")
// ErrConflict is returned for unique constraint violations.
var ErrConflict = errors.New("already exists")
// Store is a Steward database.
type Store struct {
db *sql.DB
}
// DB exposes the database (backups, tests).
func (s *Store) DB() *sql.DB { return s.db }
// Path is the database file ("" in memory).
func (s *Store) Path() string {
var file string
if err := s.db.QueryRow(`SELECT file FROM pragma_database_list WHERE name = 'main'`).Scan(&file); err != nil {
return ""
}
return file
}
// Open opens (creating if needed) the database at path and applies
// migrations. ":memory:" makes a throwaway database.
func Open(path string) (*Store, error) {
dsn := "file:" + path + "?_pragma=busy_timeout(10000)&_pragma=foreign_keys(1)&_pragma=synchronous(NORMAL)&_txlock=immediate&_pragma=journal_mode(WAL)"
if path == ":memory:" {
dsn = "file::memory:?_pragma=foreign_keys(1)&_txlock=immediate"
}
db, err := sql.Open("sqlite", dsn)
if err != nil {
return nil, err
}
if path == ":memory:" {
db.SetMaxOpenConns(1)
}
s := &Store{db: db}
if err := s.migrate(context.Background()); err != nil {
db.Close()
return nil, fmt.Errorf("migrating database: %w", err)
}
return s, nil
}
// OpenExisting opens a database without creating or migrating it, for
// commands that run next to a running stewardd ('stewardd backup').
func OpenExisting(path string) (*Store, error) {
db, err := sql.Open("sqlite", "file:"+path+"?mode=rw&_pragma=busy_timeout(10000)&_pragma=foreign_keys(1)&_txlock=immediate")
if err != nil {
return nil, err
}
if err := db.Ping(); err != nil {
db.Close()
return nil, err
}
return &Store{db: db}, nil
}
// Close closes the database.
func (s *Store) Close() error { return s.db.Close() }
// SchemaVersion is the schema version this Steward uses.
func SchemaVersion() int { return len(migrations) }
// Append new migrations; never edit one that has shipped.
var migrations = []string{
// 1: initial schema
`
CREATE TABLE meta (key TEXT PRIMARY KEY, value TEXT NOT NULL);
CREATE TABLE seq (name TEXT PRIMARY KEY, value INTEGER NOT NULL);
INSERT INTO seq (name, value) VALUES ('object', 0);
CREATE TABLE subnets (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL DEFAULT '',
interface TEXT NOT NULL,
prefix TEXT NOT NULL,
pools TEXT NOT NULL DEFAULT '[]',
lease_time INTEGER NOT NULL DEFAULT 0,
options TEXT NOT NULL DEFAULT '[]',
disabled INTEGER NOT NULL DEFAULT 0,
comment TEXT NOT NULL DEFAULT ''
);
CREATE TABLE reservations (
id INTEGER PRIMARY KEY,
mac TEXT NOT NULL DEFAULT '',
client_id TEXT NOT NULL DEFAULT '',
ip TEXT NOT NULL,
hostname TEXT NOT NULL DEFAULT '',
options TEXT NOT NULL DEFAULT '[]',
comment TEXT NOT NULL DEFAULT ''
);
CREATE TABLE leases (
ip TEXT PRIMARY KEY,
subnet_id INTEGER NOT NULL,
mac TEXT NOT NULL DEFAULT '',
client_id TEXT NOT NULL DEFAULT '',
hostname TEXT NOT NULL DEFAULT '',
state TEXT NOT NULL,
start TEXT NOT NULL,
end TEXT NOT NULL,
interface TEXT NOT NULL DEFAULT '',
updated TEXT NOT NULL
);
CREATE INDEX leases_mac ON leases (mac);
CREATE TABLE history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
time TEXT NOT NULL,
user_id INTEGER,
username TEXT NOT NULL,
via TEXT NOT NULL,
description TEXT NOT NULL,
changes TEXT NOT NULL,
revert_of INTEGER,
reverted_by INTEGER
);
CREATE TABLE users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL UNIQUE COLLATE NOCASE,
password_hash TEXT NOT NULL DEFAULT '',
admin INTEGER NOT NULL DEFAULT 0,
grants TEXT NOT NULL DEFAULT '[]',
disabled INTEGER NOT NULL DEFAULT 0,
comment TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
last_login_at TEXT
);
CREATE TABLE tokens (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
name TEXT NOT NULL,
public_id TEXT NOT NULL UNIQUE,
secret_hash TEXT NOT NULL,
grants TEXT,
expires_at TEXT,
created_at TEXT NOT NULL,
last_used_at TEXT,
last_used_ip TEXT NOT NULL DEFAULT '',
revoked_at TEXT
);
CREATE TABLE sessions (
id_hash TEXT PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
csrf_token TEXT NOT NULL,
created_at TEXT NOT NULL,
expires_at TEXT NOT NULL,
last_seen_at TEXT NOT NULL,
ip TEXT NOT NULL DEFAULT '',
user_agent TEXT NOT NULL DEFAULT ''
);
CREATE TABLE audit (
id INTEGER PRIMARY KEY AUTOINCREMENT,
time TEXT NOT NULL,
user_id INTEGER,
username TEXT NOT NULL DEFAULT '',
auth TEXT NOT NULL DEFAULT '',
token_id INTEGER,
ip TEXT NOT NULL DEFAULT '',
action TEXT NOT NULL,
object TEXT NOT NULL DEFAULT '',
result TEXT NOT NULL,
detail TEXT NOT NULL DEFAULT ''
);
CREATE INDEX audit_time ON audit (time);
`,
}
func (s *Store) migrate(ctx context.Context) error {
if _, err := s.db.ExecContext(ctx, `CREATE TABLE IF NOT EXISTS schema_version (version INTEGER NOT NULL)`); err != nil {
return err
}
var v int
err := s.db.QueryRowContext(ctx, `SELECT version FROM schema_version`).Scan(&v)
if errors.Is(err, sql.ErrNoRows) {
if _, err := s.db.ExecContext(ctx, `INSERT INTO schema_version (version) VALUES (0)`); err != nil {
return err
}
} else if err != nil {
return err
}
if v > len(migrations) {
return fmt.Errorf("database schema version %d is newer than this Steward (%d); upgrade Steward", v, len(migrations))
}
for i := v; i < len(migrations); i++ {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return err
}
if _, err := tx.ExecContext(ctx, migrations[i]); err != nil {
tx.Rollback()
return fmt.Errorf("migration %d: %w", i+1, err)
}
if _, err := tx.ExecContext(ctx, `UPDATE schema_version SET version = ?`, i+1); err != nil {
tx.Rollback()
return err
}
if err := tx.Commit(); err != nil {
return err
}
}
return nil
}
// Version returns the database's schema version.
func (s *Store) Version(ctx context.Context) (int, error) {
var v int
err := s.db.QueryRowContext(ctx, `SELECT version FROM schema_version`).Scan(&v)
return v, err
}
// AllocateIDs reserves n object IDs (subnets and reservations share one
// sequence, so an ID names one object).
func (s *Store) AllocateIDs(ctx context.Context, n int) ([]int64, error) {
if n <= 0 {
return nil, nil
}
var last int64
if err := s.db.QueryRowContext(ctx, `UPDATE seq SET value = value + ? WHERE name = 'object' RETURNING value`, n).Scan(&last); err != nil {
return nil, err
}
ids := make([]int64, n)
for i := range ids {
ids[i] = last - int64(n) + 1 + int64(i)
}
return ids, nil
}
// GetMeta reads a meta value ("" when unset).
func (s *Store) GetMeta(ctx context.Context, key string) (string, error) {
var v string
err := s.db.QueryRowContext(ctx, `SELECT value FROM meta WHERE key = ?`, key).Scan(&v)
if errors.Is(err, sql.ErrNoRows) {
return "", nil
}
return v, err
}
// SetMeta writes a meta value.
func (s *Store) SetMeta(ctx context.Context, key, value string) error {
_, err := s.db.ExecContext(ctx, `INSERT INTO meta (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value`, key, value)
return err
}
// Backup writes a consistent copy of the database to path.
func (s *Store) Backup(ctx context.Context, path string) error {
_, err := s.db.ExecContext(ctx, `VACUUM INTO ?`, path)
return err
}
// ---- helpers
const tsLayout = "2006-01-02T15:04:05.000000000Z07:00"
func ts(t time.Time) string { return t.UTC().Format(tsLayout) }
func tsPtr(t *time.Time) any {
if t == nil {
return nil
}
return ts(*t)
}
func parseTS(s string) time.Time {
t, err := time.Parse(time.RFC3339Nano, s)
if err != nil {
return time.Time{}
}
return t
}
func parseTSPtr(s sql.NullString) *time.Time {
if !s.Valid || s.String == "" {
return nil
}
t := parseTS(s.String)
return &t
}
func boolInt(b bool) int {
if b {
return 1
}
return 0
}
func jsonText(v any) string {
b, err := json.Marshal(v)
if err != nil {
panic(err)
}
return string(b)
}
func isUnique(err error) bool {
return err != nil && strings.Contains(err.Error(), "UNIQUE constraint failed")
}
+128
View File
@@ -0,0 +1,128 @@
package store
import (
"context"
"encoding/json"
"errors"
"net/netip"
"path/filepath"
"testing"
"time"
"steward/internal/model"
)
func raw(v any) json.RawMessage {
b, _ := json.Marshal(v)
return b
}
func TestStateHistoryAndLeases(t *testing.T) {
ctx := context.Background()
s, err := Open(filepath.Join(t.TempDir(), "steward.db"))
if err != nil {
t.Fatal(err)
}
defer s.Close()
st, err := s.LoadState(ctx)
if err != nil {
t.Fatal(err)
}
if st.Settings.EffectiveLeaseTime() != model.DefaultLeaseTime || len(st.Subnets) != 0 || !st.Settings.PingCheck {
t.Fatalf("new database state: %+v", st)
}
ids, _ := s.AllocateIDs(ctx, 2)
sn := &model.Subnet{ID: ids[0], Name: "office", Interface: "eth0", Prefix: netip.MustParsePrefix("10.0.10.0/24"),
Pools: []model.Pool{{Start: netip.MustParseAddr("10.0.10.100"), End: netip.MustParseAddr("10.0.10.199")}},
Options: []model.OptionValue{{Code: 3, Value: "10.0.10.1"}}}
r := &model.Reservation{ID: ids[1], MAC: "aa:bb:cc:dd:ee:01", IP: netip.MustParseAddr("10.0.10.20"), Hostname: "nas"}
set := st.Settings
set.LeaseTime = 3600
e := &model.HistoryEntry{Time: time.Now(), Username: "admin", Via: "cli", Description: "add office",
Changes: []model.Change{
{Kind: model.KindSubnet, ID: sn.ID, After: raw(sn)},
{Kind: model.KindReservation, ID: r.ID, After: raw(r)},
{Kind: model.KindSettings, Before: raw(st.Settings), After: raw(set)},
}}
if err := s.SaveChanges(ctx, e.Changes, e); err != nil {
t.Fatal(err)
}
st, _ = s.LoadState(ctx)
if len(st.Subnets) != 1 || st.Subnets[0].Pools[0].Size() != 100 || st.Subnets[0].Options[0].Value != "10.0.10.1" {
t.Errorf("subnets %+v", st.Subnets)
}
if len(st.Reservations) != 1 || st.Reservations[0].Hostname != "nas" || st.Settings.LeaseTime != 3600 {
t.Errorf("reservations %+v settings %+v", st.Reservations, st.Settings)
}
// Undo: the inverse changes, marked as a revert.
var inv []model.Change
for _, c := range e.Changes {
inv = append(inv, c.Inverse())
}
u := &model.HistoryEntry{Time: time.Now(), Username: "admin", Via: "web", Description: "undo", Changes: inv, RevertOf: e.ID}
if err := s.SaveChanges(ctx, inv, u); err != nil {
t.Fatal(err)
}
st, _ = s.LoadState(ctx)
if len(st.Subnets) != 0 || len(st.Reservations) != 0 || st.Settings.LeaseTime != model.DefaultLeaseTime {
t.Errorf("after undo: %+v", st)
}
h, err := s.GetHistory(ctx, e.ID)
if err != nil || h.RevertedBy != u.ID || len(h.Changes) != 3 {
t.Errorf("history %+v %v", h, err)
}
list, _ := s.ListHistory(ctx, 10, 0)
if len(list) != 2 || list[0].ID != u.ID {
t.Errorf("history list %+v", list)
}
now := time.Now().Truncate(time.Millisecond)
l := &model.Lease{IP: netip.MustParseAddr("10.0.10.100"), SubnetID: sn.ID, MAC: "aa:bb:cc:dd:ee:02", Hostname: "laptop", State: model.LeaseActive,
Start: now, End: now.Add(time.Hour), Interface: "eth0", Updated: now}
if err := s.PutLease(ctx, l); err != nil {
t.Fatal(err)
}
l.End = now.Add(2 * time.Hour)
if err := s.PutLease(ctx, l); err != nil {
t.Fatal(err)
}
ls, _ := s.LoadLeases(ctx)
if len(ls) != 1 || !ls[0].End.Equal(l.End) || ls[0].Hostname != "laptop" {
t.Errorf("leases %+v", ls)
}
if n, _ := s.PruneLeases(ctx, now.Add(3*time.Hour)); n != 1 {
t.Errorf("pruned %d", n)
}
if err := s.DeleteLease(ctx, l.IP); !errors.Is(err, ErrNotFound) {
t.Errorf("delete of a pruned lease: %v", err)
}
}
func TestUsersAndAudit(t *testing.T) {
ctx := context.Background()
s, err := Open(":memory:")
if err != nil {
t.Fatal(err)
}
u := &model.User{Username: "admin", PasswordHash: "x", Admin: true}
if err := s.CreateUser(ctx, u); err != nil {
t.Fatal(err)
}
if err := s.CreateUser(ctx, &model.User{Username: "ADMIN"}); !errors.Is(err, ErrConflict) {
t.Errorf("duplicate user: %v", err)
}
tok := &model.Token{UserID: u.ID, Name: "ci", PublicID: "p1", SecretHash: "h"}
if err := s.CreateToken(ctx, tok); err != nil {
t.Fatal(err)
}
got, err := s.GetTokenByPublicID(ctx, "p1")
if err != nil || got.Username != "admin" || got.Grants != nil {
t.Errorf("token %+v %v", got, err)
}
s.AddAudit(ctx, &model.AuditEvent{Username: "admin", Action: "auth.login", Result: "success"})
s.AddAudit(ctx, &model.AuditEvent{Username: "admin", Action: "lease.delete", Object: "10.0.10.5", Result: "success"})
evs, _ := s.ListAudit(ctx, AuditFilter{Action: "lease."})
if len(evs) != 1 || evs[0].Object != "10.0.10.5" {
t.Errorf("audit %+v", evs)
}
}
+372
View File
@@ -0,0 +1,372 @@
package store
import (
"context"
"database/sql"
"encoding/json"
"errors"
"strings"
"time"
"steward/internal/model"
)
const userCols = `id, username, password_hash, admin, grants, disabled, comment, created_at, updated_at, last_login_at`
func scanUser(sc interface{ Scan(...any) error }) (*model.User, error) {
u := &model.User{}
var grants, ca, ua string
var admin, disabled int
var ll sql.NullString
if err := sc.Scan(&u.ID, &u.Username, &u.PasswordHash, &admin, &grants, &disabled, &u.Comment, &ca, &ua, &ll); err != nil {
return nil, err
}
u.Admin, u.Disabled = admin != 0, disabled != 0
u.HasPassword = u.PasswordHash != ""
if err := json.Unmarshal([]byte(grants), &u.Grants); err != nil {
return nil, err
}
if u.Grants == nil {
u.Grants = []model.Grant{}
}
u.CreatedAt, u.UpdatedAt, u.LastLoginAt = parseTS(ca), parseTS(ua), parseTSPtr(ll)
return u, nil
}
// CreateUser inserts a user.
func (s *Store) CreateUser(ctx context.Context, u *model.User) error {
now := time.Now()
u.CreatedAt, u.UpdatedAt = now, now
if u.Grants == nil {
u.Grants = []model.Grant{}
}
res, err := s.db.ExecContext(ctx, `INSERT INTO users (username, password_hash, admin, grants, disabled, comment, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?)`,
u.Username, u.PasswordHash, boolInt(u.Admin), jsonText(u.Grants), boolInt(u.Disabled), u.Comment, ts(now), ts(now))
if isUnique(err) {
return ErrConflict
}
if err != nil {
return err
}
u.ID, err = res.LastInsertId()
u.HasPassword = u.PasswordHash != ""
return err
}
// UpdateUser saves a user.
func (s *Store) UpdateUser(ctx context.Context, u *model.User) error {
u.UpdatedAt = time.Now()
res, err := s.db.ExecContext(ctx, `UPDATE users SET username = ?, password_hash = ?, admin = ?, grants = ?, disabled = ?, comment = ?, updated_at = ? WHERE id = ?`,
u.Username, u.PasswordHash, boolInt(u.Admin), jsonText(u.Grants), boolInt(u.Disabled), u.Comment, ts(u.UpdatedAt), u.ID)
if isUnique(err) {
return ErrConflict
}
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrNotFound
}
u.HasPassword = u.PasswordHash != ""
return nil
}
// TouchLogin records a successful login.
func (s *Store) TouchLogin(ctx context.Context, id int64) error {
_, err := s.db.ExecContext(ctx, `UPDATE users SET last_login_at = ? WHERE id = ?`, ts(time.Now()), id)
return err
}
// GetUser loads a user by ID.
func (s *Store) GetUser(ctx context.Context, id int64) (*model.User, error) {
u, err := scanUser(s.db.QueryRowContext(ctx, `SELECT `+userCols+` FROM users WHERE id = ?`, id))
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
return u, err
}
// GetUserByName loads a user by name (case-insensitive).
func (s *Store) GetUserByName(ctx context.Context, name string) (*model.User, error) {
u, err := scanUser(s.db.QueryRowContext(ctx, `SELECT `+userCols+` FROM users WHERE username = ?`, name))
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
return u, err
}
// ListUsers lists all users by name.
func (s *Store) ListUsers(ctx context.Context) ([]*model.User, error) {
rows, err := s.db.QueryContext(ctx, `SELECT `+userCols+` FROM users ORDER BY username COLLATE NOCASE`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []*model.User
for rows.Next() {
u, err := scanUser(rows)
if err != nil {
return nil, err
}
out = append(out, u)
}
return out, rows.Err()
}
// CountAdmins counts the enabled administrators.
func (s *Store) CountAdmins(ctx context.Context) (int, error) {
var n int
err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM users WHERE admin = 1 AND disabled = 0`).Scan(&n)
return n, err
}
// DeleteUser removes a user with their tokens and sessions.
func (s *Store) DeleteUser(ctx context.Context, id int64) error {
res, err := s.db.ExecContext(ctx, `DELETE FROM users WHERE id = ?`, id)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrNotFound
}
return nil
}
const tokenCols = `t.id, t.user_id, u.username, t.name, t.public_id, t.secret_hash, t.grants, t.expires_at, t.created_at, t.last_used_at, t.last_used_ip, t.revoked_at`
func scanToken(sc interface{ Scan(...any) error }) (*model.Token, error) {
t := &model.Token{}
var grants, exp, lu, rev sql.NullString
var ca string
if err := sc.Scan(&t.ID, &t.UserID, &t.Username, &t.Name, &t.PublicID, &t.SecretHash, &grants, &exp, &ca, &lu, &t.LastUsedIP, &rev); err != nil {
return nil, err
}
if grants.Valid {
if err := json.Unmarshal([]byte(grants.String), &t.Grants); err != nil {
return nil, err
}
if t.Grants == nil {
t.Grants = []model.Grant{}
}
}
t.ExpiresAt, t.LastUsedAt, t.RevokedAt = parseTSPtr(exp), parseTSPtr(lu), parseTSPtr(rev)
t.CreatedAt = parseTS(ca)
return t, nil
}
// CreateToken inserts a token.
func (s *Store) CreateToken(ctx context.Context, t *model.Token) error {
t.CreatedAt = time.Now()
var grants any
if t.Grants != nil {
grants = jsonText(t.Grants)
}
res, err := s.db.ExecContext(ctx, `INSERT INTO tokens (user_id, name, public_id, secret_hash, grants, expires_at, created_at) VALUES (?,?,?,?,?,?,?)`,
t.UserID, t.Name, t.PublicID, t.SecretHash, grants, tsPtr(t.ExpiresAt), ts(t.CreatedAt))
if isUnique(err) {
return ErrConflict
}
if err != nil {
return err
}
t.ID, err = res.LastInsertId()
return err
}
// GetTokenByPublicID loads a token by its public ID.
func (s *Store) GetTokenByPublicID(ctx context.Context, pid string) (*model.Token, error) {
t, err := scanToken(s.db.QueryRowContext(ctx, `SELECT `+tokenCols+` FROM tokens t JOIN users u ON u.id = t.user_id WHERE t.public_id = ?`, pid))
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
return t, err
}
// GetToken loads a token by ID.
func (s *Store) GetToken(ctx context.Context, id int64) (*model.Token, error) {
t, err := scanToken(s.db.QueryRowContext(ctx, `SELECT `+tokenCols+` FROM tokens t JOIN users u ON u.id = t.user_id WHERE t.id = ?`, id))
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
return t, err
}
// ListTokens lists the tokens of one user (userID > 0) or of all.
func (s *Store) ListTokens(ctx context.Context, userID int64) ([]*model.Token, error) {
q := `SELECT ` + tokenCols + ` FROM tokens t JOIN users u ON u.id = t.user_id`
var args []any
if userID > 0 {
q += ` WHERE t.user_id = ?`
args = append(args, userID)
}
q += ` ORDER BY t.id`
rows, err := s.db.QueryContext(ctx, q, args...)
if err != nil {
return nil, err
}
defer rows.Close()
var out []*model.Token
for rows.Next() {
t, err := scanToken(rows)
if err != nil {
return nil, err
}
out = append(out, t)
}
return out, rows.Err()
}
// RevokeToken marks a token revoked.
func (s *Store) RevokeToken(ctx context.Context, id int64) error {
res, err := s.db.ExecContext(ctx, `UPDATE tokens SET revoked_at = ? WHERE id = ? AND revoked_at IS NULL`, ts(time.Now()), id)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrNotFound
}
return nil
}
// TouchToken records a token's use.
func (s *Store) TouchToken(ctx context.Context, id int64, ip string) error {
_, err := s.db.ExecContext(ctx, `UPDATE tokens SET last_used_at = ?, last_used_ip = ? WHERE id = ?`, ts(time.Now()), ip, id)
return err
}
// CreateSession stores a session.
func (s *Store) CreateSession(ctx context.Context, se *model.Session) error {
_, err := s.db.ExecContext(ctx, `INSERT INTO sessions (id_hash, user_id, csrf_token, created_at, expires_at, last_seen_at, ip, user_agent) VALUES (?,?,?,?,?,?,?,?)`,
se.IDHash, se.UserID, se.CSRFToken, ts(se.CreatedAt), ts(se.ExpiresAt), ts(se.LastSeenAt), se.IP, se.UserAgent)
return err
}
// GetSession loads a session by the hash of its ID.
func (s *Store) GetSession(ctx context.Context, idHash string) (*model.Session, error) {
se := &model.Session{}
var ca, ea, ls string
err := s.db.QueryRowContext(ctx, `SELECT id_hash, user_id, csrf_token, created_at, expires_at, last_seen_at, ip, user_agent FROM sessions WHERE id_hash = ?`, idHash).
Scan(&se.IDHash, &se.UserID, &se.CSRFToken, &ca, &ea, &ls, &se.IP, &se.UserAgent)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
if err != nil {
return nil, err
}
se.CreatedAt, se.ExpiresAt, se.LastSeenAt = parseTS(ca), parseTS(ea), parseTS(ls)
return se, nil
}
// TouchSession updates a session's last-seen time.
func (s *Store) TouchSession(ctx context.Context, idHash string, at time.Time) error {
_, err := s.db.ExecContext(ctx, `UPDATE sessions SET last_seen_at = ? WHERE id_hash = ?`, ts(at), idHash)
return err
}
// DeleteSession removes a session.
func (s *Store) DeleteSession(ctx context.Context, idHash string) error {
_, err := s.db.ExecContext(ctx, `DELETE FROM sessions WHERE id_hash = ?`, idHash)
return err
}
// DeleteUserSessions removes a user's sessions, except keep (if set).
func (s *Store) DeleteUserSessions(ctx context.Context, userID int64, keep string) error {
_, err := s.db.ExecContext(ctx, `DELETE FROM sessions WHERE user_id = ? AND id_hash != ?`, userID, keep)
return err
}
// PurgeSessions removes expired sessions.
func (s *Store) PurgeSessions(ctx context.Context, now time.Time) error {
_, err := s.db.ExecContext(ctx, `DELETE FROM sessions WHERE expires_at < ?`, ts(now))
return err
}
// AddAudit stores an audit event.
func (s *Store) AddAudit(ctx context.Context, ev *model.AuditEvent) error {
if ev.Time.IsZero() {
ev.Time = time.Now()
}
var uid, tid any
if ev.UserID != 0 {
uid = ev.UserID
}
if ev.TokenID != 0 {
tid = ev.TokenID
}
res, err := s.db.ExecContext(ctx, `INSERT INTO audit (time, user_id, username, auth, token_id, ip, action, object, result, detail) VALUES (?,?,?,?,?,?,?,?,?,?)`,
ts(ev.Time), uid, ev.Username, ev.Auth, tid, ev.IP, ev.Action, ev.Object, ev.Result, ev.Detail)
if err != nil {
return err
}
ev.ID, _ = res.LastInsertId()
return nil
}
// AuditFilter selects audit events.
type AuditFilter struct {
Username string
Action string // prefix: "auth." or "lease.delete"
Result string
Search string // in the object or the detail
Before int64 // IDs below this (paging)
Limit int
}
// ListAudit lists audit events, newest first.
func (s *Store) ListAudit(ctx context.Context, f AuditFilter) ([]*model.AuditEvent, error) {
var where []string
var args []any
if f.Username != "" {
where = append(where, "username = ? COLLATE NOCASE")
args = append(args, f.Username)
}
if f.Action != "" {
where = append(where, "action LIKE ? ESCAPE '\\'")
args = append(args, likeEscape(f.Action)+"%")
}
if f.Result != "" {
where = append(where, "result = ?")
args = append(args, f.Result)
}
if f.Search != "" {
where = append(where, "(object LIKE ? ESCAPE '\\' OR detail LIKE ? ESCAPE '\\')")
p := "%" + likeEscape(f.Search) + "%"
args = append(args, p, p)
}
if f.Before > 0 {
where = append(where, "id < ?")
args = append(args, f.Before)
}
q := `SELECT id, time, user_id, username, auth, token_id, ip, action, object, result, detail FROM audit`
if len(where) > 0 {
q += " WHERE " + strings.Join(where, " AND ")
}
limit := f.Limit
if limit <= 0 || limit > 1000 {
limit = 200
}
q += " ORDER BY id DESC LIMIT ?"
args = append(args, limit)
rows, err := s.db.QueryContext(ctx, q, args...)
if err != nil {
return nil, err
}
defer rows.Close()
out := []*model.AuditEvent{}
for rows.Next() {
ev := &model.AuditEvent{}
var t string
var uid, tid sql.NullInt64
if err := rows.Scan(&ev.ID, &t, &uid, &ev.Username, &ev.Auth, &tid, &ev.IP, &ev.Action, &ev.Object, &ev.Result, &ev.Detail); err != nil {
return nil, err
}
ev.Time = parseTS(t)
ev.UserID, ev.TokenID = uid.Int64, tid.Int64
out = append(out, ev)
}
return out, rows.Err()
}
func likeEscape(s string) string {
return strings.NewReplacer(`\`, `\\`, `%`, `\%`, `_`, `\_`).Replace(s)
}
+335
View File
@@ -0,0 +1,335 @@
// Package validate checks a whole configuration. Every problem has a
// stable code (docs/validation.md); errors stop a change from being saved,
// warnings are reported with it.
package validate
import (
"fmt"
"net/netip"
"sort"
"strings"
"steward/internal/dhcp4"
"steward/internal/model"
)
// Severities.
const (
Error = "error"
Warning = "warning"
)
// Problem is one finding.
type Problem struct {
Severity string `json:"severity"`
Code string `json:"code"`
Message string `json:"message"`
// Object names what it is about: "subnet office (10.0.10.0/24)".
Object string `json:"object,omitempty"`
}
// Env describes the host, for checks against its interfaces. A nil Env
// skips them (tests, imports for another machine).
type Env struct {
// Interfaces maps each interface name to its IPv4 prefixes
// (10.0.10.1/24).
Interfaces map[string][]netip.Prefix
}
// HasErrors reports whether any problem is an error.
func HasErrors(ps []Problem) bool {
for _, p := range ps {
if p.Severity == Error {
return true
}
}
return false
}
// Errors returns only the errors.
func Errors(ps []Problem) []Problem {
var out []Problem
for _, p := range ps {
if p.Severity == Error {
out = append(out, p)
}
}
return out
}
type checker struct {
out []Problem
}
func (c *checker) add(sev, code, object, format string, args ...any) {
c.out = append(c.out, Problem{Severity: sev, Code: code, Object: object, Message: fmt.Sprintf(format, args...)})
}
// Check validates a configuration.
func Check(st *model.State, env *Env) []Problem {
c := &checker{}
c.settings(st.Settings)
c.subnets(st, env)
c.reservations(st, env)
return c.out
}
func (c *checker) settings(s model.Settings) {
if s.LeaseTime != 0 {
c.leaseTime(s.LeaseTime, "settings")
}
if s.PingTimeout != 0 && (s.PingTimeout < 50 || s.PingTimeout > 5000) {
c.add(Error, "ping_timeout_range", "settings", "the ping timeout is %d ms; use 50 to 5000 ms", s.PingTimeout)
}
if s.DeclineHold != 0 && (s.DeclineHold < 60 || s.DeclineHold > 30*24*3600) {
c.add(Error, "decline_hold_range", "settings", "the decline hold is %d seconds; use 1 minute to 30 days", s.DeclineHold)
}
c.options(s.Options, "settings")
}
func (c *checker) leaseTime(sec int, object string) {
if sec < model.MinLeaseTime || sec > model.MaxLeaseTime {
c.add(Error, "lease_time_range", object, "the lease time is %d seconds; use 1 minute to 365 days", sec)
}
}
// options checks option values: known types, valid values, no reserved
// codes, no code twice.
func (c *checker) options(opts []model.OptionValue, object string) {
seen := map[int]bool{}
for _, o := range opts {
if o.Code < 1 || o.Code > 254 {
c.add(Error, "option_code", object, "option code %d is out of range (1 to 254)", o.Code)
continue
}
def := dhcp4.ByCode(byte(o.Code))
if why, ok := dhcp4.Reserved[byte(o.Code)]; ok {
c.add(Error, "option_reserved", object, "option %d can't be set: %s", o.Code, why)
continue
}
if seen[o.Code] {
c.add(Error, "option_duplicate", object, "option %s is set twice", def.Name)
}
seen[o.Code] = true
typ := def.Type
if !dhcp4.Known(byte(o.Code)) && o.Type != "" {
typ = o.Type
}
if _, err := dhcp4.Encode(typ, o.Value); err != nil {
c.add(Error, "option_invalid", object, "option %s: %v", def.Name, err)
}
}
}
func (c *checker) subnets(st *model.State, env *Env) {
names := map[string]*model.Subnet{}
for i, s := range st.Subnets {
obj := "subnet " + s.Label()
p := s.Prefix
switch {
case !p.IsValid() || !p.Addr().Is4():
c.add(Error, "subnet_prefix", obj, "the prefix must be an IPv4 network such as 10.0.10.0/24")
continue
case p.Masked() != p:
c.add(Error, "subnet_prefix", obj, "%s has host bits set; the network is %s", p, p.Masked())
continue
case p.Bits() < 8 || p.Bits() > 30:
c.add(Error, "subnet_size", obj, "a /%d is not a subnet DHCP can serve; use /8 to /30", p.Bits())
continue
}
if s.Name != "" {
if strings.ContainsAny(s.Name, " \t/,") {
c.add(Error, "subnet_name", obj, "the name %q has spaces, slashes or commas; use letters, digits, '-' and '_'", s.Name)
}
key := strings.ToLower(s.Name)
if o := names[key]; o != nil {
c.add(Error, "subnet_name_taken", obj, "the name %s is already used by subnet %s", s.Name, o.Prefix)
}
names[key] = s
}
if s.Interface == "" {
c.add(Error, "subnet_interface_missing", obj, "the subnet needs the network interface it is served on (eth0, eth0.10, br-lan)")
} else if !validIfName(s.Interface) {
c.add(Error, "subnet_interface_name", obj, "%q isn't an interface name", s.Interface)
}
for _, o := range st.Subnets[:i] {
if o.Prefix.IsValid() && o.Prefix.Overlaps(p) {
c.add(Error, "subnet_overlap", obj, "it overlaps subnet %s", o.Label())
}
}
if s.LeaseTime != 0 {
c.leaseTime(s.LeaseTime, obj)
}
c.pools(s, obj)
c.options(s.Options, obj)
c.routers(s, st.Settings.Options, obj)
if env != nil && !s.Disabled && s.Interface != "" {
c.hostInterface(s, env, obj)
}
}
}
func (c *checker) pools(s *model.Subnet, obj string) {
if len(s.Pools) == 0 && !s.Disabled {
c.add(Warning, "subnet_no_pools", obj, "the subnet has no pools: only clients with a reservation get an address")
}
network, broadcast := s.Prefix.Addr(), model.Last(s.Prefix)
for i, p := range s.Pools {
if !p.Start.Is4() || !p.End.Is4() || p.End.Less(p.Start) {
c.add(Error, "pool_invalid", obj, "pool %s is not a range of IPv4 addresses", p)
continue
}
if !s.Prefix.Contains(p.Start) || !s.Prefix.Contains(p.End) {
c.add(Error, "pool_outside_subnet", obj, "pool %s is not inside %s", p, s.Prefix)
continue
}
if p.Contains(network) || p.Contains(broadcast) {
c.add(Error, "pool_network_address", obj, "pool %s includes the network address %s or the broadcast address %s; start it at %s and end it at %s at most", p, network, broadcast, network.Next(), broadcast.Prev())
}
for _, o := range s.Pools[:i] {
if o.Start.Is4() && o.End.Is4() && !(p.End.Less(o.Start) || o.End.Less(p.Start)) {
c.add(Error, "pool_overlap", obj, "pools %s and %s overlap", o, p)
}
}
}
}
// routers warns about a gateway outside the subnet: clients can't reach it.
func (c *checker) routers(s *model.Subnet, global []model.OptionValue, obj string) {
val := ""
for _, o := range global {
if o.Code == dhcp4.OptRouters {
val = o.Value
}
}
for _, o := range s.Options {
if o.Code == dhcp4.OptRouters {
val = o.Value
}
}
if val == "" {
return
}
b, err := dhcp4.Encode(dhcp4.TypeIPs, val)
if err != nil {
return
}
for i := 0; i+4 <= len(b); i += 4 {
a := netip.AddrFrom4([4]byte(b[i : i+4]))
if !s.Prefix.Contains(a) {
c.add(Warning, "router_outside_subnet", obj, "the router %s is outside %s, so clients can't reach it", a, s.Prefix)
}
for _, p := range s.Pools {
if p.Contains(a) {
c.add(Warning, "router_in_pool", obj, "the router %s is inside pool %s; it is never handed out while it answers pings, but take it out of the pool", a, p)
}
}
}
}
func (c *checker) hostInterface(s *model.Subnet, env *Env, obj string) {
addrs, ok := env.Interfaces[s.Interface]
if !ok {
var names []string
for n := range env.Interfaces {
names = append(names, n)
}
sort.Strings(names)
c.add(Warning, "interface_unknown", obj, "this server has no interface %s (it has %s); the subnet is served once it appears", s.Interface, strings.Join(names, ", "))
return
}
for _, a := range addrs {
if s.Prefix.Contains(a.Addr()) {
for _, p := range s.Pools {
if p.Contains(a.Addr()) {
c.add(Warning, "pool_includes_server", obj, "pool %s includes this server's own address %s; it is never handed out, but take it out of the pool", p, a.Addr())
}
}
return
}
}
c.add(Warning, "interface_no_address", obj, "interface %s has no address in %s; give it one (the clients renew their leases with it), or check the interface", s.Interface, s.Prefix)
}
func (c *checker) reservations(st *model.State, env *Env) {
byIP := map[netip.Addr]*model.Reservation{}
type key struct {
subnet int64
id string
}
byClient := map[key]*model.Reservation{}
for _, r := range st.Reservations {
obj := "reservation " + r.Label()
if r.MAC == "" && r.ClientID == "" {
c.add(Error, "reservation_no_client", obj, "a reservation needs the client's MAC address or client identifier")
}
if !r.IP.Is4() {
c.add(Error, "reservation_ip", obj, "the address must be an IPv4 address")
continue
}
s := st.SubnetFor(r.IP)
if s == nil {
c.add(Error, "reservation_no_subnet", obj, "%s is in none of the subnets; add the subnet first", r.IP)
continue
}
if r.IP == s.Prefix.Addr() || r.IP == model.Last(s.Prefix) {
c.add(Error, "reservation_network_address", obj, "%s is the network or broadcast address of %s", r.IP, s.Prefix)
}
if o := byIP[r.IP]; o != nil {
c.add(Error, "reservation_ip_taken", obj, "%s is already reserved for %s", r.IP, o.Label())
}
byIP[r.IP] = r
k := key{s.ID, "mac " + r.MAC}
if r.ClientID != "" {
k.id = "id " + r.ClientID
}
if o := byClient[k]; o != nil {
c.add(Error, "reservation_client_taken", obj, "this client already has a reservation in %s: %s", s.Label(), o.Label())
}
byClient[k] = r
if r.Hostname != "" && !validHostname(r.Hostname) {
c.add(Error, "reservation_hostname", obj, "%q isn't a valid host name (letters, digits and '-', labels up to 63 characters, separated by dots)", r.Hostname)
}
c.options(r.Options, obj)
if env != nil && s.Interface != "" {
for _, a := range env.Interfaces[s.Interface] {
if a.Addr() == r.IP {
c.add(Warning, "reservation_server_address", obj, "%s is this server's own address on %s", r.IP, s.Interface)
}
}
}
}
}
func validIfName(n string) bool {
if n == "" || len(n) > 15 {
return false
}
for _, c := range n {
if c <= ' ' || c == '/' || c == ':' || c > '~' {
return false
}
}
return true
}
func validHostname(n string) bool {
n = strings.TrimSuffix(n, ".")
if n == "" || len(n) > 253 {
return false
}
for _, l := range strings.Split(n, ".") {
if l == "" || len(l) > 63 || l[0] == '-' || l[len(l)-1] == '-' {
return false
}
for _, c := range l {
if !(c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' || c == '-') {
return false
}
}
}
return true
}
// ValidHostname reports whether n can be sent as a host name.
func ValidHostname(n string) bool { return validHostname(n) }
+129
View File
@@ -0,0 +1,129 @@
package validate
import (
"net/netip"
"strings"
"testing"
"steward/internal/model"
)
func pool(s string) model.Pool {
p, err := model.ParsePool(s)
if err != nil {
panic(err)
}
return p
}
func good() *model.State {
return &model.State{
Settings: model.DefaultSettings(),
Subnets: []*model.Subnet{
{ID: 1, Name: "office", Interface: "eth0", Prefix: netip.MustParsePrefix("10.0.10.0/24"), Pools: []model.Pool{pool("10.0.10.100-10.0.10.199")},
Options: []model.OptionValue{{Code: 3, Value: "10.0.10.1"}}},
{ID: 2, Name: "iot", Interface: "eth0.20", Prefix: netip.MustParsePrefix("10.0.20.0/24")},
},
Reservations: []*model.Reservation{
{ID: 3, MAC: "aa:bb:cc:dd:ee:01", IP: netip.MustParseAddr("10.0.10.20"), Hostname: "nas"},
},
}
}
func codes(ps []Problem) string {
var out []string
for _, p := range ps {
out = append(out, p.Severity+":"+p.Code)
}
return strings.Join(out, " ")
}
func TestGoodConfig(t *testing.T) {
st := good()
st.Subnets[1].Pools = []model.Pool{pool("10.0.20.100-10.0.20.200")}
if ps := Check(st, nil); len(ps) != 0 {
t.Errorf("problems: %v", ps)
}
}
func TestProblems(t *testing.T) {
cases := []struct {
name string
edit func(st *model.State)
want string
}{
{"host bits", func(st *model.State) { st.Subnets[0].Prefix = netip.MustParsePrefix("10.0.10.5/24") }, "error:subnet_prefix"},
{"too small", func(st *model.State) { st.Subnets[0].Prefix = netip.MustParsePrefix("10.0.10.0/31") }, "error:subnet_size"},
{"ipv6", func(st *model.State) { st.Subnets[0].Prefix = netip.MustParsePrefix("2001:db8::/64") }, "error:subnet_prefix"},
{"overlap", func(st *model.State) { st.Subnets[1].Prefix = netip.MustParsePrefix("10.0.0.0/16") }, "error:subnet_overlap"},
{"name taken", func(st *model.State) { st.Subnets[1].Name = "Office" }, "error:subnet_name_taken"},
{"no interface", func(st *model.State) { st.Subnets[0].Interface = "" }, "error:subnet_interface_missing"},
{"bad interface", func(st *model.State) { st.Subnets[0].Interface = "eth 0" }, "error:subnet_interface_name"},
{"no pools", func(st *model.State) {}, "warning:subnet_no_pools"},
{"pool outside", func(st *model.State) { st.Subnets[0].Pools = []model.Pool{pool("10.0.11.1-10.0.11.9")} }, "error:pool_outside_subnet"},
{"pool network", func(st *model.State) { st.Subnets[0].Pools = []model.Pool{pool("10.0.10.0/24")} }, "error:pool_network_address"},
{"pool overlap", func(st *model.State) {
st.Subnets[0].Pools = append(st.Subnets[0].Pools, pool("10.0.10.150-10.0.10.250"))
}, "error:pool_overlap"},
{"lease time", func(st *model.State) { st.Subnets[0].LeaseTime = 10 }, "error:lease_time_range"},
{"reserved option", func(st *model.State) { st.Settings.Options = []model.OptionValue{{Code: 51, Value: "1"}} }, "error:option_reserved"},
{"bad option", func(st *model.State) { st.Subnets[0].Options = []model.OptionValue{{Code: 6, Value: "dns.example"}} }, "error:option_invalid"},
{"duplicate option", func(st *model.State) {
st.Subnets[0].Options = append(st.Subnets[0].Options, model.OptionValue{Code: 3, Value: "10.0.10.2"})
}, "error:option_duplicate"},
{"router outside", func(st *model.State) { st.Subnets[0].Options = []model.OptionValue{{Code: 3, Value: "10.0.11.1"}} }, "warning:router_outside_subnet"},
{"router in pool", func(st *model.State) { st.Subnets[0].Options = []model.OptionValue{{Code: 3, Value: "10.0.10.150"}} }, "warning:router_in_pool"},
{"no client", func(st *model.State) { st.Reservations[0].MAC = "" }, "error:reservation_no_client"},
{"no subnet", func(st *model.State) { st.Reservations[0].IP = netip.MustParseAddr("192.168.1.5") }, "error:reservation_no_subnet"},
{"broadcast", func(st *model.State) { st.Reservations[0].IP = netip.MustParseAddr("10.0.10.255") }, "error:reservation_network_address"},
{"ip taken", func(st *model.State) {
st.Reservations = append(st.Reservations, &model.Reservation{ID: 9, MAC: "aa:bb:cc:dd:ee:02", IP: netip.MustParseAddr("10.0.10.20")})
}, "error:reservation_ip_taken"},
{"client taken", func(st *model.State) {
st.Reservations = append(st.Reservations, &model.Reservation{ID: 9, MAC: "aa:bb:cc:dd:ee:01", IP: netip.MustParseAddr("10.0.10.21")})
}, "error:reservation_client_taken"},
{"hostname", func(st *model.State) { st.Reservations[0].Hostname = "my_nas" }, "error:reservation_hostname"},
}
for _, c := range cases {
st := good()
st.Subnets[1].Pools = []model.Pool{pool("10.0.20.100-10.0.20.200")}
if c.name == "no pools" {
st.Subnets[1].Pools = nil
}
c.edit(st)
got := codes(Check(st, nil))
if !strings.Contains(" "+got+" ", " "+c.want+" ") {
t.Errorf("%s: got %q, want %s", c.name, got, c.want)
}
}
}
func TestSameMACInTwoSubnets(t *testing.T) {
st := good()
st.Subnets[1].Pools = []model.Pool{pool("10.0.20.100-10.0.20.200")}
st.Reservations = append(st.Reservations, &model.Reservation{ID: 9, MAC: "aa:bb:cc:dd:ee:01", IP: netip.MustParseAddr("10.0.20.20")})
if ps := Check(st, nil); len(ps) != 0 {
t.Errorf("a laptop on two VLANs: %v", ps)
}
}
func TestHostInterfaces(t *testing.T) {
st := good()
st.Subnets[1].Pools = []model.Pool{pool("10.0.20.100-10.0.20.200")}
st.Subnets[0].Pools = []model.Pool{pool("10.0.10.1-10.0.10.199")}
st.Subnets[0].Options = nil
env := &Env{Interfaces: map[string][]netip.Prefix{
"eth0": {netip.MustParsePrefix("10.0.10.2/24")},
"wg0": {netip.MustParsePrefix("10.9.0.1/24")},
}}
got := codes(Check(st, env))
for _, want := range []string{"warning:pool_includes_server", "warning:interface_unknown"} {
if !strings.Contains(got, want) {
t.Errorf("got %q, want %s", got, want)
}
}
env.Interfaces["eth0.20"] = []netip.Prefix{netip.MustParsePrefix("192.168.5.1/24")}
if got := codes(Check(st, env)); !strings.Contains(got, "warning:interface_no_address") {
t.Errorf("got %q, want interface_no_address", got)
}
}
+150
View File
@@ -0,0 +1,150 @@
// Package version holds Steward's version, set at build time with
//
// go build -ldflags "-X steward/internal/version.Version=1.2.3 -X steward/internal/version.Tag=v1.2.3"
//
// The Makefile sets Version from the VERSION file, and Tag only when the
// build is of that release: HEAD is the tag v<VERSION> and no tracked file
// is changed. Only such a build reports a version number. Other builds
// are development builds, identified by their commit (which Go records
// from git by itself) and by where they stand relative to the last tag
// (Describe, from git describe).
package version
import (
"regexp"
"runtime/debug"
"strconv"
"strings"
"sync"
)
// Version is the Steward version (the VERSION file).
var Version = "0.1.0-dev"
// Tag is the release tag the build is of ("" when it isn't a release).
var Tag = ""
// Commit is the git commit the build is of. It is read from the build
// information when not set at build time.
var Commit = ""
// Describe is 'git describe --tags --long' of the build's commit
// ("v0.2.0-rc.1-3-gca0f148b78b6"), set by the Makefile; it says how many
// commits a development build is past the last tag.
var Describe = ""
// Repo is Steward's source repository.
const Repo = "https://gitea.adbraun.lol/adam/steward"
// Build describes what a binary was built from.
type Build struct {
Version string `json:"version"`
Tag string `json:"tag,omitempty"`
Commit string `json:"commit,omitempty"`
Modified bool `json:"modified,omitempty"` // built with uncommitted changes
// Since is the last tag before a development build's commit, and
// Ahead how many commits the build is past it (0: the tag's commit,
// with uncommitted changes).
Since string `json:"since,omitempty"`
Ahead int `json:"ahead,omitempty"`
// Label is the short form to show: the tag of a release, otherwise
// the abbreviated commit (or the version when the commit is unknown).
Label string `json:"label"`
// URL is the release or commit in the repository.
URL string `json:"url"`
}
var info = sync.OnceValue(func() Build {
commit, modified := Commit, false
if bi, ok := debug.ReadBuildInfo(); ok {
for _, s := range bi.Settings {
switch s.Key {
case "vcs.revision":
if commit == "" {
commit = s.Value
}
case "vcs.modified":
modified = s.Value == "true"
}
}
}
b := describe(Version, Tag, commit, modified)
b.Since, b.Ahead = parseDescribe(Describe, b.Commit)
return b
})
// parseDescribe splits git describe --long output, if it is of commit.
func parseDescribe(d, commit string) (since string, ahead int) {
m := describeRe.FindStringSubmatch(d)
if m == nil || commit == "" || !strings.HasPrefix(commit, m[3]) {
return "", 0
}
n, _ := strconv.Atoi(m[2])
return m[1], n
}
var describeRe = regexp.MustCompile(`^(v.+)-(\d+)-g([0-9a-f]+)$`)
func describe(version, tag, commit string, modified bool) Build {
b := Build{Version: version, Tag: tag, Commit: commit, Modified: modified}
switch {
case tag != "":
b.Modified = false // the Makefile only sets Tag for clean trees
b.Label, b.URL = tag, Repo+"/releases/tag/"+tag
case commit != "":
b.Label, b.URL = short(commit), Repo+"/commit/"+commit
if modified {
b.Label += "-modified"
}
default:
b.Label, b.URL = version, Repo
}
return b
}
// Info describes this build.
func Info() Build { return info() }
// String is the version for logs and 'version' commands. Only a release
// build gives a version ("0.2.0"); any other build says it's a
// development build: "dev a1b2c3d4e5f6 (3 commits after v0.2.0-rc.1)".
func String() string { return Info().String() }
func (b Build) String() string {
switch {
case b.Tag != "":
return b.Version
case b.Commit == "":
// No git information (built from a source archive).
return b.Version + " (source build)"
}
s := "dev " + b.Label
switch {
case b.Since == "":
case b.Ahead == 1:
s += " (1 commit after " + b.Since + ")"
case b.Ahead > 1:
s += " (" + strconv.Itoa(b.Ahead) + " commits after " + b.Since + ")"
case b.Modified:
s += " (" + b.Since + " with uncommitted changes)"
default:
s += " (at " + b.Since + ")"
}
return s
}
// Short is the version for a User-Agent: the version of a release,
// otherwise the label ("a1b2c3d4e5f6").
func Short() string {
if b := Info(); b.Tag == "" && b.Commit != "" {
return b.Label
}
return Version
}
func short(commit string) string {
if len(commit) > 12 {
return commit[:12]
}
return commit
}
+49
View File
@@ -0,0 +1,49 @@
package version
import "testing"
func TestDescribe(t *testing.T) {
const c = "4d00be5e1f0a9b8c7d6e5f4a3b2c1d0e9f8a7b6c"
for _, tc := range []struct {
tag, commit string
modified bool
label, url string
}{
{"v0.1.0", c, true, "v0.1.0", Repo + "/releases/tag/v0.1.0"},
{"", c, false, "4d00be5e1f0a", Repo + "/commit/" + c},
{"", c, true, "4d00be5e1f0a-modified", Repo + "/commit/" + c},
{"", "", false, "0.1.0", Repo},
} {
b := describe("0.1.0", tc.tag, tc.commit, tc.modified)
if b.Label != tc.label || b.URL != tc.url {
t.Errorf("describe(%q, %q, %v) = %q %q, want %q %q", tc.tag, tc.commit, tc.modified, b.Label, b.URL, tc.label, tc.url)
}
}
}
func TestString(t *testing.T) {
const c = "ca0f148b78b6e1f0a9b8c7d6e5f4a3b2c1d0e9f8"
build := func(tag, commit string, modified bool, desc string) Build {
b := describe("0.2.0-rc.1", tag, commit, modified)
b.Since, b.Ahead = parseDescribe(desc, b.Commit)
return b
}
for _, tc := range []struct {
b Build
want string
}{
{build("v0.2.0-rc.1", c, false, "v0.2.0-rc.1-0-gca0f148b78b6"), "0.2.0-rc.1"},
{build("", c, false, "v0.2.0-rc.1-3-gca0f148b78b6"), "dev ca0f148b78b6 (3 commits after v0.2.0-rc.1)"},
{build("", c, true, "v0.2.0-rc.1-1-gca0f148b78b6"), "dev ca0f148b78b6-modified (1 commit after v0.2.0-rc.1)"},
{build("", c, true, "v0.2.0-rc.1-0-gca0f148b78b6"), "dev ca0f148b78b6-modified (v0.2.0-rc.1 with uncommitted changes)"},
{build("", c, false, "v0.1.0-0-gca0f148b78b6"), "dev ca0f148b78b6 (at v0.1.0)"},
{build("", c, false, ""), "dev ca0f148b78b6"},
// A describe of another commit (stale ldflags) is ignored.
{build("", c, false, "v0.2.0-rc.1-3-gdeadbeef0000"), "dev ca0f148b78b6"},
{build("", "", false, ""), "0.2.0-rc.1 (source build)"},
} {
if got := tc.b.String(); got != tc.want {
t.Errorf("String() = %q, want %q", got, tc.want)
}
}
}
+82
View File
@@ -0,0 +1,82 @@
// API client and shared application state.
import { toast } from "./ui.js";
export const state = {
me: null, // whoami response
csrf: "",
catalog: null,
};
export class APIError extends Error {
constructor(status, body) {
const e = (body && body.error) || {};
super(e.message || ("HTTP " + status));
this.status = status;
this.code = e.code || "";
this.details = e;
}
}
let onUnauthorized = () => {};
export function setUnauthorizedHandler(fn) { onUnauthorized = fn; }
// api calls the REST API. opts: {body, query}.
export async function api(method, path, opts = {}) {
const headers = { "Accept": "application/json" };
let body;
if (opts.body !== undefined) {
headers["Content-Type"] = "application/json";
body = JSON.stringify(opts.body);
}
if (method !== "GET" && state.csrf) headers["X-CSRF-Token"] = state.csrf;
let url = "/api/v1" + path;
if (opts.query) {
const q = new URLSearchParams();
for (const [k, v] of Object.entries(opts.query)) if (v !== undefined && v !== null && v !== "") q.set(k, v);
const s = q.toString();
if (s) url += (url.includes("?") ? "&" : "?") + s;
}
const resp = await fetch(url, { method, headers, body, credentials: "same-origin" });
if (resp.status === 204) return null;
const ct = resp.headers.get("Content-Type") || "";
const data = ct.includes("application/json") ? await resp.json() : await resp.text();
if (!resp.ok) {
if (resp.status === 401 && !path.startsWith("/auth/login")) onUnauthorized();
throw new APIError(resp.status, data);
}
return data;
}
export const get = (path, query) => api("GET", path, { query });
// change sends a configuration change and reports it: a toast with the
// history entry (to undo it from History) and one per new warning. It
// returns the response.
export async function change(method, path, body, opts = {}) {
const r = await api(method, path, { body, query: opts.query });
if (!opts.quiet) report(r);
return r;
}
export function report(r) {
if (!r) return;
if (r.dry_run) toast("Checked: nothing was changed");
else if (r.applied) toast("Saved (history #" + r.history_id + ")");
else if (!(r.changes || []).length) toast("Nothing to change");
for (const w of r.warnings || []) toast(w.message + (w.object ? " (" + w.object + ")" : ""), "warn", 10000);
}
// can reports whether the user may read (or write) a resource.
export function can(resource, write) {
const me = state.me;
if (!me) return false;
if (me.admin) return true;
const perms = me.permissions || [];
return perms.includes(resource + ":write") || (!write && perms.includes(resource + ":read"));
}
// catalog returns the option catalog (cached).
export async function catalog() {
if (!state.catalog) state.catalog = await get("/catalog");
return state.catalog;
}
+209
View File
@@ -0,0 +1,209 @@
// Steward web UI: router, layout and login.
import { h, mount, toast, errorBox } from "./ui.js";
import { wordmark, logo } from "./brand.js";
import { api, get, state, can, setUnauthorizedHandler } from "./api.js";
import * as dashboard from "./pages/dashboard.js";
import * as subnets from "./pages/subnets.js";
import * as subnet from "./pages/subnet.js";
import * as leases from "./pages/leases.js";
import * as reservations from "./pages/reservations.js";
import * as activity from "./pages/activity.js";
import * as settings from "./pages/settings.js";
import * as history from "./pages/history.js";
import * as audit from "./pages/audit.js";
import * as users from "./pages/users.js";
import * as account from "./pages/account.js";
const routes = [
{ pattern: /^$/, page: dashboard, nav: "dashboard" },
{ pattern: /^subnets$/, page: subnets, nav: "subnets" },
{ pattern: /^subnets\/(\d+)(?:\/(\w+))?$/, page: subnet, nav: "subnets", params: ["id", "tab"] },
{ pattern: /^leases$/, page: leases, nav: "leases" },
{ pattern: /^reservations$/, page: reservations, nav: "reservations" },
{ pattern: /^activity$/, page: activity, nav: "activity" },
{ pattern: /^settings$/, page: settings, nav: "settings" },
{ pattern: /^history$/, page: history, nav: "history" },
{ pattern: /^history\/(\d+)$/, page: history, nav: "history", params: ["id"] },
{ pattern: /^audit$/, page: audit, nav: "audit" },
{ pattern: /^users$/, page: users, nav: "users" },
{ pattern: /^account$/, page: account, nav: "account" },
];
// The top bar. ["key", "#/href", "Label", permission?]
const navItems = [
["dashboard", "#/", "Dashboard"],
["subnets", "#/subnets", "Subnets", "config"],
["leases", "#/leases", "Leases", "leases"],
["reservations", "#/reservations", "Reservations", "config"],
["activity", "#/activity", "Activity", "leases"],
["history", "#/history", "History", "config"],
{ menu: "Server", items: [
["settings", "#/settings", "Settings and options", "config"],
["audit", "#/audit", "Audit log", "audit"],
] },
];
const app = document.getElementById("app");
let mainEl, navEl, current = { key: "" }, routeGen = 0;
function parseHash() {
const raw = location.hash.replace(/^#\/?/, "");
const [path, qs] = raw.split("?");
return { path: path.replace(/\/$/, ""), query: new URLSearchParams(qs || "") };
}
function renderLogin(message) {
const user = h("input", { name: "username", autocomplete: "username", placeholder: "Username", required: true });
const pass = h("input", { name: "password", type: "password", autocomplete: "current-password", placeholder: "Password", required: true });
const err = h("div");
const form = h("form", { onsubmit: async (e) => {
e.preventDefault();
mount(err);
try {
const me = await api("POST", "/auth/login", { body: { username: user.value, password: pass.value } });
await loggedIn(me);
} catch (ex) {
mount(err, errorBox(ex));
pass.select();
}
} }, user, pass, h("button", { class: "primary", type: "submit" }, "Log in"), err);
mount(app, h("div", { class: "login panel" },
logo(104, "login-logo"),
h("h1", null, wordmark()),
h("div", { class: "tag" }, "Keeper of the hold's addresses"),
message ? h("div", { class: "msg warn" }, message) : null,
form));
user.focus();
}
async function loggedIn(me) {
state.me = me;
state.csrf = me.csrf_token || "";
layout();
window.addEventListener("hashchange", route);
await route();
}
function layout() {
navEl = h("nav", { class: "main" });
mainEl = h("main");
const me = state.me;
const header = h("header", { class: "top" },
h("a", { class: "brand", href: "#/", title: "Dashboard" },
logo(30),
wordmark({ rule: false, height: 17, cls: "brand-name" })),
navEl,
h("div", { class: "header-right" },
menu(h("span", { class: "user", title: (me.permissions || []).join("\n") }, me.user.username), [
h("a", { href: "#/account" }, "Account"),
me.admin ? h("a", { href: "#/users" }, "Users") : null,
h("button", { type: "button", class: "menu-item", onclick: logout }, "Log out"),
], { right: true, active: ["account", "users"].includes(current.nav), label: "Account menu" })));
mount(app, h("div", { class: "shell" }, header, mainEl, footer(me.build)));
renderNav();
}
// footer names the build: the release, or the commit for other builds.
function footer(b) {
if (!b) return null;
let title = b.tag ? "Steward " + b.version + " (release " + b.tag + ")" : "Steward " + b.version;
if (!b.tag && b.commit) {
title = "Development build of commit " + b.commit + (b.modified ? " with uncommitted changes" : "");
if (b.since) title += b.ahead ? ", " + b.ahead + (b.ahead === 1 ? " commit" : " commits") + " after " + b.since : ", at " + b.since;
}
return h("footer", { class: "site" }, "Powered by ",
h("a", { href: b.url, target: "_blank", rel: "noopener", title }, "Steward " + (b.tag || b.commit ? b.label : b.version)));
}
function renderNav() {
const allowed = (n) => !n[3] || can(n[3], false);
const link = ([key, href, label]) => h("a", { href, class: current.nav === key ? "active" : "" }, label);
mount(navEl, navItems.map((n) => {
if (!n.menu) return allowed(n) ? link(n) : null;
const items = n.items.filter(allowed);
if (!items.length) return null;
return menu(n.menu, items.map(link), { active: items.some((i) => i[0] === current.nav), label: n.menu + " menu" });
}));
}
// menu is a drop-down that closes when an item is picked, on a click
// elsewhere and on Escape.
function menu(label, items, opts = {}) {
const d = h("details", { class: "menu" + (opts.right ? " right" : "") },
h("summary", { class: opts.active ? "active" : "", "aria-label": opts.label || null }, label, h("span", { class: "caret", "aria-hidden": "true" }, "▾")),
h("div", { class: "menu-list", role: "menu" }, items.filter(Boolean)));
d.addEventListener("click", (e) => { if (e.target.closest(".menu-list a, .menu-list button")) d.open = false; });
return d;
}
document.addEventListener("click", (e) => {
for (const d of document.querySelectorAll("details.menu[open]")) if (!d.contains(e.target)) d.open = false;
});
document.addEventListener("keydown", (e) => {
if (e.key !== "Escape") return;
for (const d of document.querySelectorAll("details.menu[open]")) { d.open = false; d.querySelector("summary").focus(); }
});
async function logout() {
try { await api("POST", "/auth/logout"); } catch { /* ignore */ }
state.me = null;
state.csrf = "";
window.removeEventListener("hashchange", route);
renderLogin("You have logged out.");
}
// stopTimers lets a page that refreshes itself stop when it is left.
let pageCleanup = null;
export function onLeave(fn) { pageCleanup = fn; }
async function route() {
if (!state.me) return;
if (pageCleanup) { pageCleanup(); pageCleanup = null; }
const { path, query } = parseHash();
for (const r of routes) {
const m = path.match(r.pattern);
if (!m) continue;
const params = {};
(r.params || []).forEach((p, i) => { params[p] = m[i + 1]; });
current = { key: path, nav: r.nav, page: r.page, params, query };
renderNav();
const gen = ++routeGen;
const old = mainEl.firstChild;
const slow = setTimeout(() => { if (gen === routeGen && mainEl.firstChild === old) mount(mainEl, h("div", { class: "muted" }, "Loading…")); }, 300);
try {
await r.page.render(mainEl, params, query);
} catch (e) {
if (gen === routeGen) mount(mainEl, h("h1", null, "Something went wrong"), errorBox(e));
} finally {
clearTimeout(slow);
}
return;
}
mount(mainEl, h("h1", null, "Not found"), h("p", null, h("a", { href: "#/" }, "Back to the dashboard")));
}
// rerender draws the current page again (after a change).
export function rerender() {
if (current.page) route();
}
setUnauthorizedHandler(() => {
if (state.me) {
state.me = null;
window.removeEventListener("hashchange", route);
renderLogin("Your session has expired. Please log in again.");
}
});
(async function boot() {
try {
await loggedIn(await get("/auth/whoami"));
} catch (e) {
if (e.status === 401) renderLogin();
else mount(app, h("div", { class: "login panel" }, h("h1", null, "Steward"), errorBox(e)));
}
})();
window.addEventListener("unhandledrejection", (e) => {
toast(e.reason && e.reason.message ? e.reason.message : String(e.reason), "err");
});
+67
View File
@@ -0,0 +1,67 @@
// The Steward logo and the login page's wordmark, drawn as inline SVG so
// they appear together with the page instead of loading afterwards.
//
// The "STEWARD" wordmark: the angular letters of Jarl's wordmark (its A
// and R are the same), drawn as paths so no font is needed. The login page
// shows it over a rule with a DHCP node; the header shows the letters
// alone. The letters use the text color and the rule the accent color.
import * as logoData from "./logo.js";
const NS = "http://www.w3.org/2000/svg";
function el(tag, attrs, ...children) {
const e = document.createElementNS(NS, tag);
for (const [k, v] of Object.entries(attrs || {})) e.setAttribute(k, v);
for (const c of children) e.append(c instanceof Node ? c : document.createTextNode(c));
return e;
}
// Letters on a 100-unit cap height; counters are cut with evenodd.
const letters = [
// S: squared, with a downward barb on the top bar and an upward one on
// the bottom bar (Jarl's L turned around)
{ x: 0, d: "M10 0 H54 L58 30 L40 16 H18 V41 H48 L58 51 V90 L48 100 H4 L0 70 L18 84 H40 V59 H10 L0 49 V10 Z" },
// T: a bar with barbed ends
{ x: 70, d: "M0 0 H70 L66 22 L52 18 H44 V100 H26 V18 H18 L4 22 Z" },
// E: the bottom arm ends in Jarl's L spike
{ x: 152, d: "M0 0 H52 L56 18 H18 V41 H44 V59 H18 V82 H40 L58 70 L54 100 H0 Z" },
// W: flat-topped middle peak
{ x: 222, d: "M0 0 H18 L28 66 L38 30 H52 L62 66 L72 0 H90 L76 100 H54 L45 66 L36 100 H14 Z" },
// A and R: Jarl's
{ x: 324, d: "M35 0 L46 10 L64 90 L70 100 H50 L44 76 L35 62 L26 76 L20 100 H0 L6 90 L24 10 Z M35 26 L41 40 L35 54 L29 40 Z" },
{ x: 406, d: "M0 0 H48 L60 12 V40 L50 50 L66 100 H46 L34 60 H18 V100 H0 Z M18 14 H41 L46 19 V35 L41 46 H18 Z" },
// D: cut corners, like R's bowl
{ x: 484, d: "M0 0 H44 L64 20 V80 L44 100 H0 Z M18 16 H38 L46 24 V76 L38 84 H18 Z" },
];
const W = 548; // the letters' width
// wordmark returns the login page's wordmark, letters over the rule.
// wordmark({ rule: false, height: 19, cls: "brand-name" }) returns just the
// letters, height pixels tall.
export function wordmark({ rule = true, height, cls = "wordmark" } = {}) {
const letterG = el("g", { fill: "currentColor", "fill-rule": "evenodd" },
...letters.map((l) => el("path", { d: l.d, transform: `translate(${l.x} 0)` })));
if (!rule) {
const attrs = { class: cls, viewBox: `-2 -2 ${W + 4} 104`, role: "img", "aria-label": "Steward" };
if (height) Object.assign(attrs, { height, width: Math.round(height * (W + 4) / 104) });
return el("svg", attrs, el("title", null, "Steward"), letterG);
}
const c = W / 2;
const ruleG = el("g", { class: "wordmark-rule", transform: "translate(0 118)" },
el("path", { d: `M2 18 L${c - 60} 15.6 V20.4 Z M${W - 2} 18 L${c + 60} 15.6 V20.4 Z`, class: "fill" }),
el("circle", { cx: c - 52, cy: 18, r: 4.6, class: "line" }),
el("path", { d: `M${c - 47.4} 18 H${c - 30} M${c + 30} 18 H${c + 47.4}`, class: "line" }),
el("circle", { cx: c, cy: 18, r: 30, class: "line" }),
el("circle", { cx: c + 52, cy: 18, r: 4.6, class: "line" }),
el("text", { x: c, y: 24.5, "text-anchor": "middle", class: "tag" }, "DHCP"));
return el("svg", { class: cls, viewBox: `-4 -4 ${W + 8} 162`, role: "img", "aria-label": "Steward" },
el("title", null, "Steward"), letterG, ruleG);
}
// logo returns the Steward logo (artwork/logo.svg, via scripts/logo.py) as
// an SVG of the given size in pixels.
export function logo(size, cls) {
return el("svg", { class: cls || "logo", width: size, height: size, viewBox: logoData.viewBox, "aria-hidden": "true", focusable: "false" },
...logoData.shapes.map(([tag, attrs]) => el(tag, attrs)));
}
+15
View File
@@ -0,0 +1,15 @@
// The Steward logo, from artwork/logo.svg by scripts/logo.py; don't edit.
// brand.js draws it; logo.svg is the same picture.
export const viewBox = "30 22 940 940";
export const shapes = [
["path", {"fill": "#fff", "stroke": "#1d5287", "stroke-width": "26", "stroke-linejoin": "miter", "d": "M452 540 V890 L478 940 H522 L548 890 V540 Z"}],
["path", {"fill": "#fff", "stroke": "#1d5287", "stroke-width": "26", "stroke-linejoin": "miter", "d": "M548 690 H688 V742 H640 V782 H712 V832 H664 V890 H548 Z"}],
["path", {"fill": "#fff", "stroke": "#1d5287", "stroke-width": "26", "stroke-linejoin": "miter", "d": "M420 450 H580 L606 498 L580 546 H420 L394 498 Z"}],
["path", {"fill": "#fff", "stroke": "#1d5287", "stroke-width": "26", "stroke-linejoin": "miter", "d": "M305 490 L245 135 L382 238 L500 45 L618 238 L755 135 L695 490 Q500 445 305 490 Z"}],
["path", {"d": "M287 418 Q500 368 713 418", "stroke": "#1d5287", "stroke-width": "22", "fill": "none"}],
["path", {"stroke": "#1d5287", "stroke-width": "22", "fill": "none", "d": "M500 200 V258 M463 312 L414 334 M537 312 L586 334"}],
["circle", {"stroke": "#1d5287", "stroke-width": "22", "fill": "#fff", "cx": "500", "cy": "170", "r": "30"}],
["circle", {"stroke": "#1d5287", "stroke-width": "22", "fill": "#fff", "cx": "390", "cy": "344", "r": "27"}],
["circle", {"stroke": "#1d5287", "stroke-width": "22", "fill": "#fff", "cx": "610", "cy": "344", "r": "27"}],
["circle", {"cx": "500", "cy": "296", "r": "40", "fill": "#1d5287"}],
];
+17
View File
@@ -0,0 +1,17 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="30 22 940 940">
<!-- The Steward logo: a key whose bow is a crown with Jarl's network nodes. The master; scripts/logo.py builds the web UI's copies. -->
<g fill="#fff" stroke="#1d5287" stroke-width="26" stroke-linejoin="miter">
<path d="M452 540 V890 L478 940 H522 L548 890 V540 Z"/>
<path d="M548 690 H688 V742 H640 V782 H712 V832 H664 V890 H548 Z"/>
<path d="M420 450 H580 L606 498 L580 546 H420 L394 498 Z"/>
<path d="M305 490 L245 135 L382 238 L500 45 L618 238 L755 135 L695 490 Q500 445 305 490 Z"/>
</g>
<path d="M287 418 Q500 368 713 418" stroke="#1d5287" stroke-width="22" fill="none"/>
<g stroke="#1d5287" stroke-width="22" fill="none">
<path d="M500 200 V258 M463 312 L414 334 M537 312 L586 334"/>
<circle cx="500" cy="170" r="30" fill="#fff"/>
<circle cx="390" cy="344" r="27" fill="#fff"/>
<circle cx="610" cy="344" r="27" fill="#fff"/>
</g>
<circle cx="500" cy="296" r="40" fill="#1d5287"/>
</svg>

After

Width:  |  Height:  |  Size: 976 B

@@ -0,0 +1,63 @@
// Your account: password and API tokens.
import { h, mount, table, time, modal, input, field, select, errorBox, toast, copyBox } from "../ui.js";
import { get, api, state, catalog } from "../api.js";
import { rerender } from "../app.js";
import { revoke } from "./users.js";
export async function render(el) {
const me = state.me;
const [tokens, cat] = await Promise.all([get("/me/tokens"), catalog()]);
const cur = input({ type: "password", autocomplete: "current-password" });
const pw = input({ type: "password", autocomplete: "new-password" });
const pw2 = input({ type: "password", autocomplete: "new-password" });
const err = h("div");
const change = async () => {
mount(err);
if (pw.value !== pw2.value) { mount(err, h("div", { class: "msg err" }, "The new passwords don't match.")); return; }
try {
await api("POST", "/me/password", { body: { current: cur.value, password: pw.value } });
toast("Password changed; your other sessions are logged out");
cur.value = pw.value = pw2.value = "";
} catch (e) { mount(err, errorBox(e)); }
};
mount(el,
h("div", { class: "page-head" }, h("div", { class: "grow" }, h("h1", null, me.user.username),
h("div", { class: "muted" }, me.admin ? "Administrator" : "May: " + (me.permissions.join(", ") || "nothing")))),
me.method === "session" ? h("div", { class: "panel" },
h("h2", null, "Password"),
h("div", { class: "form-grid" }, field("Current password", cur), field("New password", pw), field("Repeat", pw2)), err,
h("div", { class: "actions", style: { marginTop: "12px" } }, h("button", { class: "primary", onclick: change }, "Change password"))) : null,
h("div", { class: "panel" },
h("div", { class: "page-head" }, h("div", { class: "grow" }, h("h2", null, "API tokens"),
h("div", { class: "muted" }, "For the steward CLI and scripts: 'steward config set token'.")),
h("button", { onclick: () => newToken(cat) }, "New token")),
table([
{ label: "Name", render: (t) => t.name },
{ label: "May", render: (t) => h("span", { class: "mono small" }, t.grant_strings.join(" ")) },
{ label: "Expires", render: (t) => t.expires_at ? time(t.expires_at) : "never" },
{ label: "Last used", render: (t) => t.last_used_at ? time(t.last_used_at) : "never" },
{ label: "", tdClass: "actions-cell", render: (t) => h("button", { class: "small reveal", onclick: () => revoke(t) }, "Revoke") },
], tokens, { empty: "No tokens." })));
}
async function newToken(cat) {
const name = input({ placeholder: "laptop, backup script…" });
const role = select([["", "everything you may do"], ...Object.keys(cat.roles).map((r) => [r, r + ": " + cat.roles[r].join(" ")])], "");
const expires = select([["", "never"], ["30d", "30 days"], ["90d", "90 days"], ["365d", "a year"]], "90d");
const err = h("div");
await modal("New API token", h("div", null, h("div", { class: "form-grid" }, field("Name", name), field("May do", role), field("Expires", expires)), err), [
{ label: "Cancel", value: null },
{ label: "Create", kind: "primary", onclick: async () => {
mount(err);
try {
const t = await api("POST", "/me/tokens", { body: { name: name.value.trim(), role: role.value || undefined, expires_in: expires.value || undefined } });
await modal("Token created", h("div", null, h("p", null, "Copy it now: it is not shown again."), copyBox(t.secret)));
rerender();
return true;
} catch (e) {
mount(err, errorBox(e));
return false;
}
} },
]);
}
@@ -0,0 +1,39 @@
// Activity: the latest DHCP messages, refreshed every few seconds.
import { h, mount, input, select, debounce } from "../ui.js";
import { get } from "../api.js";
import { onLeave } from "../app.js";
export async function render(el, params, query) {
const ifaces = await get("/interfaces");
const mac = input({ placeholder: "MAC address", value: query.get("mac") || "", class: "mono" });
const iface = select([["", "all interfaces"], ...ifaces.filter((i) => i.subnets.length).map((i) => [i.name, i.name])], query.get("interface") || "");
const out = h("div");
let live = true;
const pause = h("button", { onclick: () => { live = !live; pause.textContent = live ? "Pause" : "Resume"; } }, "Pause");
const draw = async () => {
const evs = await get("/events", { mac: mac.value.trim(), interface: iface.value, limit: 300 });
mount(out, evs.length ? h("div", { class: "table-wrap" }, h("table", { class: "activity" },
h("thead", null, h("tr", null, ["Time", "Interface", "Message", "Client", "Address", "Host name", ""].map((t) => h("th", null, t)))),
h("tbody", null, evs.map((e) => {
const t = e.type.replace(/^DHCP/, "");
return h("tr", null,
h("td", { class: "mono small" }, new Date(e.time).toLocaleTimeString(undefined, { hourCycle: "h23" })),
h("td", { class: "mono" }, e.interface),
h("td", null, h("strong", { class: "type-" + t.split(" ")[0] }, t)),
h("td", { class: "mono" }, e.mac ? h("a", { href: "#", onclick: (ev) => { ev.preventDefault(); mac.value = e.mac; draw(); } }, e.mac) : ""),
h("td", { class: "mono" }, e.ip || ""),
h("td", null, e.hostname || ""),
h("td", { class: "detail" }, e.detail || ""));
})))) : h("div", { class: "empty" }, "No DHCP messages yet" + (mac.value || iface.value ? " that match." : ".")));
};
mac.addEventListener("input", debounce(draw, 300));
iface.addEventListener("change", draw);
mount(el,
h("div", { class: "page-head" }, h("div", { class: "grow" }, h("h1", null, "Activity"),
h("div", { class: "muted" }, "The latest DHCP messages Steward received and sent, newest first. Click a MAC address to follow one client."))),
h("div", { class: "filter-row" }, mac, iface, pause),
h("div", { class: "panel" }, out));
await draw();
const t = setInterval(() => { if (live) draw().catch(() => {}); }, 3000);
onLeave(() => clearInterval(t));
}
+25
View File
@@ -0,0 +1,25 @@
// The audit log: logins, users, tokens, changes, lease deletions.
import { h, mount, table, time, input, debounce, badge } from "../ui.js";
import { get } from "../api.js";
export async function render(el) {
const search = input({ type: "search", placeholder: "user, action, object or detail" });
const out = h("div");
const draw = async () => {
const q = search.value.trim();
const evs = await get("/audit", { q, limit: 300 });
mount(out, table([
{ label: "When", render: (e) => time(e.time) },
{ label: "User", render: (e) => [e.username || "–", e.auth ? h("span", { class: "muted small" }, " " + e.auth) : null] },
{ label: "From", render: (e) => h("span", { class: "mono small" }, e.ip || "") },
{ label: "Action", render: (e) => h("span", { class: "mono" }, e.action) },
{ label: "Object", render: (e) => e.object },
{ label: "Result", render: (e) => badge(e.result, e.result === "success" ? "ok" : "err") },
{ label: "Detail", render: (e) => h("span", { class: "small" }, e.detail) },
], evs, { empty: "Nothing logged" + (q ? " that matches." : " yet.") }));
};
search.addEventListener("input", debounce(draw, 300));
mount(el, h("div", { class: "page-head" }, h("div", { class: "grow" }, h("h1", null, "Audit log"))),
h("div", { class: "filter-row" }, search), h("div", { class: "panel" }, out));
await draw();
}
@@ -0,0 +1,71 @@
// The dashboard: interfaces, subnets and how full they are, problems,
// recent activity.
import { h, mount, problemsList, table, time, plural } from "../ui.js";
import { get, can } from "../api.js";
import { usageBar, subnetLabel } from "../widgets.js";
import { onLeave } from "../app.js";
export async function render(el) {
const draw = async () => {
const st = await get("/status");
const events = can("leases") ? await get("/events", { limit: 8 }) : [];
const leased = st.leases.active || 0;
const reservations = st.subnets.reduce((n, s) => n + s.usage.reservations, 0);
const poolSize = st.subnets.reduce((n, s) => n + (s.disabled ? 0 : s.usage.pool_size), 0);
const notServing = st.interfaces.filter((i) => !i.listening);
const errors = st.problems.filter((p) => p.severity === "error");
mount(el,
h("div", { class: "page-head" }, h("div", { class: "grow" }, h("h1", null, "Dashboard"))),
h("div", { class: "cards" },
card("Subnets", st.subnets.length, plural(st.interfaces.length, "interface"), "#/subnets"),
card("Active leases", leased, poolSize ? Math.round(leased * 100 / poolSize) + "% of " + poolSize + " pool addresses" : "no pools yet", "#/leases"),
card("Reservations", reservations, "fixed addresses", "#/reservations"),
card("Problems", st.problems.length, errors.length ? plural(errors.length, "error") : notServing.length ? plural(notServing.length, "interface") + " not serving" : "none", null)),
st.subnets.length === 0 ? h("div", { class: "panel" },
h("h2", null, "Getting started"),
h("p", null, "Steward serves no subnets yet. Add one for each network, on the interface its clients are on: ", h("a", { href: "#/subnets?new=1" }, "New subnet"), "."),
h("p", null, "Moving from Kea? On the server, ", h("code", null, "sudo steward import kea /etc/kea/kea-dhcp4.conf --leases /var/lib/kea/kea-leases4.csv"),
" brings over the subnets, reservations, options and current leases.")) : null,
h("div", { class: "panel" },
h("h2", null, "Interfaces"),
st.interfaces.length ? table([
{ label: "Interface", tdClass: "nowrap", render: (i) => [h("span", { class: "dot " + (i.listening ? "ok" : "err") }), h("span", { class: "mono" }, h("strong", null, i.name))] },
{ label: "Addresses", render: (i) => h("span", { class: "mono small" }, i.addresses.join(", ") || "none") },
{ label: "DHCP", render: (i) => i.listening ? h("span", { class: "muted" }, "serving") : h("span", { class: "err-text small" }, "not serving: " + i.error) },
{ label: "Subnets", render: (i) => i.subnets.map((id, n) => {
const s = st.subnets.find((x) => x.id === id);
return [n ? ", " : "", s ? h("a", { href: "#/subnets/" + id }, subnetLabel(s)) : "#" + id];
}) },
], st.interfaces) : h("div", { class: "muted" }, "DHCP is served on the interfaces that have subnets.")),
st.subnets.length ? h("div", { class: "panel" },
h("h2", null, "Subnets"),
table([
{ label: "Subnet", render: (s) => h("a", { href: "#/subnets/" + s.id }, subnetLabel(s)) },
{ label: "Interface", render: (s) => h("span", { class: "mono" }, s.interface) },
{ label: "Pools in use", render: (s) => s.disabled ? h("span", { class: "badge off" }, "disabled") : usageBar(s.usage) },
{ label: "Reservations", render: (s) => s.usage.reservations ? s.usage.reservations_leased + " of " + s.usage.reservations + " online" : "–" },
], st.subnets)) : null,
st.problems.length || st.ping_problem ? h("div", { class: "panel" },
h("h2", null, "Problems"),
problemsList(st.problems),
st.ping_problem ? h("div", { class: "msg warn" }, st.ping_problem) : null) : null,
events.length ? h("div", { class: "panel" },
h("h2", null, "Latest DHCP messages ", h("a", { href: "#/activity", class: "small" }, "all")),
table([
{ label: "When", render: (e) => time(e.time) },
{ label: "Interface", render: (e) => e.interface },
{ label: "Message", render: (e) => h("span", { class: "type-" + e.type.replace(/^DHCP/, "").split(" ")[0] }, e.type.replace(/^DHCP/, "")) },
{ label: "Client", render: (e) => h("span", { class: "mono" }, e.mac || "") },
{ label: "Address", render: (e) => h("span", { class: "mono" }, e.ip || "") },
{ label: "Host name", render: (e) => e.hostname || "" },
], events, { })) : null);
};
await draw();
const t = setInterval(() => { draw().catch(() => {}); }, 15000);
onLeave(() => clearInterval(t));
}
function card(label, value, sub, href) {
const body = [h("div", { class: "label" }, label), h("div", { class: "value" }, String(value)), h("div", { class: "sub" }, sub)];
return href ? h("a", { class: "card link", href }, body) : h("div", { class: "card" }, body);
}
@@ -0,0 +1,69 @@
// History: every configuration change, with undo.
import { h, mount, table, time, confirmDialog, modal, errorBox, badge } from "../ui.js";
import { get, change, can } from "../api.js";
import { rerender } from "../app.js";
export async function render(el, params) {
if (params.id) return entry(el, params.id);
const hs = await get("/history", { limit: 200 });
mount(el,
h("div", { class: "page-head" }, h("div", { class: "grow" }, h("h1", null, "History"),
h("div", { class: "muted" }, "Every configuration change, newest first. Undo applies a change backwards, if nothing it touched has changed since."))),
h("div", { class: "panel" }, table([
{ label: "#", render: (e) => h("a", { href: "#/history/" + e.id }, String(e.id)) },
{ label: "When", render: (e) => time(e.time) },
{ label: "By", render: (e) => [e.username, h("span", { class: "muted small" }, " via " + e.via)] },
{ label: "Change", render: (e) => [h("a", { href: "#/history/" + e.id }, e.description),
e.reverted_by ? [" ", badge("undone by #" + e.reverted_by, "")] : null, e.revert_of ? [" ", badge("undoes #" + e.revert_of, "accent")] : null] },
can("config", true) ? { label: "", tdClass: "actions-cell", render: (e) => e.reverted_by ? null : h("button", { class: "small reveal", onclick: () => undo(e) }, "Undo") } : null,
].filter(Boolean), hs, { empty: "No changes yet." })));
}
async function undo(e) {
if (!await confirmDialog("Undo change #" + e.id, "Undo “" + e.description + "”? This is recorded as a new change, which can be undone too.", "Undo")) return;
try {
await change("POST", "/history/" + e.id + "/revert");
rerender();
} catch (err) { modal("Can't undo", errorBox(err)); }
}
async function entry(el, id) {
const e = await get("/history/" + id);
mount(el,
h("nav", { class: "back-link" }, h("a", { href: "#/history" }, "← History")),
h("div", { class: "page-head" },
h("div", { class: "grow" }, h("h1", null, "#" + e.id + ": " + e.description),
h("div", { class: "muted" }, new Date(e.time).toLocaleString(), " by ", e.username, " via ", e.via,
e.reverted_by ? [" · undone by ", h("a", { href: "#/history/" + e.reverted_by }, "#" + e.reverted_by)] : null,
e.revert_of ? [" · undoes ", h("a", { href: "#/history/" + e.revert_of }, "#" + e.revert_of)] : null)),
can("config", true) && !e.reverted_by ? h("div", { class: "actions" }, h("button", { onclick: () => undo(e) }, "Undo")) : null),
e.changes.map((c) => h("div", { class: "panel" },
h("h3", null, action(c) + " " + c.kind + " ", h("span", { class: "mono" }, c.label)),
fieldsTable(c))));
}
function action(c) {
if (!c.before) return "Created";
if (!c.after) return "Deleted";
return "Changed";
}
// fieldsTable lists the fields that differ (all of them for a create or
// delete).
function fieldsTable(c) {
const a = c.before || {}, b = c.after || {};
const keys = [...new Set([...Object.keys(a), ...Object.keys(b)])].sort();
const show = (v, k) => {
if (v === undefined || v === "") return "–";
if (k === "options") return v.length ? v.map((o) => o.name + "=" + o.value).join("; ") : "none";
if (k === "pools") return v.length ? v.map((p) => p.start + "-" + p.end).join(", ") : "none";
if (k === "lease_time" || k === "decline_hold") return v ? v + "s" : "default";
return typeof v === "string" ? v : JSON.stringify(v);
};
const rows = keys.filter((k) => k !== "id").filter((k) => JSON.stringify(a[k]) !== JSON.stringify(b[k]) || !c.before || !c.after);
return table([
{ label: "Field", render: (k) => h("strong", null, k) },
c.before ? { label: "Before", render: (k) => h("span", { class: "mono small" }, show(a[k], k)) } : null,
c.after ? { label: c.before ? "After" : "Value", render: (k) => h("span", { class: "mono small" }, show(b[k], k)) } : null,
].filter(Boolean), rows);
}
@@ -0,0 +1,91 @@
// Leases: every address handed out, with filters; reserve or delete one.
import { h, mount, table, time, input, select, confirmDialog, toast, modal, errorBox, debounce, badge } from "../ui.js";
import { get, api, change, can } from "../api.js";
import { stateText, subnetLabel } from "../widgets.js";
// leaseHost is the host name to show: the reservation's, or what the
// client said.
function leaseHost(l) {
if (l.reserved_hostname && l.hostname && l.reserved_hostname !== l.hostname) return [l.reserved_hostname, h("span", { class: "muted small" }, " (says " + l.hostname + ")")];
return l.reserved_hostname || l.hostname || h("span", { class: "muted" }, "–");
}
// leasesTable shows leases with their actions; after a change, done() is
// called.
export function leasesTable(leases, done, opts = {}) {
const write = can("leases", true), config = can("config", true);
return table([
{ label: "Address", render: (l) => h("span", { class: "mono" }, l.ip) },
{ label: "MAC address", render: (l) => h("span", { class: "mono" }, l.mac || "–") },
{ label: "Host name", render: leaseHost },
{ label: "State", render: (l) => [stateBadge(l.state), l.reservation ? [" ", badge("reserved", "accent")] : null] },
{ label: "Ends", render: (l) => time(l.end) },
opts.subnet ? null : { label: "Subnet", render: (l) => l.subnet || "–" },
{ label: "Interface", render: (l) => h("span", { class: "mono" }, l.interface) },
{ label: "", tdClass: "actions-cell", render: (l) => [
config && !l.reservation && l.mac && l.state !== "declined" ? h("button", { class: "small reveal", title: "Give this client this address from now on", onclick: () => reserve(l, done) }, "Reserve") : null, " ",
write ? h("button", { class: "small reveal", title: "Forget this lease: the address is free at once", onclick: () => del(l, done) }, "Delete") : null,
] },
].filter(Boolean), leases, { empty: opts.empty || "No leases." });
}
function stateBadge(st) {
return badge(stateText(st), { active: "ok", declined: "warn" }[st] || "");
}
async function reserve(l, done) {
const host = input({ value: l.hostname || "", placeholder: "host name (optional)" });
const err = h("div");
await modal("Reserve " + l.ip, h("div", null,
h("p", null, l.mac + " keeps " + l.ip + " from now on."),
h("div", { class: "form-grid" }, h("label", null, "Host name"), host), err), [
{ label: "Cancel", value: null },
{ label: "Reserve", kind: "primary", onclick: async () => {
try {
await change("POST", "/leases/" + l.ip + "/reserve", { hostname: host.value.trim() });
done();
return true;
} catch (e) {
mount(err, errorBox(e));
return false;
}
} },
]);
}
async function del(l, done) {
if (!await confirmDialog("Delete lease", "Forget the lease of " + l.ip + (l.mac ? " (" + l.mac + ")" : "") + "? The address is free at once; if the client still uses it, it gets a NAK when it next renews and asks for a new address.", "Delete", "danger")) return;
try {
await api("DELETE", "/leases/" + l.ip);
toast("Deleted the lease of " + l.ip);
done();
} catch (e) { toast(e.message, "err", 8000); }
}
export async function render(el, params, query) {
const subnets = await get("/subnets");
const subnetSel = select([["", "all subnets"], ...subnets.map((s) => [String(s.id), subnetLabel(s)])], query.get("subnet") || "");
const stateSel = select([["current", "active and held back"], ["active", "active"], ["expired", "expired"], ["released", "released"], ["declined", "held back"], ["", "all"]], query.get("state") || "current");
const search = input({ type: "search", placeholder: "address, MAC or host name", value: query.get("q") || "" });
const out = h("div");
const draw = async () => {
const st = stateSel.value === "current" ? "" : stateSel.value;
let leases = await get("/leases", { subnet: subnetSel.value, state: st, q: search.value.trim() });
if (stateSel.value === "current") leases = leases.filter((l) => l.state === "active" || l.state === "declined");
const q = new URLSearchParams();
if (subnetSel.value) q.set("subnet", subnetSel.value);
if (stateSel.value !== "current") q.set("state", stateSel.value);
if (search.value.trim()) q.set("q", search.value.trim());
history.replaceState(null, "", "#/leases" + (q.toString() ? "?" + q : ""));
mount(out, h("div", { class: "muted small" }, leases.length + (leases.length === 1 ? " lease" : " leases")), leasesTable(leases, draw));
};
subnetSel.addEventListener("change", draw);
stateSel.addEventListener("change", draw);
search.addEventListener("input", debounce(draw, 300));
mount(el,
h("div", { class: "page-head" }, h("div", { class: "grow" }, h("h1", null, "Leases"),
h("div", { class: "muted" }, "Addresses handed out. Expired and released leases are kept a while, so a client that comes back gets its old address."))),
h("div", { class: "filter-row" }, subnetSel, stateSel, search, h("button", { onclick: draw }, "Refresh")),
h("div", { class: "panel" }, out));
await draw();
}
@@ -0,0 +1,90 @@
// Reservations: fixed addresses for clients.
import { h, mount, table, input, modal, errorBox, field, confirmDialog, select, debounce } from "../ui.js";
import { get, change, can } from "../api.js";
import { optionsEditor, subnetLabel } from "../widgets.js";
// reservationsTable lists reservations with Edit and Delete.
export function reservationsTable(rs, done, opts = {}) {
const write = can("config", true);
return table([
{ label: "Address", render: (r) => h("span", { class: "mono" }, r.ip) },
{ label: "Client", render: (r) => h("span", { class: "mono" }, r.client_id ? "id " + r.client_id : r.mac) },
{ label: "Host name", render: (r) => r.hostname || h("span", { class: "muted" }, "–") },
opts.subnet ? null : { label: "Subnet", render: (r) => r.subnet_id ? h("a", { href: "#/subnets/" + r.subnet_id }, r.subnet) : "–" },
{ label: "Online", render: (r) => r.lease ? h("span", { title: "lease until " + new Date(r.lease.end).toLocaleString() }, "yes, on " + r.lease.interface) : h("span", { class: "muted" }, "no") },
{ label: "Options", render: (r) => r.options.length ? h("span", { class: "mono small" }, r.options.map((o) => o.name + "=" + o.value).join("; ")) : "" },
{ label: "Comment", render: (r) => r.comment },
write ? { label: "", tdClass: "actions-cell", render: (r) => [
h("button", { class: "small reveal", onclick: () => reservationDialog(r, done) }, "Edit"), " ",
h("button", { class: "small reveal", onclick: () => del(r, done) }, "Delete"),
] } : null,
].filter(Boolean), rs, { empty: opts.empty || "No reservations." });
}
async function del(r, done) {
if (!await confirmDialog("Delete reservation", "Delete the reservation of " + r.ip + " for " + (r.hostname || r.mac || r.client_id) + "? The client keeps its address until its lease runs out.", "Delete", "danger")) return;
try {
await change("DELETE", "/reservations/" + r.id);
done();
} catch (e) { modal("Not deleted", errorBox(e)); }
}
// reservationDialog adds (r null) or edits a reservation. preset may give
// {ip, mac, hostname} for a new one.
export async function reservationDialog(r, done, preset = {}) {
const [settings, subnets] = await Promise.all([get("/settings"), get("/subnets")]);
// What the client gets from above: the settings', then its subnet's.
const sub = subnets.find((x) => x.id === (r && r.subnet_id));
const inherited = [...settings.options.filter((o) => !(sub && sub.options.some((x) => x.code === o.code))), ...(sub ? sub.options : [])];
const ip = input({ value: r ? r.ip : preset.ip || "", placeholder: "10.0.10.20", class: "mono" });
const mac = input({ value: r ? r.mac || "" : preset.mac || "", placeholder: "aa:bb:cc:dd:ee:ff", class: "mono" });
const cid = input({ value: r ? r.client_id || "" : "", placeholder: "only for clients known by a client identifier", class: "mono" });
const host = input({ value: r ? r.hostname : preset.hostname || "", placeholder: "nas" });
const opts = await optionsEditor(r ? r.options : [], inherited);
const comment = input({ value: r ? r.comment : "" });
const err = h("div");
const body = h("div", null, h("div", { class: "form-grid" },
field("Address", ip, "Its subnet is the one whose network holds it. Inside a pool is fine: nobody else gets it."),
field("MAC address", mac),
field("Client identifier", cid, "Optional: match the client's identifier (option 61, hex) instead of its MAC address."),
field("Host name", host, "Sent to the client."),
field("Options", opts, "Only for this client; they win over the subnet's."),
field("Comment", comment)), err);
await modal(r ? "Edit reservation " + r.ip : "New reservation", body, [
{ label: "Cancel", value: null },
{ label: r ? "Save" : "Add reservation", kind: "primary", onclick: async () => {
mount(err);
const v = { ip: ip.value.trim(), mac: mac.value.trim(), client_id: cid.value.trim(), hostname: host.value.trim(), options: opts.value(), comment: comment.value };
try {
await change(r ? "PATCH" : "POST", r ? "/reservations/" + r.id : "/reservations", v);
done();
return true;
} catch (e) {
mount(err, errorBox(e));
return false;
}
} },
], { wide: true, sticky: true });
}
export async function render(el, params, query) {
const subnets = await get("/subnets");
const subnetSel = select([["", "all subnets"], ...subnets.map((s) => [String(s.id), subnetLabel(s)])], query.get("subnet") || "");
const search = input({ type: "search", placeholder: "address, MAC or host name" });
const out = h("div");
const draw = async () => {
const q = search.value.trim().toLowerCase();
const rs = (await get("/reservations", { subnet: subnetSel.value })).filter((r) =>
!q || r.ip.includes(q) || (r.mac || "").includes(q) || (r.hostname || "").toLowerCase().includes(q) || (r.client_id || "").includes(q));
mount(out, reservationsTable(rs, draw, { empty: q || subnetSel.value ? "None match." : "No reservations yet." }));
};
subnetSel.addEventListener("change", draw);
search.addEventListener("input", debounce(draw, 200));
mount(el,
h("div", { class: "page-head" },
h("div", { class: "grow" }, h("h1", null, "Reservations"), h("div", { class: "muted" }, "Fixed addresses for clients, by MAC address or client identifier.")),
can("config", true) ? h("div", { class: "actions" }, h("button", { class: "primary", onclick: () => reservationDialog(null, draw) }, "New reservation")) : null),
h("div", { class: "filter-row" }, subnetSel, search),
h("div", { class: "panel" }, out));
await draw();
}
@@ -0,0 +1,43 @@
// Settings: the default lease time, conflict checks, authority, and the
// options every subnet gets.
import { h, mount, input, field, checkbox, errorBox } from "../ui.js";
import { get, change, can } from "../api.js";
import { optionsEditor, fmtSeconds, parseSeconds } from "../widgets.js";
import { rerender } from "../app.js";
export async function render(el) {
const s = await get("/settings");
const write = can("config", true);
const lease = input({ value: fmtSeconds(s.lease_time || 43200) });
const ping = checkbox("Ping an address before offering it, and skip it if something answers", s.ping_check);
const timeout = input({ type: "number", min: 50, max: 5000, value: s.ping_timeout_ms || 500 });
timeout.style.width = "7em";
const hold = input({ value: fmtSeconds(s.decline_hold || 86400) });
const auth = checkbox("Authoritative: refuse (NAK) requests for addresses that don't belong on the interface", !s.not_authoritative);
const opts = await optionsEditor(s.options);
const err = h("div");
const save = async (dry) => {
mount(err);
const lt = parseSeconds(lease.value), dh = parseSeconds(hold.value);
if (lt === null || dh === null) { mount(err, h("div", { class: "msg err" }, "Write times as 12h, 1d or 30m.")); return; }
try {
const r = await change("PATCH", "/settings", { lease_time: lt, ping_check: ping.input.checked, ping_timeout_ms: parseInt(timeout.value, 10) || 0,
decline_hold: dh, not_authoritative: !auth.input.checked, options: opts.value() }, { query: dry ? { dry_run: 1 } : undefined });
if (!dry && r.applied) rerender();
} catch (e) { mount(err, errorBox(e)); }
};
mount(el,
h("div", { class: "page-head" }, h("div", { class: "grow" }, h("h1", null, "Settings and options"),
h("div", { class: "muted" }, "Server-wide settings, and the options every subnet sends. A subnet's or a reservation's own option of the same name wins."))),
h("div", { class: "panel" },
h("div", { class: "form-grid" },
field("Default lease time", lease, "How long clients keep an address unless a subnet says otherwise (12h, 1d). They renew halfway."),
field("Options for every subnet", opts, "Usually domain-name-servers and domain-name; routers belong to each subnet."),
field("Conflict check", h("div", null, ping, h("div", { class: "small" }, "wait ", timeout, " ms for an answer"))),
field("Hold back for", hold, "How long an address a client declined (or one that answered a ping) isn't handed out."),
field("Authority", auth, "Turn this off only if another DHCP server serves the same network.")),
err,
write ? h("div", { class: "actions", style: { marginTop: "14px" } },
h("button", { class: "primary", onclick: () => save(false) }, "Save"),
h("button", { onclick: () => save(true), title: "Check without saving" }, "Check")) : null));
}
@@ -0,0 +1,83 @@
// A subnet's page: its leases, its reservations and its settings.
import { h, mount, badge, errorBox, confirmDialog, modal, input, debounce } from "../ui.js";
import { get, change, can } from "../api.js";
import { usageBar, subnetForm, fmtSeconds, subnetLabel } from "../widgets.js";
import { leasesTable } from "./leases.js";
import { reservationsTable, reservationDialog } from "./reservations.js";
import { rerender } from "../app.js";
const tabs = [["leases", "Leases"], ["reservations", "Reservations"], ["settings", "Settings"]];
export async function render(el, params) {
const id = params.id;
const tab = params.tab || "leases";
const s = await get("/subnets/" + id);
const body = h("div");
mount(el,
h("nav", { class: "back-link" }, h("a", { href: "#/subnets" }, "← Subnets")),
h("div", { class: "page-head" },
h("div", { class: "grow" },
h("h1", null, subnetLabel(s), " ", s.disabled ? badge("disabled", "off") : null),
h("div", { class: "muted" }, "On ", h("span", { class: "mono" }, s.interface), " · pools ",
s.pools.length ? h("span", { class: "mono" }, s.pools.map((p) => p.start + "-" + p.end).join(", ")) : "none",
" · lease time ", s.lease_time ? fmtSeconds(s.lease_time) : "the default")),
h("div", { class: "subnet-usage" }, usageBar(s.usage))),
h("div", { class: "tabs" }, tabs.map(([k, label]) => h("a", { href: "#/subnets/" + id + "/" + k, class: tab === k ? "active" : "" }, label))),
body);
if (tab === "leases") await leasesTab(body, s);
else if (tab === "reservations") await reservationsTab(body, s);
else await settingsTab(body, s);
}
async function leasesTab(el, s) {
const search = input({ type: "search", placeholder: "address, MAC or host name" });
const out = h("div");
let all = false;
const toggle = h("a", { href: "#", class: "small", onclick: (e) => { e.preventDefault(); all = !all; toggle.textContent = all ? "Only current leases" : "Also expired and released"; draw(); } }, "Also expired and released");
const draw = async () => {
let ls = await get("/leases", { subnet: s.id, q: search.value.trim() });
if (!all) ls = ls.filter((l) => l.state === "active" || l.state === "declined");
mount(out, leasesTable(ls, rerender, { subnet: true, empty: all ? "No leases." : "No current leases." }));
};
search.addEventListener("input", debounce(draw, 300));
mount(el, h("div", { class: "filter-row" }, search, toggle), h("div", { class: "panel" }, out));
await draw();
}
async function reservationsTab(el, s) {
const rs = await get("/reservations", { subnet: s.id });
mount(el,
can("config", true) ? h("div", { class: "filter-row" }, h("button", { class: "primary", onclick: () => reservationDialog(null, rerender) }, "New reservation")) : null,
h("div", { class: "panel" }, reservationsTable(rs, rerender, { subnet: true, empty: "No reservations in this subnet." })));
}
async function settingsTab(el, s) {
const settings = await get("/settings");
if (!can("config", true)) {
mount(el, h("div", { class: "panel" }, h("pre", null, JSON.stringify(s, null, 2))));
return;
}
const form = await subnetForm(s, settings);
const err = h("div");
const save = async (dry) => {
mount(err);
try {
const r = await change("PATCH", "/subnets/" + s.id, form.values(), { query: dry ? { dry_run: 1 } : undefined });
if (!dry && r.applied) rerender();
} catch (e) { mount(err, errorBox(e)); }
};
const del = async () => {
const n = s.usage.reservations;
if (!await confirmDialog("Delete subnet", "Delete " + subnetLabel(s) + (n ? " and its " + n + (n === 1 ? " reservation" : " reservations") : "") + "? Its clients get no answers from then on; their leases are kept until they run out. History can undo it.", "Delete", "danger")) return;
try {
await change("DELETE", "/subnets/" + s.id);
location.hash = "#/subnets";
} catch (e) { modal("Not deleted", errorBox(e)); }
};
mount(el, h("div", { class: "panel" }, form, err,
h("div", { class: "actions", style: { marginTop: "14px" } },
h("button", { class: "primary", onclick: () => save(false) }, "Save"),
h("button", { onclick: () => save(true), title: "Check the change without saving it" }, "Check"),
h("span", { class: "grow" }),
h("button", { class: "danger", onclick: del }, "Delete subnet"))));
}
@@ -0,0 +1,45 @@
// The subnets, and the New subnet dialog.
import { h, mount, table, modal, errorBox, badge } from "../ui.js";
import { get, change, can } from "../api.js";
import { usageBar, subnetForm, fmtSeconds, subnetLabel } from "../widgets.js";
export async function render(el, params, query) {
const subnets = await get("/subnets");
const poolsText = (s) => s.pools.length ? s.pools.map((p) => p.start + "-" + p.end).join(", ") : "–";
mount(el,
h("div", { class: "page-head" },
h("div", { class: "grow" }, h("h1", null, "Subnets"),
h("div", { class: "muted" }, "Each subnet is served on one interface: only clients on that interface get its addresses.")),
can("config", true) ? h("div", { class: "actions" }, h("button", { class: "primary", onclick: () => newSubnet() }, "New subnet")) : null),
h("div", { class: "panel" }, table([
{ label: "Subnet", render: (s) => h("a", { href: "#/subnets/" + s.id }, subnetLabel(s)) },
{ label: "Interface", render: (s) => h("span", { class: "mono" }, s.interface) },
{ label: "Pools", render: (s) => h("span", { class: "mono small" }, poolsText(s)) },
{ label: "In use", render: (s) => usageBar(s.usage) },
{ label: "Reserved", class: "num", tdClass: "num", render: (s) => String(s.usage.reservations) },
{ label: "Lease time", render: (s) => s.lease_time ? fmtSeconds(s.lease_time) : h("span", { class: "muted" }, "default") },
{ label: "", render: (s) => s.disabled ? badge("disabled", "off") : null },
], subnets, { empty: "No subnets yet. Add one for each network Steward should serve." })));
if (query.get("new") === "1" && can("config", true)) newSubnet();
}
export async function newSubnet() {
const settings = await get("/settings");
const form = await subnetForm(null, settings);
const err = h("div");
const res = await modal("New subnet", h("div", null, form, err), [
{ label: "Cancel", value: null },
{ label: "Add subnet", kind: "primary", onclick: async () => {
mount(err);
try {
const r = await change("POST", "/subnets", form.values());
location.hash = "#/subnets/" + (r.object ? r.object.id : "");
return true;
} catch (e) {
mount(err, errorBox(e));
return false;
}
} },
], { wide: true, sticky: true });
return res;
}
Loaded 100 of 111 files, more files were not shown because too many files have changed in this diff. Show more