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