Files
smallwebwaf/script/example-app
T
clawbot f408f42b28
check / check (push) Waiting to run
SPEC, README and docs rewritten from scratch (closes #154, closes #155, closes #156)
SPEC.md is the only place that says how smallwebwaf behaves and why; docs/ holds lists linking to it; the README is short and links to both. Passed independent review in round 8.

Model: fable-5-1 (writing); opus-5-5 (review, merge)
2026-10-26 03:06:28 +01:00

189 lines
7.6 KiB
Bash
Executable File

#!/bin/sh
# script/example-app: build the image, and on it the example app in
# deploy/example-app, then run the app's container with a volume for the
# state files and check that the health check passes, that a request is
# served through smallwebwaf, that a second one in a minute bans the
# client, that a probe for /.env bans another client, which its next
# request bans for good, that `sv stop` stops smallwebwaf in order, that
# `docker stop` stops the container without having to kill it, that a
# new container on the same volume still refuses the banned client, that
# a container with its state files on a new volume mounted at the
# directory SWWAF_STATE_DIR_FILE names becomes healthy, and that one whose
# SWWAF_STATE_DIR does not exist logs smallwebwaf's message about it. The
# containers run with SWWAF_LOOKUP_SOURCE=off, so that no address is sent
# to GeoJS. The containers, the volumes and both images are removed
# however the script ends. Building the app needs network access, for
# nixpkgs' binary cache. script/check does not run this.
set -eu
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)"
# Named after this run, so that runs in other clones on the same host
# never touch each other's.
NAME="$("$SCRIPT_DIR/projectname")-example-$$"
IMAGE="$NAME-base"
APP_IMAGE="$NAME-app"
CONTAINER="$NAME"
VOLUME="$NAME-state"
# The volumes of the container whose state directory SWWAF_STATE_DIR_FILE
# names: its state files, and the file that names their directory.
MOVED_VOLUME="$NAME-moved-state"
SETTINGS_VOLUME="$NAME-settings"
cleanup() {
docker rm --force "$CONTAINER" >/dev/null 2>&1 || true
docker volume rm --force "$VOLUME" "$MOVED_VOLUME" "$SETTINGS_VOLUME" \
>/dev/null 2>&1 || true
docker rmi --force "$APP_IMAGE" "$IMAGE" >/dev/null 2>&1 || true
}
fail() {
echo "example-app: $*; the container's output:" >&2
docker logs "$CONTAINER" >&2 || true
exit 1
}
# wait_for <what fails> <command>...: run the command every second until
# it succeeds, for at most a minute.
wait_for() {
failure="$1"
shift
tries=0
until "$@"; do
tries=$((tries + 1))
[ "$tries" -lt 60 ] || fail "$failure"
sleep 1
done
}
healthy() {
status="$(docker inspect --format '{{.State.Health.Status}}' "$CONTAINER")"
[ "$status" = healthy ]
}
# logged <text>...: a line of the container's output holds every text,
# in any order.
logged() {
lines="$(docker logs "$CONTAINER" 2>&1)"
for text in "$@"; do
lines="$(printf '%s\n' "$lines" | grep -F "$text")" || return 1
done
}
# start_container: run the app's container, with the state files on the
# volume, a rate limit of one request a minute and no client looked up,
# and wait until it is healthy.
start_container() {
docker run --detach --name "$CONTAINER" --publish 127.0.0.1::8080 \
--volume "$VOLUME:/var/lib/smallwebwaf" \
--env SWWAF_RATE_LIMIT_PER_MINUTE=1 \
--env SWWAF_LOOKUP_SOURCE=off \
"$APP_IMAGE" >/dev/null
wait_for "the health check did not pass" healthy
address="$(docker port "$CONTAINER" 8080/tcp)"
}
# start_moved_container: run the app's container with the state files on
# a new volume at /srv/state, which SWWAF_STATE_DIR_FILE names in a file
# only the smallwebwaf user can read, as under "Settings given as files"
# in docs/settings.md, and wait until it is healthy. smallwebwaf can write the
# new volume only once the run script has given it to that user.
start_moved_container() {
docker volume create "$MOVED_VOLUME" >/dev/null
docker volume create "$SETTINGS_VOLUME" >/dev/null
docker run --rm --volume "$SETTINGS_VOLUME:/settings" \
--entrypoint bash "$IMAGE" -c 'echo /srv/state > /settings/state-dir &&
chown smallwebwaf:smallwebwaf /settings/state-dir &&
chmod 0400 /settings/state-dir'
docker run --detach --name "$CONTAINER" \
--volume "$MOVED_VOLUME:/srv/state" \
--volume "$SETTINGS_VOLUME:/etc/smallwebwaf/settings:ro" \
--env SWWAF_STATE_DIR_FILE=/etc/smallwebwaf/settings/state-dir \
--env SWWAF_LOOKUP_SOURCE=off \
"$APP_IMAGE" >/dev/null
wait_for "the health check did not pass" healthy
}
# refused: a request to the container gets 403, SWWAF_BAN_RESPONSE's
# default.
refused() {
code="$(curl --silent --output /dev/null --write-out '%{http_code}' \
--max-time 10 "http://$address/")" || true
[ "$code" = 403 ]
}
# refused_from <client> <path>: a request for path from client, as
# X-Forwarded-For names it, gets 403. smallwebwaf believes the header
# from docker's gateway, a private address.
refused_from() {
code="$(curl --silent --output /dev/null --write-out '%{http_code}' \
--max-time 10 --header "X-Forwarded-For: $1" "http://$address$2")" || true
[ "$code" = 403 ]
}
main() {
cd "$ROOT"
trap cleanup EXIT
trap 'exit 1' HUP INT TERM
docker build --no-cache -t "$IMAGE" .
docker build --no-cache --build-arg SMALLWEBWAF_IMAGE="$IMAGE" \
-t "$APP_IMAGE" deploy/example-app
docker volume create "$VOLUME" >/dev/null
start_container
echo "example-app: the health check passes"
page="$(curl --fail --silent --show-error --max-time 10 "http://$address/")" ||
fail "no answer on port 8080"
[ "$page" = "hello from the example app" ] || fail "port 8080 answered $page"
wait_for "smallwebwaf logged no request it forwarded" logged '"action":"forward"'
echo "example-app: smallwebwaf passes a request to the app and its answer back"
refused || fail "a second request in a minute was not refused"
wait_for "smallwebwaf logged no ban" logged '"action":"rate_limited"'
echo "example-app: a second request in a minute bans the client"
refused_from 203.0.113.9 /.env || fail "a probe for /.env was not refused"
wait_for "smallwebwaf logged no ban for the probe" \
logged '"action":"banned"' '"rule_ids":["env-file"]'
refused_from 203.0.113.9 / || fail "the client of the probe was let through"
wait_for "the client's next request did not make its ban permanent" \
logged '"ban_expires":"permanent"'
echo "example-app: a probe for /.env bans the client, its next request for good"
docker exec "$CONTAINER" sv stop smallwebwaf >/dev/null ||
fail "sv stop smallwebwaf failed"
wait_for "smallwebwaf did not stop in order" logged '"msg":"stopped"'
echo "example-app: sv stop stops smallwebwaf in order"
docker stop "$CONTAINER" >/dev/null
status="$(docker inspect --format '{{.State.ExitCode}}' "$CONTAINER")"
[ "$status" = 0 ] || fail "docker stop left exit status $status"
echo "example-app: docker stop stops the container in order"
docker rm "$CONTAINER" >/dev/null
start_container
refused || fail "the new container let the banned client through"
wait_for "smallwebwaf logged no request refused under the ban" \
logged '"action":"banned"'
echo "example-app: a new container on the same volume keeps the ban"
docker rm --force "$CONTAINER" >/dev/null
start_moved_container
echo "example-app: the run script gives the state files' directory named" \
"by SWWAF_STATE_DIR_FILE to the smallwebwaf user"
docker rm --force "$CONTAINER" >/dev/null
docker run --detach --name "$CONTAINER" \
--env SWWAF_STATE_DIR=/srv/missing \
--env SWWAF_LOOKUP_SOURCE=off \
"$APP_IMAGE" >/dev/null
wait_for "smallwebwaf did not report the missing state directory" \
logged "SWWAF_STATE_DIR cannot be written"
echo "example-app: smallwebwaf reports a state directory that does not exist"
}
main "$@"