Steward 0.1.0 (in development): a DHCPv4 server with a web UI, API and CLI
Subnets served per interface, pools, reservations, options at three levels, leases in SQLite saved before the ACK, ping and decline conflict checks, validated changes with history and undo, users and tokens, an audit log, a Kea importer, a sandboxed systemd unit and Debian packages. Tested with real udhcpc and dhcpcd clients in Docker (scripts/e2e.sh). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
3c0808591f
111 files changed
+17851
No files matched your search
Executable
+201
@@ -0,0 +1,201 @@
|
||||
#!/bin/bash
|
||||
# End-to-end test with real DHCP clients: stewardd runs in a Docker
|
||||
# container on two bridge networks (eth0 and eth1, like two VLANs), and
|
||||
# busybox udhcpc and dhcpcd clients in other containers ask it for
|
||||
# addresses. Needs Docker; no root on the host. Run from the repository
|
||||
# root:
|
||||
#
|
||||
# make e2e or scripts/e2e.sh
|
||||
#
|
||||
# KEEP=1 leaves the containers and networks running afterwards. Set
|
||||
# DOCKER_HOST if Docker isn't at its default socket.
|
||||
set -u
|
||||
ROOT=$(cd "$(dirname "$0")/.." && pwd)
|
||||
P=${E2E_PREFIX:-stwe2e}
|
||||
NA=10.78.10 NB=10.78.20
|
||||
WORK=${WORK:-$(mktemp -d "${TMPDIR:-/tmp}/steward-e2e.XXXXXX")}
|
||||
mkdir -p "$WORK/dhcpcd"
|
||||
docker info >/dev/null 2>&1 || { echo "Docker isn't reachable (set DOCKER_HOST?)"; exit 2; }
|
||||
|
||||
(cd "$ROOT" && CGO_ENABLED=0 go build -o "$WORK/stewardd" ./cmd/stewardd && CGO_ENABLED=0 go build -o "$WORK/steward" ./cmd/steward) || exit 1
|
||||
docker build -q -t steward-e2e-client - >/dev/null <<'EOF' || exit 1
|
||||
FROM alpine:3.22
|
||||
RUN apk add --no-cache dhcpcd && printf '#!/bin/sh\ncase "$1" in bound|renew) echo "BOUND ip=$ip mask=$subnet router=$router dns=$dns domain=$domain lease=$lease serverid=$serverid hostname=$hostname ntp=$ntpsrv";; nak) echo "NAK $message";; esac\n' > /usr/local/bin/show && chmod +x /usr/local/bin/show
|
||||
EOF
|
||||
|
||||
cleanup() {
|
||||
if [ "${KEEP:-}" = 1 ]; then echo "left running: containers ${P}-*, networks ${P}-a ${P}-b, files in $WORK"; return; fi
|
||||
# Root in the containers made some of the files.
|
||||
docker exec ${P}-srv sh -c 'rm -rf /w/data /w/backups /w/dhcpcd /w/srv.log' >/dev/null 2>&1
|
||||
docker rm -f $(docker ps -aq --filter "name=^${P}-") >/dev/null 2>&1
|
||||
docker network rm ${P}-a ${P}-b >/dev/null 2>&1
|
||||
rm -rf "$WORK"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
docker rm -f $(docker ps -aq --filter "name=^${P}-") >/dev/null 2>&1
|
||||
docker network rm ${P}-a ${P}-b >/dev/null 2>&1
|
||||
docker network create --subnet $NA.0/24 --gateway $NA.1 ${P}-a >/dev/null || exit 1
|
||||
docker network create --subnet $NB.0/24 --gateway $NB.1 ${P}-b >/dev/null || exit 1
|
||||
docker run -d --name ${P}-srv --network ${P}-a --ip $NA.2 -v "$WORK:/w" debian:trixie sleep infinity >/dev/null || exit 1
|
||||
docker network connect --ip $NB.2 ${P}-b ${P}-srv || exit 1
|
||||
cat > "$WORK/steward.yaml" <<'EOF'
|
||||
listen: ["0.0.0.0:8067"]
|
||||
database: /w/data/steward.db
|
||||
backup: {dir: /w/backups}
|
||||
EOF
|
||||
|
||||
PASS=0; FAIL=0
|
||||
ok() { echo "PASS: $1"; PASS=$((PASS+1)); }
|
||||
bad() { echo "FAIL: $1"; FAIL=$((FAIL+1)); }
|
||||
# run DESC EXPECTED_RC CMD...: runs a command, checks its exit code and
|
||||
# keeps the output in $OUT.
|
||||
run() {
|
||||
local desc=$1 want=$2; shift 2
|
||||
OUT=$("$@" 2>&1); local rc=$?
|
||||
if [ "$rc" = "$want" ]; then ok "$desc (rc=$rc)"; else bad "$desc: rc=$rc want $want"; echo "$OUT" | sed 's/^/ /'; fi
|
||||
}
|
||||
contains() { if echo "$OUT" | grep -q -- "$1"; then ok "output has '$1'"; else bad "output lacks '$1'"; echo "$OUT" | sed 's/^/ /'; fi; }
|
||||
not_contains() { if echo "$OUT" | grep -q -- "$1"; then bad "output has '$1'"; echo "$OUT" | sed 's/^/ /'; else ok "output lacks '$1'"; fi; }
|
||||
S() { docker exec ${P}-srv /w/steward "$@"; }
|
||||
start() { docker exec ${P}-srv sh -c '(/w/stewardd -c /w/steward.yaml serve >> /w/srv.log 2>&1 &); for i in 1 2 3 4 5 6 7 8 9 10; do [ -S /run/steward/steward.sock ] && exit 0; sleep 0.3; done; exit 1'; }
|
||||
# udhcpc NET MAC [ARGS...]: one DHCP exchange with busybox's client.
|
||||
udhcpc() {
|
||||
local net=$1 mac=$2; shift 2
|
||||
docker run --rm --network ${P}-$net --mac-address "$mac" --cap-add NET_ADMIN steward-e2e-client \
|
||||
udhcpc -i eth0 -f -q -n -t 3 -T 1 -s /usr/local/bin/show "$@"
|
||||
}
|
||||
# dhcpcd NET MAC ARGS...: dhcpcd with its lease files kept on the host, so
|
||||
# a second run reboots with the lease of the first.
|
||||
dhcpcd() {
|
||||
local net=$1 mac=$2; shift 2
|
||||
docker run --rm --network ${P}-$net --mac-address "$mac" --cap-add NET_ADMIN -v "$WORK/dhcpcd:/var/lib/dhcpcd" steward-e2e-client sh -c "$*"
|
||||
}
|
||||
|
||||
# ---- set up
|
||||
|
||||
run "init" 0 docker exec ${P}-srv sh -c 'echo secret-pass-123 | /w/stewardd -c /w/steward.yaml init --password-stdin'
|
||||
run "start stewardd" 0 start
|
||||
run "interfaces are seen" 0 S interface list
|
||||
contains "eth0 *$NA.2/24"
|
||||
contains "eth1 *$NB.2/24"
|
||||
run "no subnets: nothing served" 0 S status
|
||||
contains "none: add a subnet"
|
||||
run "subnet on eth0" 0 S subnet add $NA.0/24 --interface eth0 --name office --pool $NA.100-$NA.104 \
|
||||
--option routers=$NA.1 --option domain-name-servers=$NA.2
|
||||
run "subnet on eth1" 0 S subnet add $NB.0/24 --interface eth1 --name iot --pool $NB.100-$NB.110 --option routers=$NB.1 --lease-time 1h
|
||||
run "an overlapping subnet is refused" 6 S subnet add $NA.128/25 --interface eth1
|
||||
contains "overlaps subnet office"
|
||||
run "a pool outside the subnet is refused" 6 S subnet update iot --add-pool $NA.150-$NA.160
|
||||
contains "pool_outside_subnet"
|
||||
run "global option" 0 S settings set --option domain-name=lan
|
||||
run "reservation" 0 S reservation add $NA.20 02:00:00:00:00:20 --hostname printer --option ntp-servers=$NA.2
|
||||
run "a reservation outside every subnet is refused" 6 S reservation add 192.168.99.5 02:00:00:00:00:21
|
||||
run "both interfaces serve" 0 S status
|
||||
contains "eth0 *$NA.2/24 *serving DHCP"
|
||||
contains "eth1 *$NB.2/24 *serving DHCP"
|
||||
|
||||
# ---- addresses per interface
|
||||
|
||||
# Something already uses the pool's first address: the ping check finds it.
|
||||
docker run -d --name ${P}-squatter --network ${P}-a --ip $NA.100 alpine:3.22 sleep infinity >/dev/null
|
||||
run "client on eth0" 0 udhcpc a 02:00:00:00:00:01 -x hostname:laptop
|
||||
contains "BOUND ip=$NA.101 mask=255.255.255.0 router=$NA.1 dns=$NA.2 domain=lan lease=43200 serverid=$NA.2"
|
||||
run "the address in use was held back" 0 S lease list
|
||||
contains "$NA.100 .*held back\|$NA.100 .*declined"
|
||||
run "client on eth1 gets the other subnet" 0 udhcpc b 02:00:00:00:00:02 -x hostname:sensor
|
||||
contains "BOUND ip=$NB.100 mask=255.255.255.0 router=$NB.1 dns= domain=lan lease=3600 serverid=$NB.2"
|
||||
run "reserved client gets its address, host name and option" 0 udhcpc a 02:00:00:00:00:20
|
||||
contains "BOUND ip=$NA.20 .*hostname=printer ntp=$NA.2"
|
||||
run "the same device on the other VLAN gets that VLAN's address" 0 udhcpc b 02:00:00:00:00:20
|
||||
contains "BOUND ip=$NB.101 "
|
||||
run "leases" 0 S lease list
|
||||
contains "$NA.101 *02:00:00:00:00:01 *laptop *active .*eth0 *office"
|
||||
contains "$NB.100 *02:00:00:00:00:02 *sensor *active .*eth1 *iot"
|
||||
contains "$NA.20 .*printer .*active, reserved"
|
||||
run "a returning client gets its address again" 0 udhcpc a 02:00:00:00:00:01
|
||||
contains "BOUND ip=$NA.101 "
|
||||
|
||||
# ---- dhcpcd: reboot, renew, release, wrong VLAN
|
||||
|
||||
run "dhcpcd gets a lease" 0 dhcpcd a 02:00:00:00:00:03 "dhcpcd -4 -A -1 -t 10 -h desk eth0"
|
||||
contains "leased $NA.102"
|
||||
run "dhcpcd reboots with it (INIT-REBOOT)" 0 dhcpcd a 02:00:00:00:00:03 "dhcpcd -4 -A -1 -t 10 eth0"
|
||||
contains "leased $NA.102"
|
||||
run "dhcpcd renews it, the host name stays" 0 dhcpcd a 02:00:00:00:00:03 "dhcpcd -4 -A -B -t 10 eth0 >/tmp/l 2>&1 & sleep 3; dhcpcd -4 -n eth0; sleep 3; cat /tmp/l"
|
||||
contains "leased $NA.102"
|
||||
run "lease" 0 S lease show $NA.102
|
||||
contains "Host name: *desk"
|
||||
run "moved to the other VLAN: NAK, then that VLAN's address" 0 dhcpcd b 02:00:00:00:00:03 "dhcpcd -4 -A -1 -t 15 eth0"
|
||||
contains "NAK"
|
||||
contains "leased $NB.10"
|
||||
run "activity shows the NAK" 0 S activity --mac 02:00:00:00:00:03
|
||||
contains "DHCPNAK .*does not belong on eth1"
|
||||
run "dhcpcd releases" 0 dhcpcd b 02:00:00:00:00:03 "dhcpcd -4 -A -B -t 10 eth0 >/tmp/l 2>&1 & sleep 4; dhcpcd -4 -k eth0; sleep 2"
|
||||
run "released lease" 0 S lease list --all
|
||||
contains "$NB.10[0-9] *02:00:00:00:00:03 .*released"
|
||||
|
||||
# ---- a full pool, a disabled subnet
|
||||
|
||||
run "office pool: 5 addresses, 1 held back, 2 leased" 0 S subnet show office
|
||||
contains "Leased: *2/5 (40%), 1 declined"
|
||||
run "the last free address" 0 udhcpc a 02:00:00:00:00:04
|
||||
contains "BOUND ip=$NA.10[34] "
|
||||
run "the last free address" 0 udhcpc a 02:00:00:00:00:05
|
||||
contains "BOUND ip=$NA.10[34] "
|
||||
run "a full pool: no answer" 1 udhcpc a 02:00:00:00:00:06
|
||||
run "activity says why" 0 S activity --mac 02:00:00:00:00:06
|
||||
contains "NO ADDRESS .*every pool on eth0 is in use"
|
||||
run "disable iot" 0 S subnet disable iot
|
||||
run "a disabled subnet doesn't answer" 1 udhcpc b 02:00:00:00:00:07
|
||||
run "status: eth1 not served" 0 S status
|
||||
not_contains "eth1 .*serving"
|
||||
run "undo the disable" 0 S history undo $(S history list -o json | grep -m1 '"id"' | tr -dc 0-9)
|
||||
run "iot answers again" 0 udhcpc b 02:00:00:00:00:07
|
||||
contains "BOUND ip=$NB.10"
|
||||
|
||||
# ---- changes: dry run, history
|
||||
|
||||
run "dry run" 0 S -n subnet update iot --lease-time 2h
|
||||
contains "would update subnet iot"
|
||||
contains "dry run: nothing was changed"
|
||||
run "unchanged" 0 S subnet show iot
|
||||
contains "Lease time: *1h"
|
||||
run "history" 0 S history list
|
||||
contains "create subnet office"
|
||||
run "a real change" 0 S subnet update iot --lease-time 2h
|
||||
run "undo of an old change is refused when the object changed since" 5 S history undo 2
|
||||
contains "has changed since entry 2"
|
||||
run "undo the lease time change" 0 S history undo $(S history list -o json | grep -m1 '"id"' | tr -dc 0-9)
|
||||
run "lease time back" 0 S subnet show iot
|
||||
contains "Lease time: *1h"
|
||||
|
||||
# ---- restart: leases survive
|
||||
|
||||
docker exec ${P}-srv sh -c 'kill $(pidof stewardd)'; sleep 1
|
||||
run "restart stewardd" 0 start
|
||||
run "leases after the restart" 0 S lease list
|
||||
contains "$NA.101 *02:00:00:00:00:01 *laptop *active"
|
||||
run "the client still gets its address" 0 udhcpc a 02:00:00:00:00:01
|
||||
contains "BOUND ip=$NA.101 "
|
||||
|
||||
# ---- the API from the host, with a token
|
||||
|
||||
TOKEN=$(S token create e2e --user admin 2>/dev/null)
|
||||
run "a token" 0 test -n "$TOKEN"
|
||||
run "CLI over HTTP with the token" 0 "$WORK/steward" --config "$WORK/cli.yaml" --server http://$NA.2:8067 --token "$TOKEN" lease list
|
||||
contains "laptop"
|
||||
run "without a token: refused" 3 "$WORK/steward" --config "$WORK/cli.yaml" --server http://$NA.2:8067 --token steward_aaaaaaaaaaaaaaaa_bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb status
|
||||
LEASES=$(S token create readonly --user admin --role leases 2>/dev/null)
|
||||
run "a leases-only token can't change subnets" 3 "$WORK/steward" --config "$WORK/cli.yaml" --server http://$NA.2:8067 --token "$LEASES" subnet disable office
|
||||
run "delete a lease" 0 S lease delete $NA.101 --yes
|
||||
run "audit log" 0 S audit --action lease.
|
||||
contains "lease.delete"
|
||||
|
||||
# ---- backup
|
||||
|
||||
run "backup" 0 docker exec ${P}-srv /w/stewardd -c /w/steward.yaml backup
|
||||
contains "/w/backups/steward-"
|
||||
|
||||
echo
|
||||
echo "RESULT: $PASS passed, $FAIL failed"
|
||||
[ $FAIL = 0 ]
|
||||
Executable
+150
@@ -0,0 +1,150 @@
|
||||
#!/bin/sh
|
||||
# Publishes a built release (make dist, make cli-dist, make deb for each
|
||||
# architecture) to Gitea:
|
||||
#
|
||||
# * a release for tag v<VERSION> with the CHANGELOG section for VERSION as
|
||||
# its notes, and every file in dist/ (tarballs, CLI binaries, .debs)
|
||||
# plus a SHA256SUMS of them attached;
|
||||
# * the .debs in the owner's Debian package registry, so servers can
|
||||
# 'apt install steward' and get updates with apt (docs/install.md).
|
||||
#
|
||||
# Run by .gitea/workflows/release.yml when a v* tag is pushed. Running it
|
||||
# again for the same version replaces the attachments and packages.
|
||||
#
|
||||
# A version with a hyphen (0.2.0-rc.1) is a pre-release: Gitea marks the
|
||||
# release so and doesn't count it as the latest, and its packages go to
|
||||
# the "testing" distribution, so servers that follow "stable" never get
|
||||
# a pre-release from apt.
|
||||
#
|
||||
# Release notes: the CHANGELOG section "## [VERSION]". A pre-release
|
||||
# without one of its own uses the section of the version it leads up to
|
||||
# ("## [0.2.0]"), or else "## [Unreleased]".
|
||||
#
|
||||
# 'gitea-release.sh notes' only prints the notes, failing if there are
|
||||
# none (the workflow checks this before building).
|
||||
#
|
||||
# Variables:
|
||||
# GITEA_URL e.g. https://gitea.example.com (in Actions:
|
||||
# GITHUB_SERVER_URL)
|
||||
# GITEA_REPO owner/name (in Actions: GITHUB_REPOSITORY)
|
||||
# GITEA_TOKEN a token with write:repository and write:package
|
||||
# VERSION default: the VERSION file
|
||||
# DEB_DISTRIBUTION the apt distribution (default: stable, testing for
|
||||
# a pre-release)
|
||||
# DEB_COMPONENT the apt component (default: main)
|
||||
#
|
||||
# Needs curl and jq.
|
||||
set -eu
|
||||
|
||||
VERSION=${VERSION:-$(cat VERSION)}
|
||||
prerelease=false
|
||||
case $VERSION in *-*) prerelease=true ;; esac
|
||||
|
||||
# section prints the CHANGELOG section headed "## [$1]" (without the
|
||||
# heading); nothing if there is none.
|
||||
section() {
|
||||
awk -v v="$1" '
|
||||
/^## \[/ { if (on) exit; on = index($0, "## [" v "]") == 1; next }
|
||||
on' CHANGELOG.md
|
||||
}
|
||||
|
||||
# The release notes (see above). A section with only blank lines counts
|
||||
# as missing.
|
||||
notes=$(section "$VERSION")
|
||||
from="[$VERSION]"
|
||||
if [ "$prerelease" = true ]; then
|
||||
for s in "${VERSION%%-*}" Unreleased; do
|
||||
[ -z "$(printf '%s' "$notes" | tr -d '[:space:]')" ] || break
|
||||
notes=$(section "$s")
|
||||
from="[$s]"
|
||||
done
|
||||
fi
|
||||
if [ -z "$(printf '%s' "$notes" | tr -d '[:space:]')" ]; then
|
||||
if [ "$prerelease" = true ]; then
|
||||
echo "gitea-release: CHANGELOG.md has no '## [$VERSION]', '## [${VERSION%%-*}]' or '## [Unreleased]' section with notes" >&2
|
||||
else
|
||||
echo "gitea-release: CHANGELOG.md has no '## [$VERSION]' section with notes" >&2
|
||||
fi
|
||||
exit 1
|
||||
fi
|
||||
if [ "${1:-}" = notes ]; then
|
||||
echo "gitea-release: notes for $VERSION from CHANGELOG.md $from" >&2
|
||||
printf '%s\n' "$notes"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
: "${GITEA_URL:=${GITHUB_SERVER_URL:-}}" "${GITEA_REPO:=${GITHUB_REPOSITORY:-}}"
|
||||
: "${GITEA_URL:?set GITEA_URL}" "${GITEA_REPO:?set GITEA_REPO}" "${GITEA_TOKEN:?set GITEA_TOKEN}"
|
||||
if [ "$prerelease" = true ]; then
|
||||
DEB_DISTRIBUTION=${DEB_DISTRIBUTION:-testing}
|
||||
else
|
||||
DEB_DISTRIBUTION=${DEB_DISTRIBUTION:-stable}
|
||||
fi
|
||||
DEB_COMPONENT=${DEB_COMPONENT:-main}
|
||||
GITEA_URL=${GITEA_URL%/}
|
||||
owner=${GITEA_REPO%%/*}
|
||||
tag=v$VERSION
|
||||
api=$GITEA_URL/api/v1/repos/$GITEA_REPO
|
||||
|
||||
# curl with the token; fails on HTTP errors except the codes in $ok.
|
||||
call() {
|
||||
ok=$1
|
||||
shift
|
||||
out=$(mktemp)
|
||||
code=$(curl -sS -o "$out" -w '%{http_code}' -H "Authorization: token $GITEA_TOKEN" "$@")
|
||||
case " 200 201 204 $ok " in
|
||||
*" $code "*) cat "$out"; rm -f "$out"; return 0 ;;
|
||||
esac
|
||||
echo "gitea-release: HTTP $code from $*" >&2
|
||||
cat "$out" >&2
|
||||
echo >&2
|
||||
rm -f "$out"
|
||||
return 1
|
||||
}
|
||||
|
||||
files=$(find dist -maxdepth 1 -type f ! -name SHA256SUMS | sort)
|
||||
[ -n "$files" ] || { echo "gitea-release: nothing in dist/" >&2; exit 1; }
|
||||
# (Debian package versions have ~ for -: steward_0.2.0~rc.1_amd64.deb.)
|
||||
debversion=$(printf '%s' "$VERSION" | tr - '~')
|
||||
for f in $files; do
|
||||
case $f in *"$VERSION"* | *"$debversion"*) ;; *) echo "gitea-release: $f is not version $VERSION" >&2; exit 1 ;; esac
|
||||
done
|
||||
# shellcheck disable=SC2046 # file names have no spaces
|
||||
(cd dist && sha256sum $(printf '%s\n' "$files" | sed 's|^dist/||')) > dist/SHA256SUMS
|
||||
files="$files dist/SHA256SUMS"
|
||||
|
||||
# The release (created, or found when publishing again).
|
||||
release=$(call 404 "$api/releases/tags/$tag")
|
||||
id=$(printf '%s\n' "$release" | jq -r '.id // empty')
|
||||
if [ -z "$id" ]; then
|
||||
body=$(jq -n --arg tag "$tag" --arg notes "$notes" --argjson pre "$prerelease" \
|
||||
'{tag_name: $tag, name: ("Steward " + $tag), body: $notes, prerelease: $pre}')
|
||||
release=$(call "" -X POST -H 'Content-Type: application/json' -d "$body" "$api/releases")
|
||||
id=$(printf '%s\n' "$release" | jq -r .id)
|
||||
echo "gitea-release: created release $tag"
|
||||
fi
|
||||
for f in $files; do
|
||||
name=${f#dist/}
|
||||
old=$(printf '%s\n' "$release" | jq -r --arg n "$name" '.assets[]? | select(.name == $n) | .id')
|
||||
[ -z "$old" ] || call "" -X DELETE "$api/releases/$id/assets/$old" >/dev/null
|
||||
call "" -X POST -F "attachment=@$f" "$api/releases/$id/assets?name=$name" >/dev/null
|
||||
echo "gitea-release: attached $name"
|
||||
done
|
||||
|
||||
# The Debian packages. Gitea refuses a package version it already has
|
||||
# (409), so publishing again deletes it first.
|
||||
pool=$GITEA_URL/api/packages/$owner/debian/pool/$DEB_DISTRIBUTION/$DEB_COMPONENT
|
||||
for f in dist/*.deb; do
|
||||
[ -e "$f" ] || continue
|
||||
# shellcheck disable=SC2016,SC2046 # dpkg-deb's fields; split into "$@"
|
||||
set -- $(dpkg-deb --showformat '${Package} ${Version} ${Architecture}' --show "$f")
|
||||
if ! call 409 --upload-file "$f" "$pool/upload" >/dev/null; then
|
||||
echo "gitea-release: upload of $f failed" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ "$code" = 409 ]; then
|
||||
call "" -X DELETE "$pool/$1/$2/$3" >/dev/null
|
||||
call "" --upload-file "$f" "$pool/upload" >/dev/null
|
||||
fi
|
||||
echo "gitea-release: uploaded $1 $2 $3 to $DEB_DISTRIBUTION/$DEB_COMPONENT"
|
||||
done
|
||||
@@ -0,0 +1,57 @@
|
||||
#!/usr/bin/env python3
|
||||
# Builds the web UI's logo from the master, artwork/logo.svg (a key whose
|
||||
# bow is a crown with Jarl's network nodes, in Jarl's blue and white):
|
||||
#
|
||||
# internal/web/static/assets/logo.svg the favicon (a copy)
|
||||
# internal/web/static/assets/logo.js the shapes for the web UI, which
|
||||
# draws them inline (brand.js), so the
|
||||
# logo appears together with the page
|
||||
#
|
||||
# The master is hand-written SVG: <g> elements whose attributes apply to
|
||||
# their <path> and <circle> children. Run from the repository root after
|
||||
# changing it:
|
||||
#
|
||||
# python3 scripts/logo.py
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
MASTER = "artwork/logo.svg"
|
||||
OUT = "internal/web/static/assets"
|
||||
SVG = "{http://www.w3.org/2000/svg}"
|
||||
|
||||
|
||||
def shapes(el, inherited):
|
||||
out = []
|
||||
attrs = dict(inherited)
|
||||
attrs.update({k: v for k, v in el.attrib.items() if k not in ("xmlns", "viewBox")})
|
||||
tag = el.tag.replace(SVG, "")
|
||||
if tag in ("path", "circle", "rect", "polygon"):
|
||||
out.append([tag, attrs])
|
||||
for child in el:
|
||||
out.extend(shapes(child, attrs if tag == "g" else inherited))
|
||||
return out
|
||||
|
||||
|
||||
def main():
|
||||
root = ET.parse(MASTER).getroot()
|
||||
view_box = root.attrib["viewBox"]
|
||||
items = []
|
||||
for child in root:
|
||||
items.extend(shapes(child, {}))
|
||||
with open(os.path.join(OUT, "logo.js"), "w") as f:
|
||||
f.write(f"// The Steward logo, from {MASTER} by scripts/logo.py; don't edit.\n"
|
||||
"// brand.js draws it; logo.svg is the same picture.\n"
|
||||
f"export const viewBox = {json.dumps(view_box)};\n"
|
||||
"export const shapes = [\n"
|
||||
+ "".join(f" {json.dumps(s)},\n" for s in items)
|
||||
+ "];\n")
|
||||
shutil.copyfile(MASTER, os.path.join(OUT, "logo.svg"))
|
||||
for name in ("logo.svg", "logo.js"):
|
||||
print(f"{OUT}/{name}: {os.path.getsize(os.path.join(OUT, name))} bytes")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Executable
+117
@@ -0,0 +1,117 @@
|
||||
#!/bin/sh
|
||||
# Builds Steward's two Debian packages:
|
||||
#
|
||||
# dist/steward-cli_<version>_<arch>.deb the CLI, to install anywhere:
|
||||
# /usr/bin/steward, its bash completion, and /usr/share/doc/steward-cli/
|
||||
# (README, CHANGELOG, the CLI and permissions docs). No scripts:
|
||||
# installing it changes nothing else on the machine.
|
||||
# dist/steward_<version>_<arch>.deb the server, which depends on
|
||||
# steward-cli of the same version:
|
||||
# /usr/bin/stewardd and its bash completion
|
||||
# /usr/lib/systemd/system/{steward.service,steward-backup.service,steward-backup.timer}
|
||||
# /usr/lib/sysusers.d/steward.conf, /usr/lib/tmpfiles.d/steward.conf
|
||||
# /usr/share/doc/steward/ (README, CHANGELOG, docs)
|
||||
# Its scripts (deploy/debian) create the steward user and directories and
|
||||
# /etc/steward/steward.yaml on the first install, turn on the daily backup
|
||||
# timer, restart a running stewardd on upgrades, and stop both on removal.
|
||||
# No file in /etc belongs to the package, so an upgrade never asks
|
||||
# about configuration files.
|
||||
#
|
||||
# Run through 'make deb' (which sets the variables below); needs only
|
||||
# dpkg-deb, no root and no debhelper.
|
||||
#
|
||||
# Variables: DEB_ARCH (Debian architecture, e.g. amd64, arm64, armhf),
|
||||
# DEB_VERSION, VERSION, LDFLAGS, GOFLAGS, and DEB_MAINTAINER (default: your
|
||||
# git name and email).
|
||||
set -eu
|
||||
|
||||
: "${DEB_ARCH:?}" "${DEB_VERSION:?}" "${VERSION:?}" "${LDFLAGS:?}"
|
||||
GOFLAGS=${GOFLAGS:-}
|
||||
|
||||
# Debian architecture -> Go architecture.
|
||||
goarm=
|
||||
case "$DEB_ARCH" in
|
||||
amd64) goarch=amd64 ;;
|
||||
arm64) goarch=arm64 ;;
|
||||
armhf) goarch=arm goarm=7 ;;
|
||||
i386) goarch=386 ;;
|
||||
ppc64el) goarch=ppc64le ;;
|
||||
riscv64) goarch=riscv64 ;;
|
||||
s390x) goarch=s390x ;;
|
||||
*) echo "mkdeb: unsupported architecture $DEB_ARCH" >&2; exit 1 ;;
|
||||
esac
|
||||
|
||||
if [ -z "${DEB_MAINTAINER:-}" ]; then
|
||||
name=$(git config user.name 2>/dev/null || true)
|
||||
email=$(git config user.email 2>/dev/null || true)
|
||||
DEB_MAINTAINER="${name:-Steward packager} <${email:-root@localhost}>"
|
||||
fi
|
||||
|
||||
work=dist/deb/${DEB_VERSION}_${DEB_ARCH}
|
||||
stage=$work/stage
|
||||
rm -rf "$work"
|
||||
# Older packages of this architecture (every development build has its
|
||||
# own version), so 'apt install ./dist/steward_*_amd64.deb' finds just one.
|
||||
rm -f dist/steward_*_"$DEB_ARCH".deb dist/steward-cli_*_"$DEB_ARCH".deb
|
||||
mkdir -p "$stage"
|
||||
|
||||
# Binaries for the package's architecture (bin/ holds the host's, which
|
||||
# also generated the completion scripts).
|
||||
echo "mkdeb: building for linux/$goarch"
|
||||
# go reads GOFLAGS (-trimpath) from the environment.
|
||||
export GOFLAGS
|
||||
GOOS=linux GOARCH=$goarch GOARM=$goarm CGO_ENABLED=0 go build -ldflags "$LDFLAGS" -o "$work/bin/stewardd" ./cmd/stewardd
|
||||
GOOS=linux GOARCH=$goarch GOARM=$goarm CGO_ENABLED=0 go build -ldflags "$LDFLAGS" -o "$work/bin/steward" ./cmd/steward
|
||||
|
||||
# Everything 'make install' would install, then split in two.
|
||||
make --no-print-directory install-files DESTDIR="$stage" PREFIX=/usr \
|
||||
UNITDIR=/usr/lib/systemd/system STEWARDD_BIN="$work/bin/stewardd" STEWARD_BIN="$work/bin/steward" >/dev/null
|
||||
|
||||
cli=$work/steward-cli
|
||||
srv=$work/steward
|
||||
mkdir -p "$cli/DEBIAN" "$cli/usr/bin" "$cli/usr/share/bash-completion/completions" "$cli/usr/share/doc/steward-cli/docs"
|
||||
mv "$stage/usr/bin/steward" "$cli/usr/bin/"
|
||||
mv "$stage/usr/share/bash-completion/completions/steward" "$cli/usr/share/bash-completion/completions/"
|
||||
cp "$stage/usr/share/doc/steward/README.md" "$stage/usr/share/doc/steward/CHANGELOG.md" "$cli/usr/share/doc/steward-cli/"
|
||||
for d in cli permissions; do
|
||||
cp "$stage/usr/share/doc/steward/docs/$d.md" "$cli/usr/share/doc/steward-cli/docs/"
|
||||
done
|
||||
mv "$stage" "$srv"
|
||||
mkdir -p "$srv/DEBIAN"
|
||||
for f in postinst prerm postrm; do
|
||||
install -m 0755 "deploy/debian/$f" "$srv/DEBIAN/$f"
|
||||
done
|
||||
|
||||
# Debian's machine-readable copyright file, from LICENSE.
|
||||
indent() { sed -e 's/^$/./' -e 's/^/ /' "$1"; }
|
||||
copyright() {
|
||||
echo "Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/"
|
||||
echo "Upstream-Name: steward"
|
||||
echo
|
||||
echo "Files: *"
|
||||
echo "Copyright: $(sed -n 's/^Copyright (c) //p' LICENSE)"
|
||||
echo "License: Expat"
|
||||
indent LICENSE
|
||||
}
|
||||
|
||||
# build PACKAGE ROOT: writes the control file and md5sums and builds the .deb.
|
||||
build() {
|
||||
pkg=$1 root=$2 out=dist/${1}_${DEB_VERSION}_${DEB_ARCH}.deb
|
||||
copyright > "$root/usr/share/doc/$pkg/copyright"
|
||||
rm -f "$root/usr/share/doc/$pkg/LICENSE"
|
||||
# Directories 0755 and no group/other write, whatever the build's umask.
|
||||
find "$root" -type d -exec chmod 0755 {} +
|
||||
chmod -R go-w "$root"
|
||||
size=$(du -sk --exclude=DEBIAN "$root" | cut -f1)
|
||||
sed -e "s/@VERSION@/$DEB_VERSION/g" -e "s/@ARCH@/$DEB_ARCH/" -e "s/@SIZE@/$size/" \
|
||||
-e "s|@MAINTAINER@|$DEB_MAINTAINER|" "deploy/debian/control-$pkg.in" > "$root/DEBIAN/control"
|
||||
(cd "$root" && find . -path ./DEBIAN -prune -o -type f -print | sed 's|^\./||' | sort | xargs md5sum) > "$root/DEBIAN/md5sums"
|
||||
chmod 0644 "$root/DEBIAN/control" "$root/DEBIAN/md5sums"
|
||||
# Files belong to root in the package, whoever builds it.
|
||||
dpkg-deb --root-owner-group -Zxz --build "$root" "$out" >/dev/null
|
||||
echo "mkdeb: wrote $out"
|
||||
dpkg-deb --info "$out" | sed -n 's/^ \(Package\|Version\|Architecture\|Installed-Size\|Depends\):/ \1:/p'
|
||||
}
|
||||
build steward-cli "$cli"
|
||||
build steward "$srv"
|
||||
rm -rf dist/deb
|
||||
Executable
+83
@@ -0,0 +1,83 @@
|
||||
#!/usr/bin/env python3
|
||||
# Writes docs/options.md from the option catalog in internal/dhcp4/catalog.go
|
||||
# (the options by name, and the reserved codes). Run from the repository
|
||||
# root after changing the catalog:
|
||||
#
|
||||
# python3 scripts/optionsdoc.py
|
||||
import re
|
||||
|
||||
src = open("internal/dhcp4/catalog.go").read()
|
||||
rows = re.findall(r'\{Code: (\d+), Name: "([^"]+)", Type: (\w+), Help: "((?:[^"\\]|\\.)*)"', src)
|
||||
types = {"TypeIP": "ip", "TypeIPs": "ips", "TypeString": "string", "TypeU8": "uint8", "TypeU16": "uint16", "TypeU32": "uint32",
|
||||
"TypeI32": "int32", "TypeBool": "bool", "TypeDomains": "domains", "TypeRoutes": "routes", "TypeHex": "hex"}
|
||||
reserved = re.findall(r'^\t(\d+): +"([^"]+)",', src, re.M)
|
||||
|
||||
out = """# DHCP options
|
||||
|
||||
Options are set by name, or by number with a type. Set them in the
|
||||
settings (every subnet), on a subnet, or on a reservation; the most
|
||||
specific wins ([subnets.md](subnets.md#options)).
|
||||
|
||||
```sh
|
||||
steward settings set --option domain-name-servers=10.0.10.2,10.0.10.3
|
||||
steward subnet update office --option routers=10.0.10.1 --unset-option ntp-servers
|
||||
steward reservation update nas --option 224:string=hello # an option Steward doesn't know
|
||||
```
|
||||
|
||||
`steward option list` prints the list below; the web UI offers it in each
|
||||
options editor. (This page is written by scripts/optionsdoc.py from the
|
||||
catalog in internal/dhcp4/catalog.go.)
|
||||
|
||||
## Options by name
|
||||
|
||||
| Code | Name | Type | What |
|
||||
|---:|---|---|---|
|
||||
"""
|
||||
for code, name, typ, help_ in rows:
|
||||
help_ = help_.replace('\\"', '"').replace("|", "\\|")
|
||||
out += f"| {code} | `{name}` | {types[typ]} | {help_} |\n"
|
||||
out += """
|
||||
The names are Kea's, so a Kea configuration's options carry over.
|
||||
|
||||
## Value types
|
||||
|
||||
| Type | How to write it | Example |
|
||||
|---|---|---|
|
||||
| ip | an IPv4 address | `10.0.10.255` |
|
||||
| ips | IPv4 addresses, separated by commas or spaces, in order of preference | `10.0.10.2, 10.0.10.3` |
|
||||
| string | text, as it is | `lan` |
|
||||
| uint8, uint16, uint32 | a whole number | `1500` |
|
||||
| int32 | a whole number, maybe negative | `-3600` |
|
||||
| bool | `true` or `false` (`yes`/`no`, `on`/`off`, `1`/`0` work too) | `false` |
|
||||
| domains | domain names, separated by commas or spaces; sent in DNS wire format (RFC 3397) | `lan, home.arpa` |
|
||||
| routes | `DESTINATION/LENGTH via ROUTER`, separated by commas (Kea's `DEST - ROUTER` works too); RFC 3442 | `10.20.0.0/16 via 10.0.10.1, 0.0.0.0/0 via 10.0.10.1` |
|
||||
| hex | bytes in hex, with or without colons | `01:02:0a` |
|
||||
|
||||
Values are checked when they are set, and stored in a canonical form:
|
||||
`10.0.10.2,10.0.10.3` becomes `10.0.10.2, 10.0.10.3`.
|
||||
|
||||
Clients that use `classless-static-route` ignore `routers`, so give the
|
||||
default route in it as well (`0.0.0.0/0 via ROUTER`).
|
||||
|
||||
## Options by number
|
||||
|
||||
An option the list doesn't have is set by its number and type,
|
||||
`CODE:TYPE=VALUE`; without a type the value is hex:
|
||||
|
||||
```sh
|
||||
steward subnet update office --option 224:string=hello --option 225=01:02
|
||||
```
|
||||
|
||||
In the API, give `{"option": "224", "type": "string", "value": "hello"}`.
|
||||
|
||||
## Options Steward sets itself
|
||||
|
||||
These can't be set; Steward fills them in (`option_reserved` if you try):
|
||||
|
||||
| Code | Why |
|
||||
|---:|---|
|
||||
"""
|
||||
for code, why in reserved:
|
||||
out += f"| {code} | {why} |\n"
|
||||
open("docs/options.md", "w").write(out)
|
||||
print("wrote docs/options.md")
|
||||
Reference in new issue
Block a user