release / release (push) Successful in 5m53s
- A disabled reservation is kept but ignored: the client gets a pool address like any other (steward reservation disable|enable, buttons and a checkbox in the web UI). Schema migration 3. - Subnets get Disable/Enable buttons on their page and in the list. - A rule left without its selector in style.css broke the page heading's layout, so the New ... buttons sat cramped under the description. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
219 lines
11 KiB
Bash
Executable File
219 lines
11 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"
|
|
|
|
# ---- 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
|
|
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"
|
|
|
|
# ---- 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 ]
|