Files
smallwebwaf/script/example-app
T
clawbot 697c2225d9
check / check (push) Waiting to run
The image creates SWW_STATE_DIR when it is missing (closes #337)
Before `waf` starts, the image's `run` script now makes `SWW_STATE_DIR` if it is missing (`mkdir -p`), gives it to `sww:sww` and sets its mode to `0700`, as it already does for the cache directory's owner. A value that is not an absolute path, or an unreadable `SWW_STATE_DIR_FILE`, still changes nothing, and `waf` stops naming the setting. The `Dockerfile` comment, `docs/image.md`, `docs/settings.md` and `SPEC.md` say the image makes the directory. `script/example-app` checks that a missing `SWW_STATE_DIR` is made, owned by `sww` with mode `0700`.

- `chmod 0700` also applies to an existing directory, a mounted volume included, at every start of `waf`.

Model: opus-5-5
2026-11-11 11:33:08 +01:00

268 lines
11 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 rproxy in it, and
# waf in a container of its own with a volume for the state files. It
# checks:
# - that the health checks pass;
# - that a request is served through rproxy, and 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 only waf's container changes the state directory's owner;
# - that waf's container makes the cache directory for the sww user;
# - that the app still answers through rproxy with waf stopped, and
# that once waf has started again, bans apply again;
# - that `sv stop` stops rproxy in order, and `docker stop` stops each
# container without having to kill it;
# - that a new waf container on the same volume still refuses the banned
# client;
# - that a waf container with its state files on a new volume mounted at
# the directory SWW_STATE_DIR_FILE names becomes healthy;
# - that the run script makes a SWW_STATE_DIR that does not exist, owned
# by sww with mode 0700, and that waf then becomes healthy.
# waf runs with SWW_LOOKUP_SOURCE=off, so that no address is sent to
# GeoJS. The containers, the network, 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"
NETWORK="$NAME"
APP="$NAME-app-container"
WAF="$NAME-waf"
VOLUME="$NAME-state"
# The volumes of the container whose state directory SWW_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 "$APP" "$WAF" >/dev/null 2>&1 || true
docker network rm "$NETWORK" >/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 app container's output:" >&2
docker logs "$APP" >&2 || true
echo "example-app: waf's output:" >&2
docker logs "$WAF" >&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 <container>: docker says the container's health check passes.
healthy() {
status="$(docker inspect --format '{{.State.Health.Status}}' "$1")"
[ "$status" = healthy ]
}
# logged <container> <text>...: a line of the container's output holds every
# text, in any order.
logged() {
lines="$(docker logs "$1" 2>&1)"
shift
for text in "$@"; do
lines="$(printf '%s\n' "$lines" | grep -F "$text")" || return 1
done
}
# start_waf: run waf from the image, 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_waf() {
docker run --detach --name "$WAF" --network "$NETWORK" \
--volume "$VOLUME:/var/lib/sww" \
--env SWW_COMMAND=waf \
--env SWW_RATE_LIMIT_PER_MINUTE=1 \
--env SWW_LOOKUP_SOURCE=off \
"$IMAGE" >/dev/null
wait_for "waf's health check did not pass" healthy "$WAF"
}
# start_app: run the app's container, with rproxy in it, and wait until it is
# healthy.
start_app() {
docker run --detach --name "$APP" --network "$NETWORK" --publish 127.0.0.1::8080 \
--env SWRP_WAF_BACKEND="http://$WAF:8080" \
"$APP_IMAGE" >/dev/null
wait_for "the app's health check did not pass" healthy "$APP"
address="$(docker port "$APP" 8080/tcp)"
}
# start_moved_waf: run waf with the state files on a new volume at
# /srv/state, which SWW_STATE_DIR_FILE names in a file only the sww
# user can read, as under "Settings given as files" in docs/settings.md, and
# wait until it is healthy. waf can write the new volume only once the run
# script has given it to that user.
start_moved_waf() {
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 sww:sww /settings/state-dir &&
chmod 0400 /settings/state-dir'
docker run --detach --name "$WAF" --network "$NETWORK" \
--volume "$MOVED_VOLUME:/srv/state" \
--volume "$SETTINGS_VOLUME:/etc/sww/settings:ro" \
--env SWW_COMMAND=waf \
--env SWW_STATE_DIR_FILE=/etc/sww/settings/state-dir \
--env SWW_LOOKUP_SOURCE=off \
"$IMAGE" >/dev/null
wait_for "waf's health check did not pass" healthy "$WAF"
}
# status_from <client> <path>: print the status a request for path from client,
# as X-Forwarded-For names it, gets. rproxy believes the header from docker's
# gateway, a private address.
status_from() {
curl --silent --output /dev/null --write-out '%{http_code}' \
--max-time 10 --header "X-Forwarded-For: $1" "http://$address$2" || true
}
# refused: a request to the container gets 403, SWW_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 gets 403.
refused_from() {
[ "$(status_from "$1" "$2")" = 403 ]
}
# owner_of <container> <dir>: print who owns dir there.
owner_of() {
docker exec "$1" stat --format '%U' "$2"
}
# owner_and_mode_of <container> <dir>: print who owns dir there, and its
# mode in octal.
owner_and_mode_of() {
docker exec "$1" stat --format '%U %a' "$2"
}
main() {
cd "$ROOT"
trap cleanup EXIT
trap 'exit 1' HUP INT TERM
docker build --no-cache -t "$IMAGE" .
docker build --no-cache --build-arg SWW_IMAGE="$IMAGE" \
-t "$APP_IMAGE" deploy/example-app
docker network create "$NETWORK" >/dev/null
docker volume create "$VOLUME" >/dev/null
start_waf
start_app
echo "example-app: the health checks pass"
[ "$(owner_of "$WAF" /var/lib/sww)" = sww ] ||
fail "waf's container did not give the state directory to sww"
[ "$(owner_of "$APP" /var/lib/sww)" = root ] ||
fail "the app's container changed the state directory's owner"
echo "example-app: only waf's container changes the state directory's owner"
[ "$(owner_of "$WAF" /var/cache/sww)" = sww ] ||
fail "waf's container did not make the cache directory for sww"
echo "example-app: waf's container makes the cache directory for sww"
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 "waf logged no request it allowed" \
logged "$WAF" '"action":"forward"' '"waf_line":"report"'
echo "example-app: rproxy passes a request to the app and its answer back"
refused || fail "a second request in a minute was not refused"
wait_for "waf logged no ban" \
logged "$WAF" '"action":"client_is_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 "waf logged no ban for the probe" \
logged "$WAF" '"action":"temporarily_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 "$WAF" '"action":"permanently_banned"' '"ban_expires":"permanent"'
echo "example-app: a probe for /.env bans the client, its next request for good"
docker exec "$WAF" sv stop sww >/dev/null ||
fail "sv stop sww failed in waf's container"
wait_for "waf did not stop in order" logged "$WAF" '"msg":"stopped"'
[ "$(status_from 203.0.113.9 /)" = 200 ] ||
fail "with waf stopped, the app did not answer through rproxy"
wait_for "rproxy logged no request SWRP_DEFAULT_DISPOSITION decided" \
logged "$APP" '"default_disposition":true'
docker exec "$APP" /usr/local/bin/sww healthcheck ||
fail "with waf stopped, the app's health check failed"
echo "example-app: with waf stopped, the app answers through rproxy"
docker exec "$WAF" sv start sww >/dev/null ||
fail "sv start sww failed in waf's container"
wait_for "once waf had started again, the ban did not apply" \
refused_from 203.0.113.9 /
echo "example-app: once waf has started again, bans apply again"
docker exec "$APP" sv stop sww >/dev/null ||
fail "sv stop sww failed in the app's container"
wait_for "rproxy did not stop in order" logged "$APP" '"msg":"stopped"'
echo "example-app: sv stop stops rproxy in order"
for container in "$APP" "$WAF"; do
docker stop "$container" >/dev/null
status="$(docker inspect --format '{{.State.ExitCode}}' "$container")"
[ "$status" = 0 ] || fail "docker stop left exit status $status"
done
echo "example-app: docker stop stops each container in order"
docker rm "$APP" "$WAF" >/dev/null
start_waf
start_app
refused || fail "the new waf container let the banned client through"
wait_for "waf logged no request refused under the ban" \
logged "$WAF" '"action":"temporarily_banned"'
echo "example-app: a new waf container on the same volume keeps the ban"
docker rm --force "$WAF" >/dev/null
start_moved_waf
echo "example-app: the run script gives the state files' directory named" \
"by SWW_STATE_DIR_FILE to the sww user"
docker rm --force "$WAF" >/dev/null
docker run --detach --name "$WAF" --network "$NETWORK" \
--env SWW_COMMAND=waf \
--env SWW_STATE_DIR=/srv/missing \
--env SWW_LOOKUP_SOURCE=off \
"$IMAGE" >/dev/null
wait_for "waf's health check did not pass" healthy "$WAF"
[ "$(owner_and_mode_of "$WAF" /srv/missing)" = "sww 700" ] ||
fail "the run script did not make SWW_STATE_DIR for sww with mode 0700"
echo "example-app: the run script makes a missing SWW_STATE_DIR for sww" \
"with mode 0700"
}
main "$@"