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