#!/usr/bin/env bash # Behaviour harness for nginx/default.p9.conf, the live nginx config. # # Starts the nginx image docker-compose.yml runs, with the repo's config, # beside stand-ins for next-image, next-app and AniList's image CDN on a # private docker network, then checks what each kind of request does: # routing; the image variant cache, its key and its query-string check; the # limit in front of the image optimizer; the private include hooks; and the # AniList original-image mirror -- which paths it will fetch, what it sends # AniList, what it stores, and what it serves while AniList fails. # # bash scripts/nginx-test/run.sh # # Needs docker, curl and openssl. Prints PASS or FAIL for each check and # exits 1 if any failed. Takes about a minute, most of it spent waiting on # purpose for slow upstreams and for stored images to expire. set -uo pipefail HERE=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) ROOT=$(cd "$HERE/../.." && pwd) CONF=$ROOT/nginx/default.p9.conf NGINX_IMAGE=nginx:alpine # what docker-compose.yml runs PY_IMAGE=python:3.12-alpine RUN=nginxtest-$$ # names this run's containers and network NET=$RUN-net NGX=$RUN-nginx EXP=$RUN-nginx-expiry PRE=$RUN-nginx-presplit # the variant cache before the zone split, and after it POST=$RUN-nginx-postsplit VOL=$RUN-cache IMG=$RUN-image APP=$RUN-app ANI=$RUN-anilist T=$(mktemp -d "${TMPDIR:-/tmp}/nginx-test.XXXXXX") passed=0 failed=0 cleanup() { docker rm -f "$NGX" "$EXP" "$PRE" "$POST" "$IMG" "$APP" "$ANI" >/dev/null 2>&1 docker volume rm "$VOL" >/dev/null 2>&1 docker network rm "$NET" >/dev/null 2>&1 rm -rf "$T" } trap cleanup EXIT trap 'exit 130' INT TERM ok() { if [ "$1" = "$2" ]; then echo "PASS $3"; passed=$((passed + 1)) else echo "FAIL $3 (got '$1', want '$2')"; failed=$((failed + 1)) fi } # ─── certificates ────────────────────────────────────────────────────── # A test root CA, two intermediates under it and certificates for the AniList # stand-in: the good one (s4.anilist.co, issued by the second intermediate), # one for another name from the same chain, and a self-signed one. All are # backdated, so a container clock a little behind this machine's cannot make # them "not yet valid". Only `openssl req` and `openssl ca` are used, which # behave the same in OpenSSL and LibreSSL. make_pki() { local d=$T/pki mkdir -p "$d/newcerts" && : > "$d/index.txt" && echo 1000 > "$d/serial" cat > "$d/pki.cnf" <<'EOF' [ca] default_ca = test_ca [test_ca] dir = . database = ./index.txt new_certs_dir = ./newcerts serial = ./serial default_md = sha256 policy = any_name unique_subject = no email_in_dn = no [any_name] commonName = supplied [req] distinguished_name = req_dn [req_dn] commonName = Common Name [v3_root] basicConstraints = critical,CA:TRUE keyUsage = critical,keyCertSign,cRLSign subjectKeyIdentifier = hash [v3_int1] basicConstraints = critical,CA:TRUE,pathlen:1 keyUsage = critical,keyCertSign,cRLSign subjectKeyIdentifier = hash authorityKeyIdentifier = keyid:always [v3_int2] basicConstraints = critical,CA:TRUE,pathlen:0 keyUsage = critical,keyCertSign,cRLSign subjectKeyIdentifier = hash authorityKeyIdentifier = keyid:always [v3_leaf] basicConstraints = critical,CA:FALSE keyUsage = critical,digitalSignature,keyEncipherment extendedKeyUsage = serverAuth subjectAltName = DNS:s4.anilist.co [v3_wrong_name] basicConstraints = critical,CA:FALSE keyUsage = critical,digitalSignature,keyEncipherment extendedKeyUsage = serverAuth subjectAltName = DNS:not-anilist.example EOF ( cd "$d" || exit 1 csr() { openssl req -new -newkey rsa:2048 -nodes -keyout "$1.key" -out "$1.csr" -subj "/CN=$2" -config pki.cnf 2>/dev/null; } sign() { # name, issuer ("self" for self-signed), extension section local by="-cert $2.crt -keyfile $2.key" [ "$2" = self ] && by="-selfsign -keyfile $1.key" # shellcheck disable=SC2086 openssl ca -batch -notext -config pki.cnf $by -in "$1.csr" -out "$1.crt" -extensions "$3" \ -startdate 200101000000Z -enddate 491231235959Z 2>ca.log || { cat ca.log; exit 1; } } csr root "nginx-test root CA" && sign root self v3_root && csr int1 "nginx-test intermediate 1" && sign int1 root v3_int1 && csr int2 "nginx-test intermediate 2" && sign int2 int1 v3_int2 && csr good "s4.anilist.co" && sign good int2 v3_leaf && csr wrong_name "not-anilist.example" && sign wrong_name int2 v3_wrong_name && csr untrusted "s4.anilist.co" && sign untrusted self v3_leaf && cat good.crt int2.crt int1.crt > good-chain.pem && cat wrong_name.crt int2.crt int1.crt > wrong_name-chain.pem && cp untrusted.crt untrusted-chain.pem && chmod 644 ./*.key && openssl req -x509 -newkey rsa:2048 -nodes -keyout "$T/server.key" -out "$T/server.crt" \ -days 2 -subj /CN=localhost -config pki.cnf 2>/dev/null ) } # ─── containers ──────────────────────────────────────────────────────── pull_once() { docker image inspect "$1" >/dev/null 2>&1 || docker pull -q "$1" >/dev/null; } start_nginx() { # container name, config file[, docker volume for /var/cache/nginx] local cache=() [ -n "${3:-}" ] && cache=(-v "$3:/var/cache/nginx") # ${cache[@]+...}: bash 3.2 (macOS) treats an empty array as unset under set -u. docker run -d --name "$1" --network "$NET" -p 127.0.0.1::443 -p 127.0.0.1::8090 \ ${cache[@]+"${cache[@]}"} \ -v "$2:/etc/nginx/conf.d/default.conf:ro" \ -v "$T/server.crt:/etc/nginx/ssl/selfsigned.crt:ro" \ -v "$T/server.key:/etc/nginx/ssl/selfsigned.key:ro" \ -v "$ROOT/nginx/maintenance.html:/usr/share/nginx/html/maintenance.html:ro" \ -v "$T/private:/etc/nginx/private:ro" \ -v "$T/pki/root.crt:/etc/ssl/certs/ca-certificates.crt:ro" \ "$NGINX_IMAGE" >/dev/null } port_of() { docker port "$1" "$2/tcp" | head -1 | sed 's/.*://'; } wait_for() { # url: wait up to 20s for any HTTP answer local i for i in $(seq 1 100); do curl -s -o /dev/null --max-time 1 "$1" && return 0 sleep 0.2 done return 1 } give_up() { echo "FAIL $1" docker logs "$NGX" 2>&1 | tail -30 exit 1 } make_pki || give_up "could not create the test certificates" mkdir -p "$T/private" "$T/flood" # A stand-in for the rules nginx/private holds on the server: refuse image # urls starting with /blocked. printf '%s\n' 'map $arg_url $test_blocked { default 0; "~^%2Fblocked" 1; }' > "$T/private/http-test.conf" printf '%s\n' 'if ($test_blocked) { return 403; }' > "$T/private/next-image-test.conf" pull_once "$NGINX_IMAGE" pull_once "$PY_IMAGE" docker network create "$NET" >/dev/null || give_up "could not create the docker network" docker run -d --name "$IMG" --network "$NET" --network-alias next-image -e STUB_NAME=next-image \ -p 127.0.0.1::9000 -v "$HERE/next_stub.py:/stub.py:ro" "$PY_IMAGE" python -u /stub.py >/dev/null docker run -d --name "$APP" --network "$NET" --network-alias next-app --network-alias go-api \ --network-alias ws-server -e STUB_NAME=next-app \ -v "$HERE/next_stub.py:/stub.py:ro" "$PY_IMAGE" python -u /stub.py >/dev/null docker run -d --name "$ANI" --network "$NET" --network-alias s4.anilist.co \ -p 127.0.0.1::9000 -v "$HERE/anilist_stub.py:/stub.py:ro" -v "$T/pki:/certs:ro" \ "$PY_IMAGE" python -u /stub.py >/dev/null PIMG=$(port_of "$IMG" 9000) PANI=$(port_of "$ANI" 9000) wait_for "http://127.0.0.1:$PIMG/" || give_up "the next-image stand-in did not start" wait_for "http://127.0.0.1:$PANI/hits" || { docker logs "$ANI" 2>&1 | tail -20; give_up "the AniList stand-in did not start"; } start_nginx "$NGX" "$CONF" P443=$(port_of "$NGX" 443) P8090=$(port_of "$NGX" 8090) [ -n "$P443" ] && wait_for "http://127.0.0.1:$P8090/" || give_up "nginx did not start with $CONF" # ─── request helpers ─────────────────────────────────────────────────── B=https://localhost:$P443 # the main server answers to `localhost` too W=http://127.0.0.1:$P8090 # the warm server CURL_RESOLVE=(--resolve "localhost:$P443:127.0.0.1") A='accept: image/avif,image/webp,*/*' WEBP='accept: image/webp,*/*' c() { curl -sk --max-time 90 "${CURL_RESOLVE[@]}" "$@"; } code() { c -o /dev/null -w '%{http_code}' -H "$A" "$B$1"; } hdr() { c -D - -o /dev/null -H "$1" "$B$2" | tr -d '\r' | awk -F': ' -v k="$3" 'tolower($1)==k {print $2}'; } hits() { curl -s "http://127.0.0.1:$PIMG/"; } # next-image: " " next_hits() { hits | cut -d' ' -f1; } flood() { # how many distinct cold images at once, name tag, url prefix (slow = 1s upstream, slower = 5s) rm -f "$T/flood.txt" "$T"/flood/* for i in $(seq 1 "$1"); do ( c -D "$T/flood/h$i" -o /dev/null -w "%{http_code} %{time_total} %{time_appconnect} %{time_starttransfer}\n" \ -H "$A" "$B/_next/image?url=%2F${3:-slow}-$2$i.png&w=640&q=85" >> "$T/flood.txt" ) & done } ani() { curl -s "http://127.0.0.1:$PANI/$1"; } # the AniList stand-in's control port ani_hits() { if [ $# -gt 0 ]; then ani "hits?path=$1"; else ani hits; fi; } # GET from /img/anilist/ or from the warm server; the body and headers # are kept for body and head_of. --path-as-is sends dot segments unchanged. img() { c --path-as-is -o "$T/body" -D "$T/head" -w '%{http_code}' "$B/img/anilist/$1"; } warm() { curl -s --path-as-is --max-time 90 -o "$T/body" -D "$T/head" -w '%{http_code}' "$W/warm/$1"; } body() { cat "$T/body"; } head_of() { tr -d '\r' < "$T/head" | awk -v k="$1" 'index(tolower($0), k ": ") == 1 { print substr($0, length(k) + 3) }'; } # ═══ the image optimizer: routing, cache, query shape, limits ══════════ ok "$(docker exec "$NGX" nginx -t 2>&1 | grep -c 'test is successful')" 1 "nginx -t accepts the config" echo "== routing" ok "$(hdr "$A" '/_next/image?url=%2Fa.png&w=640&q=85' x-stub)" next-image "image request is served by next-image" ok "$(hdr "$A" /anime/1 x-stub)" next-app "page request is served by next-app" echo "== caching" ok "$(next_hits)" 1 "first request reached upstream once" code '/_next/image?url=%2Fa.png&w=640&q=85' >/dev/null ok "$(next_hits)" 1 "repeat request is a cache hit" c -o /dev/null -H "$WEBP" "$B/_next/image?url=%2Fa.png&w=640&q=85" ok "$(next_hits)" 2 "a different format is a separate entry" echo "== only the shape the pages produce is accepted" ok "$(code '/_next/image?URL=%2Fvictim.png&url=%2Fattacker.png&w=640&q=85')" 400 "poisoning attempt (URL= then url=) is refused" ok "$(hdr "$A" '/_next/image?url=%2Fvictim.png&w=640&q=85' x-url)" /victim.png "victim's image is the victim's image" ok "$(code '/_next/image?URL=%2Fa.png&w=640&q=85')" 400 "upper-case parameter name is refused" ok "$(code '/_next/image?w=640&url=%2Fa.png&q=85')" 400 "parameters out of order are refused" ok "$(code '/_next/image?url=%2Fa.png&w=640&q=85&junk=1')" 400 "padded query string is refused" ok "$(code '/_next/image?url=%2Fa.png&url=%2Fb.png&w=640&q=85')" 400 "duplicate url is refused" ok "$(code '/_next/image?url=%2Fa.png&w=640')" 400 "missing q is refused" ok "$(code '/_next/image?url=&w=640&q=85')" 400 "empty url is refused" ok "$(code '/_next/image?url=%2Fa.png&w=640&q=85')" 200 "the canonical shape still works" ok "$(next_hits)" 3 "none of the refused requests reached the optimizer" echo "== a whole page of cold images (48 at once) is served, not refused" flood 48 page; sleep 0.3; during=$(c -o /dev/null -w "%{http_code} %{time_total}" -H "$A" "$B/_next/image?url=%2Fa.png&w=640&q=85"); wait ok "$(grep -c '^200' "$T/flood.txt")" 48 "all 48 cold images return 200" ok "$(echo "$during" | cut -d' ' -f1)" 200 "cached image during the burst still 200" ok "$(echo "$during" | awk '{print ($2<0.5)}')" 1 "cached image during the burst returns in under 0.5s ($(echo "$during" | cut -d' ' -f2)s)" echo "== a flood (300 at once) is capped at 256 in flight" flood 300 flood slower; wait ok "$(grep -c '^200' "$T/flood.txt")" 256 "256 served" ok "$(grep -c '^503' "$T/flood.txt")" 44 "44 over the limit get 503" echo " 503 times: $(awk '$1==503 {print $2}' "$T/flood.txt" | sort -n | awk '{a[NR]=$1} END {printf "min %.2fs median %.2fs max %.2fs", a[1], a[int((NR+1)/2)], a[NR]}') | 200 times: $(awk '$1==200 {print $2}' "$T/flood.txt" | sort -n | awk '{a[NR]=$1} END {printf "min %.2fs max %.2fs", a[1], a[NR]}')" ok "$(awk '$1==503 && NF==4' "$T/flood.txt" | wc -l | tr -d ' ')" 44 "all 44 503s carry connect and first-byte times" ok "$(awk '$1==503 && ($4-$3)>0.5' "$T/flood.txt" | wc -l | tr -d ' ')" 0 "every 503 is answered within 0.5s of the TLS session being up" ok "$(hits | awk '{print ($2 <= 256)}')" 1 "upstream never sees more than 256 at once (peak $(hits | cut -d' ' -f2))" busy=$(grep -l '^HTTP/[0-9.]* 503' "$T"/flood/h* | head -1) ok "$(tr -d '\r' < "$busy" | awk -F': ' 'tolower($1)=="cache-control" {print $2}')" no-store "503 carries Cache-Control: no-store" echo "== private rules (nginx/private) are applied" ok "$(code '/_next/image?url=%2Fblocked-x.png&w=640&q=85')" 403 "a private rule refuses what it matches" ok "$(code '/_next/image?url=%2Fallowed-x.png&w=640&q=85')" 200 "and nothing else" # The 60s read timeout check takes 40s; it runs in the background from here # and is read at the end. Wait until the optimizer has counted it, so the # hit counts below are not off by one. before=$(next_hits) ( code '/_next/image?url=%2Fveryslow-x.png&w=640&q=85' > "$T/veryslow.txt" ) & veryslow=$! for i in $(seq 1 50); do [ "$(next_hits)" -gt "$before" ] && break; sleep 0.1; done # Every key in the variant cache, in both zones (next_image, next_image_misc). variant_keys() { docker exec "$NGX" sh -c 'find /var/cache/nginx/next_image /var/cache/nginx/next_image_misc -type f -exec cat {} +' | grep -a '^KEY: ' } # ═══ /_next/image: the mirror address shares the AniList address's entry ═ S4=https%3A%2F%2Fs4.anilist.co%2Ffile%2Fanilistcdn%2F MIRROR=https%3A%2F%2Fanimegoclub.com%2Fimg%2Fanilist%2F P1=media%2Fanime%2Fcover%2Flarge%2Fbx9001-share.png P1_S4=https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx9001-share.png echo "== /_next/image: an AniList image's mirror address shares its AniList address's entry" n=$(next_hits) ok "$(code "/_next/image?url=$S4$P1&w=640&q=85")|$(( $(next_hits) - n ))" "200|1" "the AniList address is encoded once" code "/_next/image?url=$S4$P1&w=640&q=85" >/dev/null ok "$(( $(next_hits) - n ))" 1 "and a repeat of it is a cache hit, as before" ok "$(hdr "$A" "/_next/image?url=$MIRROR$P1&w=640&q=85" x-url)" "$P1_S4" "the mirror address is answered from the AniList address's entry" ok "$(( $(next_hits) - n ))" 1 "so the two addresses reach the optimizer once in total" keys=$(variant_keys) ok "$(printf '%s\n' "$keys" | grep -c -x -F "KEY: url=$S4$P1&w=640&q=85|avif")" 1 "the entry's key is the pre-change format, url=&w=640&q=85|avif" ok "$(printf '%s\n' "$keys" | grep -c -F 'animegoclub.com')" 0 "no entry is keyed by a mirror address" P2=media%2Fanime%2Fbanner%2F9002-share.jpg n=$(next_hits) code "/_next/image?url=$MIRROR$P2&w=640&q=85" >/dev/null code "/_next/image?url=$S4$P2&w=640&q=85" >/dev/null ok "$(( $(next_hits) - n ))" 1 "mirror address first, then the AniList address: once in total" n=$(next_hits) code "/_next/image?url=${MIRROR}character%2Flarge%2Fb9003-share.png&w=640&q=85" >/dev/null code "/_next/image?url=${MIRROR}character%2Flarge%2Fb9004-share.png&w=640&q=85" >/dev/null ok "$(( $(next_hits) - n ))" 2 "two different paths reach it twice" n=$(next_hits) code "/_next/image?url=$MIRROR$P1&w=384&q=85" >/dev/null ok "$(( $(next_hits) - n ))" 1 "another width of the same image is another entry" LOWER=https%3a%2f%2fanimegoclub.com%2fimg%2fanilist%2fmedia%2fanime%2fcover%2flarge%2fbx9001-share.png n=$(next_hits) ok "$(hdr "$A" "/_next/image?url=$LOWER&w=640&q=85" x-url)" \ "https://animegoclub.com/img/anilist/media/anime/cover/large/bx9001-share.png" \ "a mirror address in lower-case %-escapes is answered for its own url" ok "$(( $(next_hits) - n ))" 1 "from an entry of its own" ok "$(hdr "$A" "/_next/image?url=$MIRROR$P1&w=640&q=85" x-url)|$(( $(next_hits) - n ))" "$P1_S4|1" "and the shared entry is untouched" n=$(next_hits) code "/_next/image?url=https%3A%2F%2Fanimegoclub.com.example%2Fimg%2Fanilist%2F$P1&w=640&q=85" >/dev/null code "/_next/image?url=https%3A%2F%2FanimegoclubXcom%2Fimg%2Fanilist%2F$P1&w=640&q=85" >/dev/null ok "$(( $(next_hits) - n ))" 2 "look-alike hosts are not taken for the mirror" # An escape other than an encoded slash (%2E is a dot) is never rewritten: # only allowlist-shaped paths share the AniList address's entry. ESC=${MIRROR}media%2Fanime%2Fcover%2Flarge%2Fbx9001-share%2Epng n=$(next_hits) code "/_next/image?url=$ESC&w=640&q=85" >/dev/null ok "$(( $(next_hits) - n ))" 1 "a mirror address with any other escape in its path is not served from a shared entry" keys=$(variant_keys) ok "$(printf '%s\n' "$keys" | grep -c -x -F "KEY: url=$ESC&w=640&q=85|avif")" 1 "it keeps the key of its own query string" # ═══ /img/anilist/ and :8090 /warm/: the allowlist ═════════════════════ echo "== /img/anilist/ serves each kind of image the site shows" while IFS='|' read -r p label; do ok "$(img "$p")|$(body)" "200|original $p v1" "$label" done <<'EOF' media/anime/cover/large/bx9101-kind.png|an anime cover, byte for byte media/manga/cover/medium/bx9102-kind.jpg|a manga cover media/anime/banner/9103-kind.jpg|a banner character/large/b9104-kind.png|a character portrait staff/medium/n9105-kind.jpg|a staff portrait EOF served=0 for p in media/anime/cover/extraLarge/bx9106.webp media/anime/cover/small/bx9107.gif \ media/manga/cover/large/bx9108.jpeg media/manga/banner/9109.png character/medium/b9110.jpg \ staff/large/n9111.png media/anime/cover/medium/bx9112-a_b.c-D.png; do [ "$(img "$p")" = 200 ] && served=$((served + 1)) done ok "$served" 7 "and every other size, kind and file type in the allowlist" # Paths outside the allowlist; %ID% becomes a number unique to each request. REFUSED='user/avatar/large/b%ID%-x.png|an AniList user avatar user/banner/b%ID%-x.jpg|an AniList user banner media/anime/cover/huge/bx%ID%.png|a cover size that does not exist media/anime/cover/large/bx%ID%.svg|a file type outside the list media/anime/cover/large/bx%ID%.PNG|an upper-case extension media/anime/cover/large/bx%ID%.png.html|a second extension after the image one media/anime/cover/large/|a directory media/anime/cover/large/x/bx%ID%.png|an extra directory level media/anime/cover/large/bx%ID%%20x.png|a space in the file name media/anime/cover/large/..%2F..%2F..%2F..%2Fuser%2Favatar%2Fb%ID%.png|encoded ../ climbing to a user upload media/anime/cover/large/%2E%2E/%2E%2E/%2E%2E/%2E%2E/user/avatar/b%ID%.png|encoded .. segments media/anime/cover/large/../../../../user/avatar/b%ID%.png|plain .. segments media/anime/cover/large/%252E%252E%252Fb%ID%.png|double-encoded ../ media/anime/cover/large/b%ID%%5C..%5C..%5Cx.png|backslashes' refused() { # get function, id base, the status every one of them gets local id=$2 p label while IFS='|' read -r p label; do id=$((id + 1)) ok "$($1 "$(printf '%s' "$p" | sed "s/%ID%/$id/")")" "$3" "$label" done </dev/null; img "$U" >/dev/null ok "$(ani_hits "$U")" 2 "two GETs of an image no one stored reach AniList twice: the first was not kept" S=media/anime/cover/large/bx9402-stored.png ok "$(warm "$S")" 200 "the warm server fetches and stores an image" ok "$(img "$S")|$(body)" "200|original $S v1" "/img/anilist/ then serves it" img "$S" >/dev/null ok "$(ani_hits "$S")" 1 "a warm GET then two public GETs reach AniList once: the public ones were served from the store" ok "$(img "$S?v=2&cache=bust")|$(ani_hits "$S")" "200|1" "a query string does not get past the store" L=media/anime/cover/large/bx9403-lock.png ani "set?path=$L&delay=1" >/dev/null curl -s -o /dev/null "$W/warm/$L" & p1=$! curl -s -o /dev/null "$W/warm/$L" & p2=$! wait $p1 $p2 ok "$(ani_hits "$L")" 1 "two warm GETs at once for the same new image fetch it once" Q=media/anime/cover/large/bx9404-concurrent.png ani "set?path=$Q&delay=1" >/dev/null rm -f "$T/concurrent.txt"; pids="" for i in 1 2 3 4; do ( c -o /dev/null -w '%{time_total}\n' "$B/img/anilist/$Q" >> "$T/concurrent.txt" ) & pids="$pids $!" done # shellcheck disable=SC2086 wait $pids slowest=$(sort -n "$T/concurrent.txt" | tail -1) ok "$(awk -v t="$slowest" 'BEGIN { print (t != "" && t + 0 < 1.8) }')|$(ani_hits "$Q")" "1|4" \ "four public GETs at once for an unstored image each go to AniList without queueing behind each other (slowest ${slowest}s)" echo "== while AniList fails" ani "all?status=503" >/dev/null ok "$(img "$S")|$(body)" "200|original $S v1" "AniList answering 503: a stored original is still served" ok "$(img "$U")" 503 "an image no one stored gets AniList's own 503" ani "all?status=0" >/dev/null ani "listen?on=0" >/dev/null ok "$(img "$S")|$(body)" "200|original $S v1" "AniList unreachable: a stored original is still served" ok "$(img media/anime/cover/large/bx9501-cold.png)" 502 "AniList unreachable: an image no one stored is a 502" ok "$(head_of cache-control)" no-store "that carries Cache-Control: no-store" ok "$(grep -c -i maintenance "$T/body")" 0 "and is not the maintenance page" ok "$(warm media/anime/cover/large/bx9502-cold.png)" 502 "the warm server answers 502" ani "listen?on=1" >/dev/null echo "== :8090 passes AniList's own answers on to the warm job, and stores none of them" X=media/anime/cover/large/bx9601-gone.png ani "set?path=$X&status=404" >/dev/null ok "$(warm "$X")" 404 "404 is passed on" warm "$X" >/dev/null ok "$(ani_hits "$X")" 2 "and not stored, though AniList said to cache it" ok "$(img "$X")" 404 "/img/anilist/ passes the 404 on too" ok "$(head_of cache-control)|$(head_of x-accel-expires)" "|" "without AniList's invitation to cache it" X=media/anime/cover/large/bx9602-throttled.png ani "set?path=$X&status=429&retry_after=120" >/dev/null ok "$(warm "$X")|$(head_of retry-after)" "429|120" "429 is passed on with its Retry-After" warm "$X" >/dev/null ok "$(ani_hits "$X")" 2 "and not stored" ok "$(img "$X")|$(head_of retry-after)|$(head_of cache-control)" "429|120|" \ "/img/anilist/ passes the 429 on with its Retry-After, and nothing that lets the CDN keep it" X=media/anime/cover/large/bx9603-broken.png ani "set?path=$X&status=503" >/dev/null ok "$(warm "$X")" 503 "503 is passed on" warm "$X" >/dev/null ok "$(ani_hits "$X")" 2 "and not stored" # ═══ what AniList is sent, and what comes back ═════════════════════════ echo "== what AniList is sent" H=media/anime/cover/large/bx9701-headers.png c -o /dev/null -H 'Cookie: session=reader-secret' -H 'Authorization: Bearer reader-token' \ -H 'Accept-Encoding: gzip, br' -H 'Referer: https://example.org/' "$B/img/anilist/$H?utm=1&w=2" ok "$(ani 'last?field=target')" "/file/anilistcdn/$H" "the AniList path alone, without the query string" ok "$(ani 'last?field=host')" s4.anilist.co "Host: s4.anilist.co" ok "$(ani 'last?field=sni')" s4.anilist.co "TLS server name s4.anilist.co" # Whether nginx adds a Connection header of its own depends on its version. names=$(ani 'last?field=header_names') ok "${names/connection /}" "host user-agent" "no header of the visitor's: not their cookies, credentials, address or Accept-Encoding" CLEAN='^/file/anilistcdn/([A-Za-z0-9_-][A-Za-z0-9_.-]*/)*[A-Za-z0-9_-][A-Za-z0-9_.-]*$' ok "$(ani targets | grep -v '^$' | grep -c -v -E "$CLEAN")" 0 "in this whole run AniList was only asked for plain /file/anilistcdn/ paths" echo "== what comes back from AniList" img "$S" >/dev/null ok "$(head_of cache-control)" "max-age=2592000" "a stored original may be kept 30 days, whatever AniList's Cache-Control said" ok "$(head_of expires | grep -c 1970)" 0 "and AniList's Expires is not passed on" ok "$(head_of set-cookie)" "" "AniList's Set-Cookie never reaches the visitor" ok "$(head_of strict-transport-security)" "max-age=31536000; includeSubDomains; preload" "only the site's own HSTS policy, not AniList's" ok "$(head_of report-to)$(head_of nel)" "" "nor AniList's error-report endpoints" ok "$(head_of age)|$(head_of cf-ray)|$(head_of cf-cache-status)|$(grep -c -i '^x-bz-' "$T/head")" "|||0" \ "nor its CDN's notes about itself: Age, CF-Ray, CF-Cache-Status, x-bz-*" ok "$(head_of x-frame-options)|$(head_of x-content-type-options)" "DENY|nosniff" "the site's security headers are still added" echo "== AniList's certificate is checked" TLS=media/anime/cover/large/bx9801-tls.png ani "cert?mode=untrusted" >/dev/null ok "$(img "$TLS")" 502 "a certificate from an issuer nginx does not trust is refused" ok "$(warm "$TLS")" 502 "by the warm server too" ani "cert?mode=wrong_name" >/dev/null ok "$(img "$TLS")" 502 "a trusted certificate for another name is refused" ok "$(ani_hits "$TLS")" 0 "and no request went over those connections" ani "cert?mode=good" >/dev/null ok "$(img "$TLS")" 200 "the right name through two intermediate CAs is accepted" # ═══ expiry: a copy of the config with validity cut to 2s ══════════════ echo "== once a stored original has expired (in a copy of the config with validity cut to 2s)" sed 's/proxy_cache_valid 200 30d;/proxy_cache_valid 200 2s;/' "$CONF" > "$T/expiry.conf" ok "$(grep -c 'proxy_cache_valid 200 2s;' "$T/expiry.conf")" 4 "the copy cuts it in both variant locations (public, :8090) and both mirror locations" start_nginx "$EXP" "$T/expiry.conf" PE443=$(port_of "$EXP" 443) PE8090=$(port_of "$EXP" 8090) wait_for "http://127.0.0.1:$PE8090/" || give_up "the expiry copy of the config did not start" CURL_RESOLVE+=(--resolve "localhost:$PE443:127.0.0.1") B=https://localhost:$PE443 W=http://127.0.0.1:$PE8090 E=media/anime/cover/large/bx9901-expiry.png ok "$(warm "$E")" 200 "stored by the warm server" sleep 3 for status in 503 429 403; do ani "all?status=$status" >/dev/null ok "$(img "$E")|$(body)" "200|original $E v1" "AniList answering $status: /img/anilist/ serves the stored original" done ani "all?status=503" >/dev/null ok "$(warm "$E")" 503 "while the warm job is told 503, not handed the stored copy" ani "all?status=429" >/dev/null ok "$(warm "$E")" 429 "and 429 likewise" ani "all?status=0" >/dev/null ani "listen?on=0" >/dev/null ok "$(img "$E")|$(body)" "200|original $E v1" "AniList unreachable: /img/anilist/ serves the stored original" ok "$(warm "$E")" 502 "while the warm job is told 502" ani "listen?on=1" >/dev/null h=$(ani_hits "$E") ok "$(warm "$E")|$(body)" "200|original $E v1" "the warm job's request revalidates it" ok "$(ani 'last?field=if-modified-since')|$(ani 'last?field=status')" "Fri, 02 Jan 2026 00:00:00 GMT|304" \ "with a conditional request, which AniList answers 304" ok "$(img "$E")|$(( $(ani_hits "$E") - h ))" "200|1" "and that makes it fresh again: the next public GET does not reach AniList" sleep 3 ani "set?path=$E&version=2" >/dev/null ok "$(img "$E")|$(body)" "200|original $E v2" "AniList has new bytes: a public GET gets them" ani "all?status=503" >/dev/null ok "$(img "$E")|$(body)" "200|original $E v1" "but does not store them: the stored original is still the old one" ani "all?status=0" >/dev/null ok "$(warm "$E")|$(body)" "200|original $E v2" "the warm job's request stores the new bytes" ok "$(img "$E")|$(body)" "200|original $E v2" "and /img/anilist/ serves those from then on" echo "== a slow encode is allowed 60s" wait "$veryslow" ok "$(cat "$T/veryslow.txt")" 200 "a 40s upstream response is still a 200" # ═══ /_next/image zones: page shapes in next_image, the rest in next_image_misc ═ # Back on the main config's server (the expiry checks above moved B and W). # After the 40s request above has finished, so the in-flight limit is all free. B=https://localhost:$P443 W=http://127.0.0.1:$P8090 ORIG='accept: */*' in_zone() { # container, zone, exact key: how many entries of that zone carry it docker exec "$1" sh -c "find /var/cache/nginx/$2 -type f -exec cat {} + 2>/dev/null" | grep -a -c -x -F "KEY: $3" } fmt_of() { case "$1" in "$A") echo avif ;; "$WEBP") echo webp ;; *) echo orig ;; esac; } echo "== /_next/image: a page shape is stored in next_image, anything else in next_image_misc" while IFS='|' read -r path w accept want label; do case "$accept" in avif) h=$A ;; webp) h=$WEBP ;; *) h=$ORIG ;; esac if [ "${path#%2F}" != "$path" ]; then key="url=$path&w=$w&q=85|$(fmt_of "$h")"; arg=$path else key="url=$S4$path&w=$w&q=85|$(fmt_of "$h")"; arg=$MIRROR$path; fi c -o /dev/null -H "$h" "$B/_next/image?url=$arg&w=$w&q=85" other=next_image; [ "$want" = next_image ] && other=next_image_misc ok "$(in_zone "$NGX" "$want" "$key")|$(in_zone "$NGX" "$other" "$key")" "1|0" "$label -> $want" done <<'EOF' media%2Fanime%2Fcover%2Flarge%2Fbx9951-zone.png|256|avif|next_image|an anime cover at 256 (detail page 1x, season grid 1x) media%2Fanime%2Fcover%2Flarge%2Fbx9951-zone.png|384|avif|next_image|at 384 (season grid 2x) media%2Fanime%2Fcover%2Flarge%2Fbx9951-zone.png|640|avif|next_image|at 640 (detail page 2x) media%2Fanime%2Fcover%2Fmedium%2Fbx9952-zone.png|96|avif|next_image|a medium cover at 96 (season list 2x) media%2Fmanga%2Fcover%2Fmedium%2Fbx9953-zone.png|384|avif|next_image|a manga cover at 384 (related works 2x) media%2Fanime%2Fbanner%2F9954-zone.jpg|828|avif|next_image|a banner at 828 media%2Fanime%2Fbanner%2F9954-zone.jpg|1920|avif|next_image|a banner at 1920 character%2Fmedium%2Fb9955-zone.png|128|avif|next_image|a character portrait at 128 staff%2Fmedium%2Fn9956-zone.png|96|avif|next_image|a staff portrait at 96 media%2Fanime%2Fcover%2Flarge%2Fbx9951-zone.png|1080|avif|next_image_misc|a cover at a width no page asks for media%2Fanime%2Fcover%2FextraLarge%2Fbx9957-zone.png|256|avif|next_image_misc|an extraLarge cover character%2Flarge%2Fb9958-zone.png|640|avif|next_image_misc|a large portrait staff%2Fmedium%2Fn9956-zone.png|640|avif|next_image_misc|a staff portrait at 640 media%2Fanime%2Fcover%2Flarge%2Fbx9951-zone.png|256|webp|next_image_misc|a page-shape width in WebP media%2Fanime%2Fcover%2Flarge%2Fbx9951-zone.png|256|orig|next_image_misc|in the original format media%2Fanime%2Fbanner%2F9954-zone.jpg|1920|orig|next_image_misc|a banner in the original format %2Fsite-9959.png|256|avif|next_image_misc|a site image EOF n=$(next_hits) code "/_next/image?url=${S4}media%2Fanime%2Fcover%2Flarge%2Fbx9951-zone.png&w=256&q=85" >/dev/null ok "$(( $(next_hits) - n ))" 0 "a page shape's AniList address is answered from its mirror address's entry in next_image" for u in "url=${MIRROR}media%2Fanime%2Fcover%2Flarge%2Fbx9951-zone.png&w=256&q=85" "url=%2Fsite-9959.png&w=256&q=85"; do hs=$(c -D - -o /dev/null -H "$A" "$B/_next/image?$u" | tr -d '\r') got=$(printf '%s\n' "$hs" | awk -F': ' 'tolower($1)=="x-frame-options" {f=$2} tolower($1)=="x-content-type-options" {t=$2} tolower($1)=="strict-transport-security" {s="hsts"} END {print f "|" t "|" s}') ok "$got" "DENY|nosniff|hsts" "the site's security headers on a variant from $( [ "${u#*%2Fsite}" != "$u" ] && echo next_image_misc || echo next_image)" done line=$(docker logs "$NGX" 2>/dev/null | grep -F "GET /_next/image?url=${MIRROR}media%2Fanime%2Fcover%2Flarge%2Fbx9951-zone.png&w=256&q=85 " | tail -1) ok "$(printf '%s' "$line" | grep -o 'cache=[A-Z]* zone=[a-z_]*$')" "cache=HIT zone=next_image" "the access log line ends with the cache status and the zone" line=$(docker logs "$NGX" 2>/dev/null | grep -F "GET /_next/image?url=%2Fsite-9959.png&w=256&q=85 " | tail -1) ok "$(printf '%s' "$line" | grep -o 'zone=[a-z_]*$')" "zone=next_image_misc" "and names next_image_misc for the rest" echo "== a busy 503 is never stored, in either zone" flood_shape() { # how many at once, tag, url prefix for .png; waits for its own requests only local pids="" i rm -f "$T/flood.txt" "$T"/flood/* for i in $(seq 1 "$1"); do ( c -D "$T/flood/h$i" -o /dev/null -w "%{http_code}\n" -H "$A" "$B/_next/image?url=$3$2$i.png&w=256&q=85" >> "$T/flood.txt" ) & pids="$pids $!" done # shellcheck disable=SC2086 wait $pids } for zone in next_image next_image_misc; do if [ "$zone" = next_image ]; then pre="${MIRROR}media%2Fanime%2Fcover%2Flarge%2Fslower-"; else pre="%2Fslower-"; fi flood_shape 300 "busy-$zone-" "$pre" ok "$(grep -c '^503' "$T/flood.txt")" 44 "$zone: 44 over the limit get 503" busy=$(grep -l '^HTTP/[0-9.]* 503' "$T"/flood/h* | head -1); i=${busy##*/h} n=$(next_hits) ok "$(c -o /dev/null -w '%{http_code}' -H "$A" "$B/_next/image?url=${pre}busy-$zone-$i.png&w=256&q=85")|$(( $(next_hits) - n ))" "200|1" \ "$zone: asked again, a variant that got 503 reaches the optimizer and is served: the 503 was not stored" done echo "== :8090 /_next/image: the variant warm entry point" WP="${MIRROR}media%2Fanime%2Fcover%2Flarge%2Fbx9961-warm.png" wstat() { curl -s -I --max-time 90 -H "$A" "$@" | tr -d '\r' | head -1 | awk '{print $2}'; } wcache() { curl -s -I --max-time 90 -H "$A" "$@" | tr -d '\r' | awk -F': ' 'tolower($1)=="x-cache-status" {print $2}'; } n=$(next_hits) ok "$(wstat "$W/_next/image?url=$WP&w=384&q=85")|$(wcache "$W/_next/image?url=$WP&w=384&q=85")" "200|HIT" "a HEAD for a cold page shape is a 200 that stores it (the next HEAD is a HIT)" ok "$(( $(next_hits) - n ))" 1 "encoded once" ok "$(in_zone "$NGX" next_image "url=${S4}media%2Fanime%2Fcover%2Flarge%2Fbx9961-warm.png&w=384&q=85|avif")" 1 "in next_image, under the public location's key" n=$(next_hits) ok "$(hdr "$A" "/_next/image?url=$WP&w=384&q=85" x-url)|$(( $(next_hits) - n ))" \ "https://animegoclub.com/img/anilist/media/anime/cover/large/bx9961-warm.png|0" "a reader's request then finds it: no encode" n=$(next_hits) ok "$(wcache -H 'X-Warm-Refresh: 1' "$W/_next/image?url=$WP&w=384&q=85")|$(( $(next_hits) - n ))" "BYPASS|1" "X-Warm-Refresh: 1 on :8090 encodes it again" ok "$(wcache "$W/_next/image?url=$WP&w=384&q=85")" HIT "and the new copy is the stored one" n=$(next_hits) c -o /dev/null -H "$A" -H 'X-Warm-Refresh: 1' "$B/_next/image?url=$WP&w=384&q=85" ok "$(( $(next_hits) - n ))" 0 "the public location ignores X-Warm-Refresh" n=$(next_hits) ok "$(wstat "$W/_next/image?url=$WP&w=1080&q=85")" 403 ":8090 refuses a width that is not a page shape" ok "$(wstat "$W/_next/image?url=${S4}media%2Fanime%2Fcover%2Flarge%2Fbx9961-warm.png&w=384&q=85")" 403 "an AniList address (only mirror addresses)" ok "$(curl -s -I --max-time 30 -H "$WEBP" -o /dev/null -w '%{http_code}' "$W/_next/image?url=$WP&w=384&q=85")" 403 "a request that does not ask for AVIF" ok "$(wstat "$W/_next/image?url=%2Fa.png&w=640&q=85")" 403 "a site image" ok "$(wstat "$W/_next/image?URL=$WP&w=384&q=85")" 400 "a query string the pages never produce" ok "$(curl -s -o /dev/null -w '%{http_code}' -X POST -H "$A" --data x "$W/_next/image?url=$WP&w=384&q=85")" 403 "a POST" ok "$(( $(next_hits) - n ))" 0 "none of them reached the optimizer" # ═══ the zone split keeps what next_image already held ═════════════════ echo "== an entry stored before the zone split is found after it" # The config as it was before the split, as far as the variant cache goes: # every variant in next_image. The zone's path, name and key are unchanged # by the split; this checks that a request after it finds such an entry. sed 's/proxy_cache \$next_image_zone;/proxy_cache next_image;/' "$CONF" > "$T/presplit.conf" ok "$(grep -c 'proxy_cache next_image;' "$T/presplit.conf")|$(grep -c 'proxy_cache \$next_image_zone;' "$T/presplit.conf")" "2|0" \ "the copy stores every public variant in next_image, as before the split" docker volume create "$VOL" >/dev/null || give_up "could not create the cache volume" start_nginx "$PRE" "$T/presplit.conf" "$VOL" PP443=$(port_of "$PRE" 443); PP8090=$(port_of "$PRE" 8090) wait_for "http://127.0.0.1:$PP8090/" || give_up "the pre-split copy of the config did not start" CURL_RESOLVE+=(--resolve "localhost:$PP443:127.0.0.1") KEPT="${MIRROR}media%2Fanime%2Fcover%2Flarge%2Fbx9971-kept.png" n=$(next_hits) ok "$(c -o /dev/null -w '%{http_code}' -H "$A" "https://localhost:$PP443/_next/image?url=$KEPT&w=384&q=85")|$(( $(next_hits) - n ))" "200|1" \ "stored by the pre-split config" docker rm -f "$PRE" >/dev/null start_nginx "$POST" "$CONF" "$VOL" PQ443=$(port_of "$POST" 443); PQ8090=$(port_of "$POST" 8090) wait_for "http://127.0.0.1:$PQ8090/" || give_up "the config did not start on the shared cache volume" CURL_RESOLVE+=(--resolve "localhost:$PQ443:127.0.0.1") n=$(next_hits) ok "$(c -o /dev/null -w '%{http_code}' -H "$A" "https://localhost:$PQ443/_next/image?url=$KEPT&w=384&q=85")|$(( $(next_hits) - n ))" "200|0" \ "the same request under the new config is answered from that entry, with no encode" ok "$(in_zone "$POST" next_image "url=${S4}media%2Fanime%2Fcover%2Flarge%2Fbx9971-kept.png&w=384&q=85|avif")" 1 "it is still the one entry, in next_image" echo echo "$passed passed, $failed failed" if [ "$failed" -gt 0 ]; then echo "--- nginx error log (last 40 lines)" docker logs "$NGX" 2>&1 | grep -v -E '^/docker-entrypoint|^[0-9/: ]+ \[notice\]' | tail -40 exit 1 fi