check / check (push) Failing after 3m27s
Every setting of discriminator and rproxy is read through viper, in place of the os.LookupEnv function Main passed down. NewViper turns AutomaticEnv and AllowEmptyEnv on, so a setting that is set but empty reads as set. A setting may also be given as a file, the _FILE form, which rproxy's settings now have too; rproxy still writes nothing to disk. The settings are renamed. Discriminator's take the prefix SWW_WAF_, rproxy's take SWW_RPROXY_, and the shared SWW_COMMAND keeps SWW_. The Core Rule Set's settings take SWW_WAF_CORE_RULE_SET_ rather than SWW_WAF_WAF_. Every place a name appeared changed: the code, the viper keys, tests, documents, examples, the README's settings table and the image files. Model: deepseek-v4-pro
255 lines
11 KiB
Bash
Executable File
255 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
|
|
# discriminator 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 discriminator's container changes the state directory's owner;
|
|
# - that the app still answers through rproxy with discriminator stopped, and
|
|
# that once discriminator 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 discriminator container on the same volume still refuses the banned
|
|
# client;
|
|
# - that a discriminator container with its state files on a new volume mounted at
|
|
# the directory SWW_WAF_STATE_DIR_FILE names becomes healthy;
|
|
# - that one whose SWW_WAF_STATE_DIR does not exist logs discriminator's message
|
|
# about it.
|
|
# discriminator runs with SWW_WAF_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"
|
|
DISCRIMINATOR="$NAME-discriminator"
|
|
VOLUME="$NAME-state"
|
|
# The volumes of the container whose state directory SWW_WAF_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" "$DISCRIMINATOR" >/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: discriminator's output:" >&2
|
|
docker logs "$DISCRIMINATOR" >&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_discriminator: run discriminator 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_discriminator() {
|
|
docker run --detach --name "$DISCRIMINATOR" --network "$NETWORK" \
|
|
--volume "$VOLUME:/var/lib/sww" \
|
|
--env SWW_COMMAND=discriminator \
|
|
--env SWW_WAF_RATE_LIMIT_PER_MINUTE=1 \
|
|
--env SWW_WAF_LOOKUP_SOURCE=off \
|
|
"$IMAGE" >/dev/null
|
|
wait_for "discriminator's health check did not pass" healthy "$DISCRIMINATOR"
|
|
}
|
|
|
|
# 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 SWW_RPROXY_WAF_BACKEND="http://$DISCRIMINATOR: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_discriminator: run discriminator with the state files on a new volume at
|
|
# /srv/state, which SWW_WAF_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. discriminator can write the new volume only once the run
|
|
# script has given it to that user.
|
|
start_moved_discriminator() {
|
|
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 "$DISCRIMINATOR" --network "$NETWORK" \
|
|
--volume "$MOVED_VOLUME:/srv/state" \
|
|
--volume "$SETTINGS_VOLUME:/etc/sww/settings:ro" \
|
|
--env SWW_COMMAND=discriminator \
|
|
--env SWW_WAF_STATE_DIR_FILE=/etc/sww/settings/state-dir \
|
|
--env SWW_WAF_LOOKUP_SOURCE=off \
|
|
"$IMAGE" >/dev/null
|
|
wait_for "discriminator's health check did not pass" healthy "$DISCRIMINATOR"
|
|
}
|
|
|
|
# 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_WAF_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_state_dir <container>: print who owns /var/lib/sww there.
|
|
owner_of_state_dir() {
|
|
docker exec "$1" stat --format '%U' /var/lib/sww
|
|
}
|
|
|
|
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_discriminator
|
|
start_app
|
|
echo "example-app: the health checks pass"
|
|
|
|
[ "$(owner_of_state_dir "$DISCRIMINATOR")" = sww ] ||
|
|
fail "discriminator's container did not give the state directory to sww"
|
|
[ "$(owner_of_state_dir "$APP")" = root ] ||
|
|
fail "the app's container changed the state directory's owner"
|
|
echo "example-app: only discriminator's container changes the state directory's owner"
|
|
|
|
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 "discriminator logged no request it allowed" \
|
|
logged "$DISCRIMINATOR" '"action":"forward"' '"discriminator_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 "discriminator logged no ban" logged "$DISCRIMINATOR" '"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 "discriminator logged no ban for the probe" \
|
|
logged "$DISCRIMINATOR" '"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 "$DISCRIMINATOR" '"ban_expires":"permanent"'
|
|
echo "example-app: a probe for /.env bans the client, its next request for good"
|
|
|
|
docker exec "$DISCRIMINATOR" sv stop sww >/dev/null ||
|
|
fail "sv stop sww failed in discriminator's container"
|
|
wait_for "discriminator did not stop in order" logged "$DISCRIMINATOR" '"msg":"stopped"'
|
|
[ "$(status_from 203.0.113.9 /)" = 200 ] ||
|
|
fail "with discriminator stopped, the app did not answer through rproxy"
|
|
wait_for "rproxy logged no request SWW_RPROXY_DEFAULT_DISPOSITION decided" \
|
|
logged "$APP" '"default_disposition":true'
|
|
docker exec "$APP" /usr/local/bin/sww healthcheck ||
|
|
fail "with discriminator stopped, the app's health check failed"
|
|
echo "example-app: with discriminator stopped, the app answers through rproxy"
|
|
|
|
docker exec "$DISCRIMINATOR" sv start sww >/dev/null ||
|
|
fail "sv start sww failed in discriminator's container"
|
|
wait_for "once discriminator had started again, the ban did not apply" \
|
|
refused_from 203.0.113.9 /
|
|
echo "example-app: once discriminator 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" "$DISCRIMINATOR"; 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" "$DISCRIMINATOR" >/dev/null
|
|
start_discriminator
|
|
start_app
|
|
refused || fail "the new discriminator container let the banned client through"
|
|
wait_for "discriminator logged no request refused under the ban" \
|
|
logged "$DISCRIMINATOR" '"action":"banned"'
|
|
echo "example-app: a new discriminator container on the same volume keeps the ban"
|
|
|
|
docker rm --force "$DISCRIMINATOR" >/dev/null
|
|
start_moved_discriminator
|
|
echo "example-app: the run script gives the state files' directory named" \
|
|
"by SWW_WAF_STATE_DIR_FILE to the sww user"
|
|
|
|
docker rm --force "$DISCRIMINATOR" >/dev/null
|
|
docker run --detach --name "$DISCRIMINATOR" --network "$NETWORK" \
|
|
--env SWW_COMMAND=discriminator \
|
|
--env SWW_WAF_STATE_DIR=/srv/missing \
|
|
--env SWW_WAF_LOOKUP_SOURCE=off \
|
|
"$IMAGE" >/dev/null
|
|
wait_for "discriminator did not report the missing state directory" \
|
|
logged "$DISCRIMINATOR" "SWW_WAF_STATE_DIR cannot be written"
|
|
echo "example-app: discriminator reports a state directory that does not exist"
|
|
}
|
|
|
|
main "$@"
|