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 ]
|
||||
Reference in new issue
Block a user