check / check (push) Failing after 1m40s
deploy/gitea/compose.yaml runs traefik, stock Gitea 1.27.3, rproxy and servewaf on one network with a fixed subnet. Its containers skip servewaf's checks; the network's gateway is left out of that range. rproxy takes uploads of 2 GiB and 30 minutes, and cuts responses at 15 minutes. script/gitea-setup (make gitea-setup) brings the setup up and checks it. docs/gitea.md is rewritten around the setup. Judgement call: the Core Rule Set runs in detect mode, so it refuses nothing. Judgement call: traefik serves its own certificate; no certificate resolver. Partially verified: the image build failed three runs on Ubuntu's snapshot service, so the checks ran only against a stand-in image with the same binary. Model: opus-5-5
213 lines
8.9 KiB
Bash
Executable File
213 lines
8.9 KiB
Bash
Executable File
#!/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 <argument>...: 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 <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
|
|
}
|
|
|
|
# logged <service> <text>...: 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 <path> <curl option>...: 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 <git argument>...: 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 <service>: 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 <method> <path>: 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 "$@"
|