#!/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 ...: 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 : docker says the container's health check passes. healthy() { status="$(docker inspect --format '{{.State.Health.Status}}' "$1")" [ "$status" = healthy ] } # logged ...: 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 : 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 : a request for path from client gets 403. refused_from() { [ "$(status_from "$1" "$2")" = 403 ] } # owner_of_state_dir : 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 "$@"