#!/bin/sh # script/gitea-setup: build the image, bring up deploy/gitea under a project # name of its own, and check it: # - that a page load, a push and a clone sent through traefik pass through # rproxy, and that servewaf checks them; # - that gitea logs the visitor's address, not rproxy's; # - that a request from a container on the shared network skips the checks; # - that with servewaf stopped, gitea still answers through rproxy. # It publishes no port of traefik's, since this host's ports 80 and 443 may be # taken. It reaches traefik at its address on the shared network instead, so # its own requests come from the network's gateway, which servewaf checks like # any visitor. Two runs at once on one docker host cannot both start, since # both would use the subnet compose.yaml names. The containers, the network, # the volumes, the image and the files the script makes are removed however it # ends; the gitea and traefik images stay, as the base images of a build do. # It needs network access. 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")-gitea-setup-$$" IMAGE="$NAME-image" NETWORK="${NAME}_shared" VISITOR="$NAME-visitor" # The site's host name and gitea's image, as deploy/gitea/compose.yaml names # them. gitea's image also runs the container on the shared network. SITE="git.example.org" # gitea 1.27.3, 2026-08-29 GITEA_IMAGE="gitea/gitea@sha256:87a67ee09d3ae0d1df5fda5dcda3e2a1f9236a45b0a59025d6e00e46adc43bef" # gitea's one user, who owns the repository. PASSWORD="owner-password" # The files the script makes, and traefik's and the gateway's addresses on the # shared network once the setup runs. WORK="" traefik="" gateway="" cleanup() { # So that a signal cannot stop it partway through. trap '' HUP INT TERM docker rm --force "$VISITOR" >/dev/null 2>&1 || true if [ -n "$WORK" ]; then compose down --volumes --remove-orphans --timeout 5 >/dev/null 2>&1 || true rm -rf "$WORK" fi docker rmi --force "$IMAGE" >/dev/null 2>&1 || true } # compose ...: docker compose on the setup, under this run's project # name, with the image built here and no port of traefik's published. compose() { SMALLWEBWAF_IMAGE="$IMAGE" docker compose --project-name "$NAME" \ --file "$ROOT/deploy/gitea/compose.yaml" --file "$WORK/no-ports.yaml" "$@" } fail() { echo "gitea-setup: $*; the last lines of each container's output:" >&2 compose logs --no-color --tail 30 >&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 } # logged ...: a line of the service's output holds every text, # in any order. logged() { lines="$(compose logs --no-color --no-log-prefix "$1" 2>&1)" shift for text in "$@"; do lines="$(printf '%s\n' "$lines" | grep -F -e "$text")" || return 1 done } # send ...: send a request for path to the site, at # traefik's address, and print the status of the answer, 000 for none. traefik # answers with a certificate it made itself, since the setup names no # certificate resolver. send() { path="$1" shift curl --silent --show-error --insecure --max-time 30 --output /dev/null \ --write-out '%{http_code}' --resolve "$SITE:443:$traefik" "$@" \ "https://$SITE$path" || true } # routed: traefik passes a request for the site's front page on to gitea. routed() { [ "$(send / 2>/dev/null)" = 200 ] } # site_git ...: run git as the owner, without the host's own git # settings, reaching the site at traefik's address. git gives up on a transfer # slower than 1000 bytes a second for 30 seconds. site_git() { GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_NOSYSTEM=1 git \ -c user.name=owner -c user.email=owner@example.invalid \ -c http.sslVerify=false -c "http.curloptResolve=$SITE:443:$traefik" \ -c http.lowSpeedLimit=1000 -c http.lowSpeedTime=30 "$@" } # address_of : print the service's address on the shared network. address_of() { docker inspect --format "{{(index .NetworkSettings.Networks \"$NETWORK\").IPAddress}}" \ "$(compose ps --quiet "$1")" } # passed_through : rproxy passed a request from the gateway for # path to gitea on servewaf's allow, and servewaf checked it: its report line # gives the client's counts and the Core Rule Set's score. passed_through() { wait_for "rproxy logged no $1 $2 that servewaf allowed" logged rproxy \ "\"method\":\"$1\"" "\"path\":\"$2\"" "\"client_ip\":\"$gateway\"" \ '"disposition":"allow"' wait_for "servewaf logged no check of $1 $2" logged servewaf \ '"servewaf_line":"report"' "\"method\":\"$1\"" "\"path\":\"$2\"" \ "\"client_ip\":\"$gateway\"" '"action":"forward"' '"counts":' '"waf_score":' } main() { cd "$ROOT" trap cleanup EXIT trap 'exit 1' HUP INT TERM WORK="$(mktemp -d)" printf 'services:\n traefik:\n ports: !reset []\n' >"$WORK/no-ports.yaml" docker build --no-cache -t "$IMAGE" . # rproxy is healthy once gitea takes connections. compose up --detach --quiet-pull --wait --wait-timeout 120 || fail "the setup did not start" traefik="$(address_of traefik)" gateway="$(docker network inspect --format '{{(index .IPAM.Config 0).Gateway}}' "$NETWORK")" wait_for "traefik did not route the site to rproxy" routed compose exec --user git gitea gitea admin user create --admin \ --username owner --password "$PASSWORD" --email owner@example.invalid \ --must-change-password=false >/dev/null || fail "creating gitea's user failed" status="$(send /api/v1/user/repos --user "owner:$PASSWORD" \ --header "Content-Type: application/json" --data '{"name":"repo"}')" [ "$status" = 201 ] || fail "creating the repository was answered $status" status="$(send /owner/repo)" [ "$status" = 200 ] || fail "the repository's page was answered $status" passed_through GET /owner/repo echo "gitea-setup: a page load passes through rproxy, and servewaf checks it" wait_for "gitea did not log the visitor's address" logged gitea \ "GET /owner/repo for $gateway:" echo "gitea-setup: gitea logs the visitor's address, not rproxy's" site_git init --quiet --initial-branch=main "$WORK/repo" echo "hello" >"$WORK/repo/README.md" site_git -C "$WORK/repo" add README.md site_git -C "$WORK/repo" commit --quiet --message "Add a README" site_git -C "$WORK/repo" push --quiet \ "https://owner:$PASSWORD@$SITE/owner/repo.git" main || fail "the push failed" passed_through POST /owner/repo.git/git-receive-pack echo "gitea-setup: a push passes through rproxy, and servewaf checks it" site_git clone --quiet "https://owner:$PASSWORD@$SITE/owner/repo.git" \ "$WORK/clone" || fail "the clone failed" [ "$(cat "$WORK/clone/README.md")" = hello ] || fail "the clone lacks the README" passed_through POST /owner/repo.git/git-upload-pack echo "gitea-setup: a clone passes through rproxy, and servewaf checks it" # A container on the shared network, as sandcastle is, asks for the site # by its name, and so reaches traefik from its own address. status="$(docker run --rm --name "$VISITOR" --network "$NETWORK" \ --entrypoint curl "$GITEA_IMAGE" --silent --insecure --max-time 30 \ --output /dev/null --write-out '%{http_code}' "https://$SITE/api/v1/version" || true)" [ "$status" = 200 ] || fail "the request from the shared network was answered $status" wait_for "servewaf logged no request from the shared network" logged servewaf \ '"servewaf_line":"report"' '"path":"/api/v1/version"' '"action":"forward"' line="$(compose logs --no-color --no-log-prefix servewaf | grep -F '"servewaf_line":"report"' | grep -F '"path":"/api/v1/version"')" case "$line" in *"\"client_ip\":\"$gateway\""*) fail "the request from the shared network came from the gateway" ;; *'"counts":'* | *'"waf_score":'*) fail "servewaf checked the request from the shared network" ;; esac echo "gitea-setup: a request from the shared network skips the checks" compose stop servewaf >/dev/null 2>&1 || fail "servewaf did not stop" status="$(send /owner/repo)" [ "$status" = 200 ] || fail "with servewaf stopped, the repository's page was answered $status" wait_for "rproxy logged no request SWRP_DEFAULT_DISPOSITION decided" \ logged rproxy '"path":"/owner/repo"' '"default_disposition":true' echo "gitea-setup: with servewaf stopped, gitea answers through rproxy" } main "$@"