Files
steward/scripts/e2e.sh
T
abrown38andClaude Opus 5.5 3ad2f981d4 Allow and deny lists per subnet; every option of a DHCP message in words
- A subnet is a deny-list subnet (the default: everyone not denied) or
  an allow-list subnet (only clients allowed or with a reservation);
  the deny list counts in both. Entries are MAC addresses, prefixes
  (aa:bb:cc:*) or client IDs. A refused client gets no answer (DENIED
  in the activity log, with an Allow button) or a NAK on renewal.
  steward access, the subnet's Access tab. Schema migration 7.
- The activity log keeps each message's header fields and options,
  decoded into words (dhcp4.Describe): the parameter request list by
  name, client IDs, lease times. ▸ on an Activity row, steward
  activity show N / --details, GET /events/{id}.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 20:27:51 +01:00

278 lines
14 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 "disable the reservation" 0 S reservation disable $NA.20
run "a disabled reservation's client gets a pool address" 0 udhcpc a 02:00:00:00:00:20
contains "BOUND ip=$NA.10"
not_contains "hostname=printer"
POOLIP=$(echo "$OUT" | sed -n 's/^BOUND ip=\([0-9.]*\) .*/\1/p')
run "enable it again" 0 S reservation enable $NA.20
run "the reserved address again" 0 udhcpc a 02:00:00:00:00:20
contains "BOUND ip=$NA.20 .*hostname=printer"
run "free the pool address it had meanwhile" 0 S lease delete "$POOLIP" --yes
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"
# ---- last seen: a client that stays up answers pings
# busybox's default script puts the leased address on the interface.
docker run -d --name ${P}-live --network ${P}-b --mac-address 02:00:00:00:00:30 --cap-add NET_ADMIN steward-e2e-client \
udhcpc -i eth0 -f -x hostname:live >/dev/null
for i in $(seq 1 20); do S lease list | grep -q " live " && break; sleep 0.5; done
# ---- 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"
# A ping round runs at start (then every minute).
sleep 2
run "last seen" 0 S lease list
contains "live *active *in [0-9hm]* *now *eth1"
contains "laptop *active *in [0-9hm]* *- *eth0"
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
H="$WORK/steward --config $WORK/cli.yaml --server http://$NA.2:8067"
NEW=$($H --token "$TOKEN" token rotate --current --grace 1h 2>/dev/null)
run "rotate the CLI's own token" 0 test -n "$NEW"
run "the new secret works" 0 $H --token "$NEW" whoami
run "the old one too, during its grace period" 0 $H --token "$TOKEN" whoami
NEWER=$($H --token "$NEW" token rotate --current 2>/dev/null)
run "rotated without grace: the old secret stops" 3 $H --token "$NEW" whoami
TOKEN=$NEWER
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"
# ---- address order
run "reverse address order" 0 S subnet update iot --address-order reverse
run "the subnet shows its order" 0 S subnet show iot
contains "addresses reverse"
run "a new client gets the highest never-used address" 0 udhcpc b 02:00:00:00:00:40
contains "BOUND ip=$NB.110 "
run "a pool's own order wins" 0 S subnet update iot --add-pool $NB.100-$NB.110:sequential
run "the pool shows it" 0 S subnet show iot
contains "$NB.100-$NB.110:sequential"
run "now the lowest never-used one" 0 udhcpc b 02:00:00:00:00:41
contains "BOUND ip=$NB.10[0-8] "
run "an unknown order is refused" 1 S subnet update iot --add-pool $NB.100-$NB.110:lowest
# ---- a reservation's own lease time
run "reservation lease time" 0 S reservation update $NA.20 --lease-time 2h
run "the reserved client gets it" 0 udhcpc a 02:00:00:00:00:20
contains "BOUND ip=$NA.20 .*lease=7200"
# ---- every option of a message
run "activity with details" 0 S activity --mac 02:00:00:00:00:20 --limit 4 -d
contains "parameter-request-list: "
contains "option 51 lease-time: 7200 s (2h)"
ID=$(S activity --mac 02:00:00:00:00:20 --limit 1 | sed -n 's/^#\([0-9]*\) .*/\1/p' | tail -1)
run "one message" 0 S activity show "$ID"
contains "DHCPACK on eth0"
contains "server-identifier: $NA.2"
# ---- allow and deny lists
run "deny a client" 0 S access deny iot 02:00:00:00:00:50 --comment "test"
run "a denied client gets nothing" 1 udhcpc b 02:00:00:00:00:50
run "activity says it was denied" 0 S activity --mac 02:00:00:00:00:50
contains "DENIED.*deny list of iot .*(test)"
run "allow-list mode" 0 S access mode iot allow-list
run "an unlisted client gets nothing" 1 udhcpc b 02:00:00:00:00:51
run "allow by prefix" 0 S access allow iot "02:00:00:00:00:*"
run "now it gets an address" 0 udhcpc b 02:00:00:00:00:51
contains "BOUND ip=$NB."
run "the deny list still wins" 1 udhcpc b 02:00:00:00:00:50
run "access shows it all" 0 S access show iot
contains "allow-list: 1 allowed"
run "back to a deny-list subnet" 0 S access mode iot deny-list
run "remove the entries" 0 S access remove iot 02:00:00:00:00:50 "02:00:00:00:00:*"
# ---- 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 ]