#!/usr/bin/env bash # check_monitoring_pages.sh — verify public monitoring HTML pages via FQDN # # Outside-in: curl https://// and require a real HTML monitoring page. # Catches: merchant JSON code 21 (Caddy handle missing), WP 404, empty stubs, deploy skip. # # Policy (v1.7.6+): # • monpages is obligatory → missing/wrong pages are ERROR (exit 1). # • GOA / hacktivism: full inventory of suite monitoring sites (catalog + on-disk discovery). # • FP: only that stack’s own monitoring hosts/paths (never GOA URLs). # • Override soft mode only with MONPAGES_REQUIRE_PUBLIC=0 (emergency / staging). # # Env: # MON_HOSTS space-separated FQDNs (default from TALER_DOMAIN) # HTML_URL_OK path for OK page (default /monitoring/) # HTML_URL_ERR err path; checked if present on disk or MONPAGES_CHECK_ERR=1 # MONITORING_PAGE_URLS optional full URL list (overrides inventory construction) # MONPAGES_EXTRA_URLS additional absolute URLs (space-separated) # MONPAGES_INVENTORY auto|full|job (default auto) # auto/full = stack inventory; job = only MON_HOSTS+paths # MONPAGES_REQUIRE_PUBLIC default 1 (ERROR). 0 = WARN only (escape hatch). # MONPAGES_STAGING_BASE / HTML_OUT / DEPLOY_WWW_ROOT trees for discovery # TIMEOUT curl timeout (from lib.sh) # set -euo pipefail ROOT=$(cd "$(dirname "$0")" && pwd) # shellcheck source=lib.sh source "$ROOT/lib.sh" set_area monpages section "monpages · public monitoring HTML via FQDN (outside-in, obligatory)" # Obligatory: public pages missing → ERROR. Soft only if explicitly disabled. : "${MONPAGES_REQUIRE_PUBLIC:=1}" : "${MONPAGES_INVENTORY:=auto}" # Default hosts from domain (mirror run-host-report.sh) if [ -z "${MON_HOSTS:-}" ]; then case "${TALER_DOMAIN:-}" in hacktivism.ch|koopa) MON_HOSTS="bank.hacktivism.ch exchange.hacktivism.ch taler.hacktivism.ch" ;; stage.lefrancpaysan.ch|stage.*lefrancpaysan*) MON_HOSTS="stage.bank.lefrancpaysan.ch stage.exchange.lefrancpaysan.ch stage.monnaie.lefrancpaysan.ch" ;; lefrancpaysan.ch) MON_HOSTS="bank.lefrancpaysan.ch exchange.lefrancpaysan.ch monnaie.lefrancpaysan.ch" ;; *) MON_HOSTS="${TALER_DOMAIN:-}" ;; esac fi HTML_URL_OK="${HTML_URL_OK:-/monitoring/}" HTML_URL_ERR="${HTML_URL_ERR:-/monitoring_err/}" # ensure leading + trailing slash _norm_path() { local p="$1" case "$p" in /*) ;; *) p="/$p" ;; esac case "$p" in */) ;; *) p="${p}/" ;; esac printf '%s' "$p" } HTML_URL_OK="$(_norm_path "$HTML_URL_OK")" HTML_URL_ERR="$(_norm_path "$HTML_URL_ERR")" # Stack family: goa | fp | other — FP never checks GOA and vice versa. _monpages_family() { case "${TALER_DOMAIN:-}" in *lefrancpaysan*|*francpaysan*) printf 'fp' ;; hacktivism.ch|koopa) printf 'goa' ;; *) # Infer from MON_HOSTS if domain ambiguous case " ${MON_HOSTS:-} " in *lefrancpaysan*) printf 'fp' ;; *hacktivism*) printf 'goa' ;; *) printf 'other' ;; esac ;; esac } _host_allowed() { local h="$1" fam="$2" case "$fam" in goa) case "$h" in *.hacktivism.ch) return 0 ;; *) return 1 ;; esac ;; fp) case "$h" in *lefrancpaysan*) return 0 ;; *) return 1 ;; esac ;; *) return 0 ;; esac } # Public mon URL families (suite name "taler-monitoring" ≠ a public path): # 1) /monitoring(+_err) — landing hosts (bare + slash) # 2) /taler-monitoring-surface(+_err) — taler.hacktivism.ch only # 3) /taler-monitoring-aptdeploy(+_err) — taler.hacktivism.ch only # No /taler-monitoring page. No /taler-monitoring-mail|mattermost pages. _path_allowed() { case "$1" in monitoring|monitoring_err|\ taler-monitoring-surface|taler-monitoring-surface_err|\ taler-monitoring-aptdeploy|taler-monitoring-aptdeploy_err) return 0 ;; # explicit deny (never invent a mon page here) taler-monitoring|taler-monitoring-mail|taler-monitoring-mattermost) return 1 ;; *) return 1 ;; esac } # Emit https://host/path/ for each …/host/path/index.html under root (max depth 3). _discover_tree() { local root="$1" fam="$2" local f rel host path [ -n "$root" ] && [ -d "$root" ] || return 0 # layout: $root///index.html while IFS= read -r -d '' f; do rel="${f#"${root%/}"/}" host="${rel%%/*}" path="${rel#*/}" path="${path%/index.html}" [ -n "$host" ] && [ -n "$path" ] || continue _host_allowed "$host" "$fam" || continue _path_allowed "$path" || continue printf 'https://%s/%s/\n' "$host" "$path" done < <(find "${root%/}" -mindepth 3 -maxdepth 3 -type f -name index.html -print0 2>/dev/null) } # Nine landing stacks (GOA 3 + FP stage 3 + FP prod 3) with /monitoring/ _landing_nine() { printf '%s\n' \ bank.hacktivism.ch exchange.hacktivism.ch taler.hacktivism.ch \ stage.bank.lefrancpaysan.ch stage.exchange.lefrancpaysan.ch stage.monnaie.lefrancpaysan.ch \ bank.lefrancpaysan.ch exchange.lefrancpaysan.ch monnaie.lefrancpaysan.ch } # Suite catalog of OK pages that must exist for this family. _catalog_urls() { local fam="$1" h case "$fam" in goa) for h in bank.hacktivism.ch exchange.hacktivism.ch taler.hacktivism.ch; do printf 'https://%s/monitoring/\n' "$h" done # ecosystem surface (versions / software) + apt-src deploy mon page printf 'https://taler.hacktivism.ch/taler-monitoring-surface/\n' printf 'https://taler.hacktivism.ch/taler-monitoring-aptdeploy/\n' ;; fp) for h in $MON_HOSTS; do [ -z "$h" ] && continue _host_allowed "$h" fp || continue printf 'https://%s/monitoring/\n' "$h" done ;; all|nine) # optional: all nine landings + surface + aptdeploy while IFS= read -r h; do [ -n "$h" ] || continue printf 'https://%s/monitoring/\n' "$h" done < <(_landing_nine) printf 'https://taler.hacktivism.ch/taler-monitoring-surface/\n' printf 'https://taler.hacktivism.ch/taler-monitoring-aptdeploy/\n' ;; esac } # Job-local URLs (this host-agent run’s path). _job_urls() { local h p for h in $MON_HOSTS; do [ -z "$h" ] && continue printf 'https://%s%s\n' "$h" "$HTML_URL_OK" if [ "${MONPAGES_CHECK_ERR:-0}" = "1" ]; then printf 'https://%s%s\n' "$h" "$HTML_URL_ERR" fi done } build_urls() { local fam inv fam="$(_monpages_family)" inv="${MONPAGES_INVENTORY:-auto}" if [ -n "${MONITORING_PAGE_URLS:-}" ]; then # shellcheck disable=SC2086 printf '%s\n' $MONITORING_PAGE_URLS else case "$inv" in job) # Only this host-agent job’s HTML paths (MON_HOSTS + HTML_URL_OK/_ERR). # Specialized timers (aptdeploy, surface) must not inherit GOA full catalog # (no bank/exchange /monitoring bare-URL noise on the aptdeploy page). _job_urls ;; full|auto|*) # Always include this job’s paths (host-agent post-check). _job_urls # Stack inventory: GOA = landings + surface + aptdeploy; FP = only FP mon hosts. # Full inventory belongs on the general stack run (run-hacktivism-monitoring). if [ "$fam" = "goa" ] || [ "$fam" = "fp" ]; then _catalog_urls "$fam" # On-disk discovery (err pages only exist after failed runs; extras welcome) _discover_tree "${DEPLOY_WWW_ROOT:-/var/www/monitoring-sites}" "$fam" _discover_tree "${MONPAGES_STAGING_BASE:-${HTML_OUT:-${HTML_BASE:-$HOME/monitoring-sites-staging}}}" "$fam" fi ;; esac fi if [ -n "${MONPAGES_EXTRA_URLS:-}" ]; then # shellcheck disable=SC2086 printf '%s\n' $MONPAGES_EXTRA_URLS fi } # Install stub only (no console log) — not a real mon page. is_bootstrap_html() { local f="$1" grep -qiE 'Monitoring page \(bootstrap\)|pill">STAGE/dev/null \ && ! grep -qE 'taler-mon:top|id="mon-console"|class="console"' "$f" 2>/dev/null } # True if body looks like our monitoring HTML (not merchant API / WP 404 / bootstrap). is_monitoring_html() { local f="$1" if grep -qE '"code"[[:space:]]*:[[:space:]]*21' "$f" 2>/dev/null; then return 1 fi if grep -qiE 'There is no endpoint defined for the URL' "$f" 2>/dev/null; then return 1 fi if grep -qiE 'wp-content|wordpress' "$f" 2>/dev/null \ && grep -qiE '404|not found|page introuvable' "$f" 2>/dev/null; then return 1 fi # Bootstrap sticky is not enough — need suite console output if is_bootstrap_html "$f"; then return 1 fi if grep -qE 'sticky-bar|version-link|taler-monitoring|class="sticky|taler-mon:top' "$f" 2>/dev/null; then return 0 fi if head -c 4096 "$f" | grep -qiE '/dev/null; then return 0 fi if head -c 512 "$f" | grep -qiE '/dev/null \ | grep -oE '[0-9]+' | sort -n | tail -1 || true) printf '%s' "${m:-0}" } # Mid-run: incomplete meta / redirect stub, or progress never reached 100% and no complete foot. _page_mid_run() { local f="$1" local pct if grep -qE 'name="taler-mon-page"[[:space:]]+content="incomplete"' "$f" 2>/dev/null \ || grep -q 'taler-mon:bottom:incomplete' "$f" 2>/dev/null \ || grep -qE 'http-equiv="refresh".*monitoring_err|mode-redirect' "$f" 2>/dev/null; then return 0 fi # finished page always has complete foot or classic footer if grep -q 'taler-mon:bottom:complete' "$f" 2>/dev/null \ || grep -qE 'id="mon-footer"' "$f" 2>/dev/null; then return 1 fi pct=$(_progress_max_pct "$f") # max progress seen < 100% and no complete footer → still running if [ "${pct:-0}" -gt 0 ] && [ "${pct:-0}" -lt 100 ]; then if ! grep -qE 'Console-style render of taler-monitoring|Rendu console de taler-monitoring' "$f" 2>/dev/null; then return 0 fi fi return 1 } # Validate top/bottom markers + meaningful body (v1.9.0). # Sets _MON_CONTENT_MSG; returns 0=ok, 1=hard fail, 2=soft/mid-run bottom skip validate_mon_content() { local f="$1" url="$2" local top_ok=1 bot_ok=1 body_ok=1 mid=0 pct _MON_CONTENT_MSG="" # --- top markers (always required) --- if ! head -c 12000 "$f" | grep -qE 'sticky-bar|id="status-bar"|taler-mon:top'; then top_ok=0 _MON_CONTENT_MSG="missing top marker (sticky-bar / status-bar / taler-mon:top)" elif ! head -c 16000 "$f" | grep -qE 'data-generated-iso|name="generated"|version-link|taler-mon-page'; then top_ok=0 _MON_CONTENT_MSG="missing top meta (generated / version-link / taler-mon-page)" fi # --- meaningful body: real checks, not only progress bars --- if grep -qE 'class="line (ok|error|warn|blocker|info)"|┌ OK|┌ ERROR|┌ WARN|┌ BLOCKER|┌ INFO|class="box"' "$f" 2>/dev/null; then body_ok=1 elif grep -qE 'taler-mon:bottom:incomplete|mode-redirect|has failures' "$f" 2>/dev/null; then body_ok=1 # redirect stub still has sticky + box else # only progress / empty? if grep -qE '┌[[:space:]]*[0-9]+%┐|█|░' "$f" 2>/dev/null \ && ! grep -qE '┌ OK|┌ ERROR|class="line ok"' "$f" 2>/dev/null; then body_ok=0 _MON_CONTENT_MSG="${_MON_CONTENT_MSG:+$_MON_CONTENT_MSG; }only progress bars / no check lines" elif ! grep -qE 'console|host-agent|taler-monitoring|monitoring' "$f" 2>/dev/null; then body_ok=0 _MON_CONTENT_MSG="${_MON_CONTENT_MSG:+$_MON_CONTENT_MSG; }no meaningful suite content" fi fi # --- bottom markers --- if grep -q 'taler-mon:bottom:complete' "$f" 2>/dev/null \ || tail -c 8000 "$f" | grep -qE 'id="mon-footer"||Console-style render of taler-monitoring'; then bot_ok=1 elif grep -q 'taler-mon:bottom:incomplete' "$f" 2>/dev/null; then bot_ok=1 # incomplete is an explicit bottom marker mid=1 else bot_ok=0 _MON_CONTENT_MSG="${_MON_CONTENT_MSG:+$_MON_CONTENT_MSG; }missing bottom marker (footer / taler-mon:bottom)" fi if _page_mid_run "$f"; then mid=1 fi pct=$(_progress_max_pct "$f") if [ "$top_ok" != "1" ]; then _MON_CONTENT_MSG="TOP: $_MON_CONTENT_MSG" return 1 fi if [ "$body_ok" != "1" ]; then _MON_CONTENT_MSG="BODY: $_MON_CONTENT_MSG" return 1 fi if [ "$bot_ok" != "1" ]; then if [ "$mid" = "1" ]; then _MON_CONTENT_MSG="BOTTOM soft (mid-run progress ${pct}%): $_MON_CONTENT_MSG" return 2 fi _MON_CONTENT_MSG="BOTTOM: $_MON_CONTENT_MSG" return 1 fi if [ "$mid" = "1" ] && [ "${pct:-0}" -gt 0 ] && [ "${pct:-0}" -lt 100 ]; then _MON_CONTENT_MSG="mid-run progress ${pct}% (bottom markers relaxed)" return 2 fi _MON_CONTENT_MSG="top+body+bottom markers OK" return 0 } _is_bare_url() { local url="$1" case " ${MONPAGES_BARE_URLS:-} " in *" $url "*) return 0 ;; esac case "$url" in */) return 1 ;; *) return 0 ;; esac } _mon_fail_or_soft() { local label="$1" detail="$2" if [ "${MONPAGES_REQUIRE_PUBLIC:-1}" != "1" ]; then warn "$label" "$detail (MONPAGES_REQUIRE_PUBLIC=0)" return 0 fi fail "$label" "$detail" return 1 } check_one() { local url="$1" local body code hint soft=0 vc body=$(mktemp) code=$(curl -skS -L --max-redirs 5 -m "${TIMEOUT}" -o "$body" -w '%{http_code}' "$url" 2>/dev/null || echo 000) if _is_bare_url "$url" && [ "${MONPAGES_BARE_STRICT:-0}" != "1" ]; then soft=1 fi if grep -qE '"code"[[:space:]]*:[[:space:]]*21' "$body" 2>/dev/null \ || grep -qiE 'There is no endpoint defined for the URL' "$body" 2>/dev/null; then # Bare without trailing slash often hits merchant until Caddy has redir * /path/ 302. # Default: WARN on bare (soft) so slash-OK stacks still publish green/yellow pages. # MONPAGES_BARE_STRICT=1 → ERROR (enforce bare redir live). if [ "$soft" = "1" ]; then warn "public mon page bare URL" \ "$url -> HTTP $code code 21 (prefer trailing slash; Caddy: redir * /path/ 302; set MONPAGES_BARE_STRICT=1 to ERROR)" rm -f "$body" return 0 fi _mon_fail_or_soft "public mon page missing" \ "$url -> HTTP $code merchant/API JSON code 21 (Caddy handle/redir; use redir * /path/ 302 inside handle; apply: sudo /home/hernani/koopa-caddy/apply-monitoring-live.sh)" || { rm -f "$body"; return 1; } rm -f "$body" return 0 fi if [ "$code" != "200" ]; then if [ "$soft" = "1" ]; then warn "public mon page bare URL" "$url -> HTTP $code (prefer trailing slash form)" rm -f "$body" return 0 fi _mon_fail_or_soft "public mon page" "$url -> HTTP $code (want 200 HTML)" || { rm -f "$body"; return 1; } rm -f "$body" return 0 fi if is_bootstrap_html "$body"; then _mon_fail_or_soft "public mon page bootstrap stub" \ "$url -> install bootstrap HTML still live (run host-agent; suite auto-update must replace stub)" || { rm -f "$body"; return 1; } rm -f "$body" return 0 fi if ! is_monitoring_html "$body"; then hint=$(head -c 120 "$body" | tr '\n' ' ' | tr -cd '[:print:] ') _mon_fail_or_soft "public mon page not monitoring HTML" \ "$url -> HTTP 200 body!=suite (${hint}...)" || { rm -f "$body"; return 1; } rm -f "$body" return 0 fi # v1.9.0: top + bottom markers + meaningful content set +e validate_mon_content "$body" "$url" vc=$? set -e case "$vc" in 0) ok "public mon page" "$url -> HTTP 200 · ${_MON_CONTENT_MSG}" ;; 2) # mid-run: bottom may be incomplete — not ERROR warn "public mon page mid-run" "$url -> ${_MON_CONTENT_MSG}" ok "public mon page" "$url -> HTTP 200 suite HTML (mid-run bottom relaxed)" ;; *) if [ "$soft" = "1" ]; then warn "public mon page content" "$url -> ${_MON_CONTENT_MSG}" else _mon_fail_or_soft "public mon page content" \ "$url -> ${_MON_CONTENT_MSG}" || { rm -f "$body"; return 1; } fi ;; esac rm -f "$body" return 0 } URLS=() while IFS= read -r line; do [ -n "$line" ] || continue # dedupe skip=0 for e in "${URLS[@]+"${URLS[@]}"}"; do [ "$e" = "$line" ] && skip=1 && break done [ "$skip" = "1" ] && continue URLS+=("$line") done < <(build_urls | sort -u) # Bare URLs without trailing slash: checked by default. # Merchant JSON code 21 is always ERROR (Caddy/proxy not serving mon path). # Other bare failures: WARN unless MONPAGES_BARE_STRICT=1 (then ERROR). # MONPAGES_CHECK_BARE=0 → skip bare entirely # MONPAGES_BARE_STRICT=1 → non-21 bare failures are ERROR too if [ "${MONPAGES_CHECK_BARE:-1}" = "1" ]; then _extra=() for u in "${URLS[@]}"; do case "$u" in */) _bare=${u%/}; [ -n "$_bare" ] && _extra+=("$_bare") ;; esac done for u in "${_extra[@]+"${_extra[@]}"}"; do skip=0 for e in "${URLS[@]+"${URLS[@]}"}"; do [ "$e" = "$u" ] && skip=1 && break done [ "$skip" = "1" ] && continue URLS+=("$u") MONPAGES_BARE_URLS="${MONPAGES_BARE_URLS:-} $u" done export MONPAGES_BARE_URLS fi if [ "${#URLS[@]}" -eq 0 ]; then fail "monpages" "no URLs to check (set MON_HOSTS or MONITORING_PAGE_URLS)" exit 1 fi _fam="$(_monpages_family)" echo " family=${_fam} inventory=${MONPAGES_INVENTORY} require_public=${MONPAGES_REQUIRE_PUBLIC}" echo " checking ${#URLS[@]} URL(s) via FQDN..." ec=0 for u in "${URLS[@]}"; do check_one "$u" || ec=1 done if [ "$ec" -ne 0 ]; then echo " hint: public monitoring HTML not served (404 / merchant code 21)" echo " publish staging HTML + configure reverse-proxy/Caddy handle for mon paths" echo " (on koopa: sudo /home/hernani/koopa-caddy/apply-monitoring-live.sh)" echo " bare code 21: Caddy redir inside handle must be: redir * /path/ 302 (not redir /path/ 302)" if [ "$_fam" = "fp" ]; then echo " FP: only FP mon hosts are checked — wire Infomaniak vhost for /monitoring*" fi if [ "$_fam" = "goa" ]; then echo " GOA: bank/exchange/taler /monitoring/ + surface + aptdeploy (v1.9.4+ layout)" fi # Staging vs public diagnosis (host-agent sets MONPAGES_STAGING_BASE / HTML_OUT) _stg="${MONPAGES_STAGING_BASE:-${HTML_OUT:-${HTML_BASE:-$HOME/monitoring-sites-staging}}}" _ok_dir="${HTML_OK_DIR:-monitoring}" if [ -n "${MON_HOSTS:-}" ] && [ -n "$_stg" ]; then for _h in $MON_HOSTS; do _f="$_stg/$_h/${_ok_dir}/index.html" if [ -f "$_f" ]; then echo " staging OK: $_f ($(wc -c <"$_f" | tr -d " ") bytes) — not published/served" else echo " staging missing: $_f" fi done fi _www="${DEPLOY_WWW_ROOT:-/var/www/monitoring-sites}" if [ -e "$_www" ] && [ ! -w "$_www" ]; then echo " DEPLOY_WWW_ROOT=$_www not writable by $(id -un 2>/dev/null || echo user)" fi fi exit "$ec"