From 3c0808591f46b3f91e4707fbd6939d4defac3ff0 Mon Sep 17 00:00:00 2001 From: abrown38 Date: Wed, 14 Oct 2026 12:14:07 +0100 Subject: [PATCH] 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 --- .gitea/workflows/release.yml | 69 ++ .gitignore | 7 + AGENTS.md | 103 +++ CHANGELOG.md | 93 +++ CLAUDE.md | 8 + LICENSE | 21 + Makefile | 145 ++++ README.md | 103 +++ VERSION | 1 + artwork/logo.svg | 17 + cmd/steward/admin.go | 700 ++++++++++++++++++ cmd/steward/leases.go | 404 ++++++++++ cmd/steward/main.go | 493 ++++++++++++ cmd/steward/main_test.go | 44 ++ cmd/steward/subnets.go | 507 +++++++++++++ cmd/stewardd/lock.go | 40 + cmd/stewardd/main.go | 614 +++++++++++++++ deploy/debian/control-steward-cli.in | 16 + deploy/debian/control-steward.in | 21 + deploy/debian/postinst | 57 ++ deploy/debian/postrm | 35 + deploy/debian/prerm | 16 + deploy/systemd/steward-backup.service | 44 ++ deploy/systemd/steward-backup.timer | 20 + deploy/systemd/steward.service | 63 ++ deploy/sysusers.d/steward.conf | 2 + deploy/tmpfiles.d/steward.conf | 8 + docs/api.md | 137 ++++ docs/cli.md | 160 ++++ docs/configuration.md | 44 ++ docs/design.md | 90 +++ docs/development.md | 124 ++++ docs/install.md | 105 +++ docs/kea.md | 111 +++ docs/operations.md | 76 ++ docs/options.md | 103 +++ docs/permissions.md | 56 ++ docs/subnets.md | 155 ++++ docs/validation.md | 65 ++ go.mod | 23 + go.sum | 38 + internal/api/types.go | 246 ++++++ internal/auth/auth.go | 281 +++++++ internal/auth/auth_test.go | 53 ++ internal/client/client.go | 166 +++++ internal/config/config.go | 238 ++++++ internal/config/config_test.go | 42 ++ internal/dhcp4/catalog.go | 444 +++++++++++ internal/dhcp4/dhcp4_test.go | 178 +++++ internal/dhcp4/packet.go | 413 +++++++++++ internal/dhcpd/config.go | 139 ++++ internal/dhcpd/handle.go | 476 ++++++++++++ internal/dhcpd/handle_test.go | 446 +++++++++++ internal/dhcpd/listen.go | 211 ++++++ internal/dhcpd/listen_linux.go | 40 + internal/dhcpd/listen_other.go | 9 + internal/dhcpd/ping.go | 84 +++ internal/dhcpd/server.go | 371 ++++++++++ internal/engine/engine.go | 459 ++++++++++++ internal/engine/engine_test.go | 140 ++++ internal/kea/kea.go | 580 +++++++++++++++ internal/kea/kea_test.go | 170 +++++ internal/model/auth.go | 123 +++ internal/model/config.go | 336 +++++++++ internal/model/lease.go | 66 ++ internal/server/authn.go | 281 +++++++ internal/server/h_config.go | 601 +++++++++++++++ internal/server/h_import.go | 149 ++++ internal/server/h_leases.go | 207 ++++++ internal/server/h_users.go | 404 ++++++++++ internal/server/respond.go | 192 +++++ internal/server/routes.go | 119 +++ internal/server/server.go | 429 +++++++++++ internal/server/server_test.go | 337 +++++++++ internal/server/socket.go | 130 ++++ internal/store/history.go | 65 ++ internal/store/leases.go | 79 ++ internal/store/state.go | 182 +++++ internal/store/store.go | 321 ++++++++ internal/store/store_test.go | 128 ++++ internal/store/users.go | 372 ++++++++++ internal/validate/validate.go | 335 +++++++++ internal/validate/validate_test.go | 129 ++++ internal/version/version.go | 150 ++++ internal/version/version_test.go | 49 ++ internal/web/static/assets/api.js | 82 ++ internal/web/static/assets/app.js | 209 ++++++ internal/web/static/assets/brand.js | 67 ++ internal/web/static/assets/logo.js | 15 + internal/web/static/assets/logo.svg | 17 + internal/web/static/assets/pages/account.js | 63 ++ internal/web/static/assets/pages/activity.js | 39 + internal/web/static/assets/pages/audit.js | 25 + internal/web/static/assets/pages/dashboard.js | 71 ++ internal/web/static/assets/pages/history.js | 69 ++ internal/web/static/assets/pages/leases.js | 91 +++ .../web/static/assets/pages/reservations.js | 90 +++ internal/web/static/assets/pages/settings.js | 43 ++ internal/web/static/assets/pages/subnet.js | 83 +++ internal/web/static/assets/pages/subnets.js | 45 ++ internal/web/static/assets/pages/users.js | 85 +++ internal/web/static/assets/style.css | 353 +++++++++ internal/web/static/assets/ui.js | 228 ++++++ internal/web/static/assets/widgets.js | 218 ++++++ internal/web/static/index.html | 16 + internal/web/web.go | 26 + scripts/e2e.sh | 201 +++++ scripts/gitea-release.sh | 150 ++++ scripts/logo.py | 57 ++ scripts/mkdeb.sh | 117 +++ scripts/optionsdoc.py | 83 +++ 111 files changed, 17851 insertions(+) create mode 100644 .gitea/workflows/release.yml create mode 100644 .gitignore create mode 100644 AGENTS.md create mode 100644 CHANGELOG.md create mode 100644 CLAUDE.md create mode 100644 LICENSE create mode 100644 Makefile create mode 100644 README.md create mode 100644 VERSION create mode 100644 artwork/logo.svg create mode 100644 cmd/steward/admin.go create mode 100644 cmd/steward/leases.go create mode 100644 cmd/steward/main.go create mode 100644 cmd/steward/main_test.go create mode 100644 cmd/steward/subnets.go create mode 100644 cmd/stewardd/lock.go create mode 100644 cmd/stewardd/main.go create mode 100644 deploy/debian/control-steward-cli.in create mode 100644 deploy/debian/control-steward.in create mode 100755 deploy/debian/postinst create mode 100755 deploy/debian/postrm create mode 100755 deploy/debian/prerm create mode 100644 deploy/systemd/steward-backup.service create mode 100644 deploy/systemd/steward-backup.timer create mode 100644 deploy/systemd/steward.service create mode 100644 deploy/sysusers.d/steward.conf create mode 100644 deploy/tmpfiles.d/steward.conf create mode 100644 docs/api.md create mode 100644 docs/cli.md create mode 100644 docs/configuration.md create mode 100644 docs/design.md create mode 100644 docs/development.md create mode 100644 docs/install.md create mode 100644 docs/kea.md create mode 100644 docs/operations.md create mode 100644 docs/options.md create mode 100644 docs/permissions.md create mode 100644 docs/subnets.md create mode 100644 docs/validation.md create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/api/types.go create mode 100644 internal/auth/auth.go create mode 100644 internal/auth/auth_test.go create mode 100644 internal/client/client.go create mode 100644 internal/config/config.go create mode 100644 internal/config/config_test.go create mode 100644 internal/dhcp4/catalog.go create mode 100644 internal/dhcp4/dhcp4_test.go create mode 100644 internal/dhcp4/packet.go create mode 100644 internal/dhcpd/config.go create mode 100644 internal/dhcpd/handle.go create mode 100644 internal/dhcpd/handle_test.go create mode 100644 internal/dhcpd/listen.go create mode 100644 internal/dhcpd/listen_linux.go create mode 100644 internal/dhcpd/listen_other.go create mode 100644 internal/dhcpd/ping.go create mode 100644 internal/dhcpd/server.go create mode 100644 internal/engine/engine.go create mode 100644 internal/engine/engine_test.go create mode 100644 internal/kea/kea.go create mode 100644 internal/kea/kea_test.go create mode 100644 internal/model/auth.go create mode 100644 internal/model/config.go create mode 100644 internal/model/lease.go create mode 100644 internal/server/authn.go create mode 100644 internal/server/h_config.go create mode 100644 internal/server/h_import.go create mode 100644 internal/server/h_leases.go create mode 100644 internal/server/h_users.go create mode 100644 internal/server/respond.go create mode 100644 internal/server/routes.go create mode 100644 internal/server/server.go create mode 100644 internal/server/server_test.go create mode 100644 internal/server/socket.go create mode 100644 internal/store/history.go create mode 100644 internal/store/leases.go create mode 100644 internal/store/state.go create mode 100644 internal/store/store.go create mode 100644 internal/store/store_test.go create mode 100644 internal/store/users.go create mode 100644 internal/validate/validate.go create mode 100644 internal/validate/validate_test.go create mode 100644 internal/version/version.go create mode 100644 internal/version/version_test.go create mode 100644 internal/web/static/assets/api.js create mode 100644 internal/web/static/assets/app.js create mode 100644 internal/web/static/assets/brand.js create mode 100644 internal/web/static/assets/logo.js create mode 100644 internal/web/static/assets/logo.svg create mode 100644 internal/web/static/assets/pages/account.js create mode 100644 internal/web/static/assets/pages/activity.js create mode 100644 internal/web/static/assets/pages/audit.js create mode 100644 internal/web/static/assets/pages/dashboard.js create mode 100644 internal/web/static/assets/pages/history.js create mode 100644 internal/web/static/assets/pages/leases.js create mode 100644 internal/web/static/assets/pages/reservations.js create mode 100644 internal/web/static/assets/pages/settings.js create mode 100644 internal/web/static/assets/pages/subnet.js create mode 100644 internal/web/static/assets/pages/subnets.js create mode 100644 internal/web/static/assets/pages/users.js create mode 100644 internal/web/static/assets/style.css create mode 100644 internal/web/static/assets/ui.js create mode 100644 internal/web/static/assets/widgets.js create mode 100644 internal/web/static/index.html create mode 100644 internal/web/web.go create mode 100755 scripts/e2e.sh create mode 100755 scripts/gitea-release.sh create mode 100644 scripts/logo.py create mode 100755 scripts/mkdeb.sh create mode 100755 scripts/optionsdoc.py diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml new file mode 100644 index 0000000..8d798fa --- /dev/null +++ b/.gitea/workflows/release.yml @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c420777 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +/bin/ +/dist/ +*.db +*.db-wal +*.db-shm +/steward +/stewardd diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..4b56689 --- /dev/null +++ b/AGENTS.md @@ -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. diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..6818dcd --- /dev/null +++ b/CHANGELOG.md @@ -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. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..a59d175 --- /dev/null +++ b/CLAUDE.md @@ -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 `. +- Keep temporary scripts, screenshots and test + servers' work directories in the session scratchpad, not in the repo. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..2eda393 --- /dev/null +++ b/LICENSE @@ -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. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..4c45f77 --- /dev/null +++ b/Makefile @@ -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---[.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 diff --git a/README.md b/README.md new file mode 100644 index 0000000..096332a --- /dev/null +++ b/README.md @@ -0,0 +1,103 @@ +

Steward

+ +# 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). diff --git a/VERSION b/VERSION new file mode 100644 index 0000000..0d4d124 --- /dev/null +++ b/VERSION @@ -0,0 +1 @@ +0.1.0-dev diff --git a/artwork/logo.svg b/artwork/logo.svg new file mode 100644 index 0000000..41ff6ed --- /dev/null +++ b/artwork/logo.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/cmd/steward/admin.go b/cmd/steward/admin.go new file mode 100644 index 0000000..9e21450 --- /dev/null +++ b/cmd/steward/admin.go @@ -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 +} diff --git a/cmd/steward/leases.go b/cmd/steward/leases.go new file mode 100644 index 0000000..3ed5331 --- /dev/null +++ b/cmd/steward/leases.go @@ -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 +} diff --git a/cmd/steward/main.go b/cmd/steward/main.go new file mode 100644 index 0000000..28672ab --- /dev/null +++ b/cmd/steward/main.go @@ -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, "; ") +} diff --git a/cmd/steward/main_test.go b/cmd/steward/main_test.go new file mode 100644 index 0000000..37aa02c --- /dev/null +++ b/cmd/steward/main_test.go @@ -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") + } +} diff --git a/cmd/steward/subnets.go b/cmd/steward/subnets.go new file mode 100644 index 0000000..50977f7 --- /dev/null +++ b/cmd/steward/subnets.go @@ -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 +} diff --git a/cmd/stewardd/lock.go b/cmd/stewardd/lock.go new file mode 100644 index 0000000..20c5811 --- /dev/null +++ b/cmd/stewardd/lock.go @@ -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 +} diff --git a/cmd/stewardd/main.go b/cmd/stewardd/main.go new file mode 100644 index 0000000..5eec515 --- /dev/null +++ b/cmd/stewardd/main.go @@ -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-