#!/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