#!/usr/bin/env bash
#
# Edge check: does the edge config the stack ships actually load, and do its health routes tell the truth?
# 1. `caddy validate` docker/Caddyfile in every hosting mode, using the SAME caddy image the web image is built
# FROM (read from docker/web.Dockerfile, so CI and production cannot disagree). validate, not adapt: adapt
# accepts values (e.g. a bad TRUSTED_PROXIES) that only fail when the config is provisioned.
# 2. `docker compose config -q` for every compose file / overlay / profile combination the installers use.
# 2b. A digest pin comes out of `docker compose config --images` whole, as supervisor/apply.sh requires of it.
# 3. /healthz and /healthz/supervisor (with or without a trailing slash), served by that caddy against stub upstreams:
# 503 + reason while the upstream is down or failing (not the SPA's or the recovery page's catch-all 200), 200 "ok"
# once it answers.
# 3b. The client address the server is told, in each hosting mode (docker/edge-peers.sh picks the :80 rule): exactly
# Caddy's view of the client, whatever forwarding headers the client sends (echo upstream). Cloudflare mode
# publishes no port and gets its mode from COMPOSE_PROFILES; every other setup, the dev stack included, does not.
# 3c. Ambiguous server paths (";", encoded ";", "/" or "\") are refused with 400, and every response carries the
# browser security headers (HSTS only over HTTPS).
# 4. The dev stack (docker-compose.dev.yml over docker-compose.yml) with docker/dev.env alone, and that the overlay
# refuses to load without it.
# Guards the v0.3.1 Cloudflare-mode break: `email {$ACME_EMAIL}` with the empty ACME_EMAIL that setup.sh and
# quickstart.sh write made the edge refuse to start, and nothing in CI parsed the Caddyfile.
# Needs only a Docker daemon (+ compose plugin). Throwaway --rm containers, no network, no ports (3b adds test
# addresses to its own container's loopback, so it runs with NET_ADMIN).
#
# Usage: scripts/edge-check.sh [caddyfile] (default: docker/Caddyfile)
set -euo pipefail
# CDPATH='' on every relative cd: with CDPATH exported, cd prints the directory it found, which would end up in the
# captured path.
ROOT="$(CDPATH='' cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
CADDYFILE="${1:-$ROOT/docker/Caddyfile}"
[ -f "$CADDYFILE" ] || { echo "no such Caddyfile: $CADDYFILE" >&2; exit 2; }
# Absolute path (it is bind-mounted, and we cd below); cd+pwd rather than realpath, which stock macOS lacks.
CADDYFILE="$(CDPATH='' cd "$(dirname "$CADDYFILE")" && pwd)/$(basename "$CADDYFILE")"
cd "$ROOT" # compose -f paths below are relative to the repo root
CADDY_IMAGE="$(awk 'tolower($1) == "from" && $2 ~ /^caddy[:@]/ { print $2; exit }' "$ROOT/docker/web.Dockerfile")"
[ -n "$CADDY_IMAGE" ] || { echo "could not find the caddy base image in docker/web.Dockerfile" >&2; exit 2; }
FAILED=0
pass() { echo " ok $*"; }
fail() { echo " FAIL $*"; FAILED=$((FAILED + 1)); }
# --- 1. Caddyfile, one run per hosting mode. Each mode is the exact environment the caddy container gets, put through
# docker/edge-peers.sh as the image's entrypoint does. ---
EDGE_PEERS="$ROOT/docker/edge-peers.sh"
echo "Caddyfile: $CADDYFILE (caddy image: $CADDY_IMAGE)"
MODES=(
"own-domain|SITE_ADDRESS=mdm.example.com ACME_EMAIL=ops@example.com COMPOSE_PROFILES="
"cloudflare|SITE_ADDRESS=:80 ACME_EMAIL= COMPOSE_PROFILES=cloudflare"
"cloudflare, compose file from before v0.3.5|SITE_ADDRESS=:80 ACME_EMAIL= CLOUDFLARED_WAIT=0"
"own-domain, no email|SITE_ADDRESS=mdm.example.com ACME_EMAIL= COMPOSE_PROFILES="
"behind a proxy|SITE_ADDRESS=:80 ACME_EMAIL= COMPOSE_PROFILES= TRUSTED_PROXIES=private_ranges"
"own-domain, empty TRUSTED_PROXIES (as the compose files pass it)|SITE_ADDRESS=mdm.example.com ACME_EMAIL=ops@example.com COMPOSE_PROFILES= TRUSTED_PROXIES="
":80, empty TRUSTED_PROXIES|SITE_ADDRESS=:80 ACME_EMAIL= COMPOSE_PROFILES= TRUSTED_PROXIES="
)
for mode in "${MODES[@]}"; do
label="${mode%%|*}"
env_args=()
read -r -a pairs <<< "${mode#*|}"
for kv in "${pairs[@]}"; do env_args+=(-e "$kv"); done
if out="$(docker run --rm --network none "${env_args[@]}" \
-v "$CADDYFILE:/etc/caddy/Caddyfile:ro" -v "$EDGE_PEERS:/edge-peers.sh:ro" "$CADDY_IMAGE" \
sh -c '. /edge-peers.sh && caddy validate --config /etc/caddy/Caddyfile --adapter caddyfile' 2>&1)"; then
pass "caddy validate: $label (${mode#*|})"
else
fail "caddy validate: $label (${mode#*|})"
printf '%s\n' "$out" | grep -v '^{"level":"info"' | tail -5 | sed 's/^/ /'
fi
done
# --- 2. Compose files, each combination setup.sh / quickstart.sh / DEPLOY.md / DEV.md use. ---
# A fixed env file with the required (:?) variables, so a developer's own .env or shell cannot change the result.
ENVF="$(mktemp)"
ENVF_CF="$(mktemp)" # the same, plus the COMPOSE_PROFILES line setup.sh / quickstart.sh write for tunnel mode
trap 'rm -f "$ENVF" "$ENVF_CF"' EXIT
printf 'DB_PASSWORD=x\nBASE_URL=https://mdm.example.com\nHASH_SECRET=x\n' > "$ENVF"
{ cat "$ENVF"; echo COMPOSE_PROFILES=cloudflare; } > "$ENVF_CF"
unset COMPOSE_FILE COMPOSE_PROFILES COMPOSE_PROJECT_NAME
echo "Compose (docker compose config -q):"
COMBOS=(
"-f docker-compose.yml"
"-f docker-compose.yml --profile cloudflare"
"-f docker-compose.yml -f docker-compose.domain.yml"
"-f docker-compose.release.yml"
"-f docker-compose.release.yml --profile cloudflare"
"-f docker-compose.release.yml -f docker-compose.domain.yml"
)
for combo in "${COMBOS[@]}"; do
read -r -a args <<< "$combo"
if out="$(docker compose --env-file "$ENVF" "${args[@]}" config -q 2>&1)"; then
pass "compose $combo"
else
fail "compose $combo"
printf '%s\n' "$out" | tail -5 | sed 's/^/ /'
fi
done
echo "Pinned images (docker compose config --images, as supervisor/apply.sh asks it):"
PIN="0.0.0@sha256:$(printf 'a%.0s' {1..64})"
for file in docker-compose.yml docker-compose.release.yml; do
if ! out="$(IMAGE_OWNER=example SERVER_VERSION="$PIN" WEB_VERSION="$PIN" \
docker compose --env-file "$ENVF" -f "$file" config --images server caddy 2>&1)"; then
fail "compose -f $file config --images"
printf '%s\n' "$out" | tail -5 | sed 's/^/ /'
continue
fi
for want in "ghcr.io/example/mdmesh-server:$PIN" "ghcr.io/example/mdmesh-web:$PIN"; do
if grep -qxF -- "$want" <<<"$out"; then pass "$file: ${want%%:*}"; else fail "$file: no line $want in: $out"; fi
done
done
# --- 3. Health routes, run under the same caddy image. /healthz and /healthz/supervisor, with or without a trailing
# slash, must answer 503 + reason (never the SPA's or the recovery page's catch-all 200) when their upstream is down or
# failing, and 200 "ok" when it answers.
# Hermetic like the rest: one --rm container, --network none, with `server` and `supervisor` pinned to loopback where
# throwaway caddy stubs (admin off) stand in for them. Output uses the ok/FAIL format above. curl, not the image's
# busybox wget: wget discards the body of a non-2xx response, and the 503 reasons are part of the contract.
HEALTH_PROBE="$(cat <<'PROBE'
set -u
F=0
SPA='
spa'
DOWN='server unavailable'
SUP_DOWN='supervisor unavailable'
command -v curl >/dev/null || { echo " FAIL no curl in the caddy image (the probe reads 503 bodies with it)"; exit 1; }
mkdir -p /srv /tmp/stub && echo "$SPA" > /srv/index.html
http_code() { # http_code [body file]: the status code, or nothing when there is no response (curl's 000)
c="$(curl -s -m 5 -o "${2:-/dev/null}" -w '%{http_code}' "$1" || true)"; [ "$c" = 000 ] || printf '%s' "$c"
}
stub() { # stub : start a throwaway upstream on : and wait until it answers
printf '{\n\tadmin off\n}\n:%s {\n%s\n}\n' "$1" "$2" > "/tmp/stub/$1"
caddy run --config "/tmp/stub/$1" --adapter caddyfile >/dev/null 2>&1 &
echo $! > "/tmp/stub/$1.pid"
for _ in $(seq 1 50); do [ -n "$(http_code "http://127.0.0.1:$1/")" ] && return 0; sleep 0.1; done
echo " FAIL stub :$1 never answered"; exit 1
}
unstub() { kill "$(cat "/tmp/stub/$1.pid")"; wait "$(cat "/tmp/stub/$1.pid")" 2>/dev/null || true; }
expect() { # expect