Files
smallwebwaf/script/example-app
T
clawbot 5fd4c961a2
check / check (push) Canceled after 0s
smallwebwaf starts with SWWAF_STATE_DIR given as a file on a new volume (closes #137)
The run script gave ${SWWAF_STATE_DIR:-/var/lib/smallwebwaf} to the
smallwebwaf user and never read SWWAF_STATE_DIR_FILE, so a new volume
at the directory that file names stayed root's. The run script now
takes the setting from the variable or the file, and changes the owner
only when it is the absolute path of a directory that exists;
otherwise it starts smallwebwaf, which stops with its message naming
the setting. A chown that fails, as on a read-only volume, no longer
ends the run script. script/example-app, and its entry in README.md,
gain two steps: a container on such a volume becomes healthy, and a
missing state directory reaches smallwebwaf's message.

Model: opus-5-5
2026-10-20 14:27:11 +00: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 README.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 "$@"