Files
abrown38andClaude Opus 5.5 3c0808591f 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>
2026-10-14 12:14:07 +01:00

202 lines
10 KiB
Bash
Executable File

#!/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 ]