koopa-admin-log/scripts/taler-monitoring/check_urls.sh
2026-07-17 15:55:50 +02:00

981 lines
34 KiB
Bash
Executable file

#!/usr/bin/env bash
# Outside-in public HTTPS checks (no SSH).
set -euo pipefail
ROOT=$(cd "$(dirname "$0")" && pwd)
# shellcheck source=lib.sh
source "$ROOT/lib.sh"
# Area www.* — public HTTPS (outside-in)
# Groups: www.exchange / www.perf / www.stats / www.bank / www.merchant /
# www.paivana / www.landing
set_area www
section "www · public URLs · ${TALER_DOMAIN:-?} (outside-in, no SSH)"
tmp=$(mktemp -d)
trap 'rm -rf "$tmp"' EXIT
check_url() {
local label="$1" expect="$2" url="$3"
local code
code=$(http_code "$url")
case ",$expect," in
*",$code,"*) ok "$label $url" ;;
*) fail "$label $url" "got $code want $expect" ;;
esac
}
# Soft check: OK on expect, WARN on foreign stack if down, ERROR on local stack
check_url_soft() {
local label="$1" expect="$2" url="$3"
local code
code=$(http_code "$url")
case ",$expect," in
*",$code,"*) ok "$label $url" ;;
*)
if [ "${LOCAL_STACK:-1}" = "0" ]; then
warn "$label $url" "got $code (optional on remote domain)"
else
fail "$label $url" "got $code want $expect"
fi
;;
esac
}
expect_currency() {
local label="$1" file="$2" want="${EXPECT_CURRENCY:-}"
local cur
cur=$(python3 -c 'import json,sys;print(json.load(open(sys.argv[1])).get("currency",""))' "$file" 2>/dev/null || true)
if [ -z "$want" ]; then
info "$label currency" "${cur:-?}"
return
fi
if [ "$cur" = "$want" ]; then
ok "$label currency=$want"
else
fail "$label currency" "got ${cur:-?} want $want"
fi
}
# Legal docs: /terms and /privacy must be HTTP 200 with a real document body
# (not empty, not "not configured", not JSON API error).
# $1=label $2=url $3=optional needle regex (case-insensitive) for local stack
check_legal_doc() {
local label="$1" url="$2" needle="${3:-}"
local f code soft
soft=0
[ "${LOCAL_STACK:-1}" = "0" ] && soft=1
f=$(mktemp)
code=$(curl -skS --max-redirs 5 -L -m "${TIMEOUT}" \
-H "Accept: text/html,text/markdown,text/plain,*/*" \
-o "$f" -w '%{http_code}' "$url" 2>/dev/null || echo 000)
if [ "$code" != "200" ]; then
rm -f "$f"
if [ "$soft" = "1" ]; then
warn "$label" "HTTP $code$url"
else
fail "$label" "HTTP $code$url"
fi
return
fi
if [ ! -s "$f" ]; then
rm -f "$f"
fail "$label" "empty body — $url"
return
fi
# merchant returns plain "not configured" when PRIVACY_ETAG missing
if grep -qiE '^(not configured)\s*$' "$f" 2>/dev/null \
|| grep -qiE '"code"\s*:\s*21' "$f" 2>/dev/null; then
rm -f "$f"
fail "$label" "not configured / API error — $url"
return
fi
if [ -n "$needle" ] && [ "${LOCAL_STACK:-1}" = "1" ]; then
if ! grep -qiE "$needle" "$f" 2>/dev/null; then
warn "$label content" "missing /$needle/ — $url"
rm -f "$f"
return
fi
fi
ok "$label" "HTTP 200 · $(wc -c <"$f" | tr -d ' ') bytes"
rm -f "$f"
}
# Shared shape for bank mint JSON (demo-withdraw + auto-account).
# Args: $1=json file $2=auto|demo
# stdout (ok): one detail line; for demo, optional "\twithdrawal_id" suffix.
# stderr (fail): short reason. Exit 0/1.
validate_bank_withdraw_json() {
python3 - "$1" "$2" <<'PY'
import json, re, sys
from urllib.parse import urlparse
def check_withdraw_uri(wuri: str):
"""HOST or HOST:non-default-port; default :443/:80 must be stripped (wallet fix)."""
m = re.match(
r"^taler://withdraw/([^/]+)/taler-integration/([0-9a-fA-F-]+)$",
wuri or "",
)
if not m:
print(
"need taler://withdraw/HOST/taler-integration/ID:",
(wuri or "")[:120],
file=sys.stderr,
)
return None
host = m.group(1)
if not host or host.startswith(":"):
print("bad withdraw host:", host, file=sys.stderr)
return None
if ":" in host:
h, _, p = host.rpartition(":")
if not h or not p.isdigit():
print("bad withdraw host:port:", host, file=sys.stderr)
return None
if p in ("443", "80"):
print("withdraw must strip default port :%s:" % p, host, file=sys.stderr)
return None
return host, m.group(2), wuri
path, mode = sys.argv[1], sys.argv[2]
d = json.load(open(path))
wuri = d.get("taler_withdraw_uri") or (
d.get("qr_payload") if mode == "auto" else ""
) or ""
if mode == "auto":
if not d.get("ok"):
print("ok!=true", file=sys.stderr)
sys.exit(1)
if d.get("payto_uri"):
print("payto_uri must not be present", file=sys.stderr)
sys.exit(1)
if not check_withdraw_uri(wuri):
sys.exit(1)
webui = d.get("login_url") or d.get("webui") or d.get("account_url") or ""
u = urlparse(webui)
if u.scheme not in ("http", "https") or "webui" not in (u.path or ""):
print("login webui missing:", webui[:80], file=sys.stderr)
sys.exit(1)
print("%s · %s" % (d.get("username", ""), wuri[:72]))
elif mode == "demo":
if not d.get("ok", True) and "taler_withdraw_uri" not in d:
print("not ok", file=sys.stderr)
sys.exit(1)
if not check_withdraw_uri(wuri):
sys.exit(1)
print("%s\t%s" % (wuri[:80], d.get("withdrawal_id") or ""))
else:
print("bad mode:", mode, file=sys.stderr)
sys.exit(2)
sys.exit(0)
PY
}
# --- exchange (core; always required) --- www.exchange-NN
set_group exchange
check_url "exchange /config" 200 "$EXCHANGE_PUBLIC/config"
code=$(http_body "$EXCHANGE_PUBLIC/config" "$tmp/ec.json")
if [ "$code" = "200" ]; then
expect_currency "exchange" "$tmp/ec.json"
# currency_specification.alt_unit_names (wallet codec)
if json_has_alt_unit_names "$tmp/ec.json" "${EXPECT_CURRENCY:-}" >/tmp/alt-ex.$$ 2>&1; then
ok "exchange /config alt_unit_names" "$(tr '\n' '; ' </tmp/alt-ex.$$ | sed 's/; $//')"
else
fail "exchange /config alt_unit_names" "$(tr '\n' '; ' </tmp/alt-ex.$$ | sed 's/; $//')"
fi
rm -f /tmp/alt-ex.$$
fi
check_url "exchange /keys" 200 "$EXCHANGE_PUBLIC/keys"
# /keys should also expose currency_specification.alt_unit_names when present
code=$(http_body "$EXCHANGE_PUBLIC/keys" "$tmp/ek.json")
if [ "$code" = "200" ]; then
if python3 - "$tmp/ek.json" <<'PY'
import json,sys
d=json.load(open(sys.argv[1]))
cs=d.get("currency_specification") or {}
au=cs.get("alt_unit_names") if isinstance(cs,dict) else None
if not isinstance(au, dict) or "0" not in au:
# older keys without embedded spec: not a hard fail if /config is good
sys.exit(2)
sys.exit(0)
PY
then
ok "exchange /keys alt_unit_names"
else
ec=$?
if [ "$ec" = "2" ]; then
warn "exchange /keys alt_unit_names" "no currency_specification in /keys (ok if /config has it)"
else
fail "exchange /keys alt_unit_names"
fi
fi
fi
if [ "${CHECK_LANDING:-1}" = "1" ]; then
check_url_soft "exchange /intro/" 200 "$EXCHANGE_PUBLIC/intro/"
# Root should land on intro (302/301 then 200 on follow is checked separately)
check_url_soft "exchange /" 302,301,200 "$EXCHANGE_PUBLIC/"
else
info "exchange /intro" "skipped (CHECK_LANDING=0 · no public landings for this stack)"
fi
# ---------------------------------------------------------------------------
# Performance — outside-in public HTTPS latency (this runner, not loopback)
# Same spirit as bank landing-stats public probes; measured here from outside.
# ---------------------------------------------------------------------------
set_group perf
section "www · performance · public HTTPS latency (outside-in)"
# Latency thresholds (ms), outside-in from this runner.
# ≥ PERF_WARN_MS → WARN. ≥ PERF_FAIL_MS → ERROR (suite fails).
PERF_WARN_MS="${PERF_WARN_MS:-8000}"
PERF_FAIL_MS="${PERF_FAIL_MS:-20000}"
PERF_TSV="$tmp/perf.tsv"
: >"$PERF_TSV"
# Measure one URL: require HTTP expect (default 200), report time_total in ms.
# $1=label $2=url $3=optional expected codes (default 200)
check_perf() {
local label="$1" url="$2" expect="${3:-200}"
local out code t_s ms
out=$(curl -skS --max-redirs 3 -L -m "${PERF_CURL_TIMEOUT:-25}" \
-o /dev/null -w '%{http_code} %{time_total}' "$url" 2>/dev/null || echo "000 0")
code=$(printf '%s' "$out" | awk '{print $1}')
t_s=$(printf '%s' "$out" | awk '{print $2}')
ms=$(awk -v t="${t_s:-0}" 'BEGIN{
ms=(t+0)*1000
if (ms>0 && ms<1) ms=1
printf "%d", int(ms+0.5)
}')
# Always record sample for rollup (label, ms, http, url)
printf '%s\t%s\t%s\t%s\n' "$label" "$ms" "$code" "$url" >>"$PERF_TSV"
case ",$expect," in
*",$code,"*)
if [ "$ms" -ge "${PERF_FAIL_MS}" ] 2>/dev/null; then
fail "$label" "HTTP $code · ${ms} ms ≥ fail ${PERF_FAIL_MS} ms · $url"
elif [ "$ms" -ge "${PERF_WARN_MS}" ] 2>/dev/null; then
warn "$label" "HTTP $code · ${ms} ms ≥ warn ${PERF_WARN_MS} ms · $url"
else
ok "$label" "HTTP $code · ${ms} ms · $url"
fi
;;
*)
if [ "${LOCAL_STACK:-1}" = "1" ]; then
fail "$label" "HTTP $code want $expect · ${ms} ms · $url"
else
warn "$label" "HTTP $code want $expect · ${ms} ms · $url"
fi
;;
esac
}
# Bank first (wallet-critical paths before UI chrome)
check_perf "perf bank /taler-integration/config" "$BANK_PUBLIC/taler-integration/config"
check_perf "perf bank /config" "$BANK_PUBLIC/config"
if [ "${CHECK_LANDING:-1}" = "1" ]; then
check_perf "perf bank /intro/" "$BANK_PUBLIC/intro/"
check_perf "perf bank /intro/stats.json" "$BANK_PUBLIC/intro/stats.json" 200
fi
check_perf "perf bank /webui/" "$BANK_PUBLIC/webui/" 200,301,302
# Exchange
check_perf "perf exchange /config" "$EXCHANGE_PUBLIC/config"
check_perf "perf exchange /keys" "$EXCHANGE_PUBLIC/keys"
if [ "${CHECK_LANDING:-1}" = "1" ]; then
check_perf "perf exchange /intro/" "$EXCHANGE_PUBLIC/intro/"
fi
# Merchant
check_perf "perf merchant /config" "$MERCHANT_PUBLIC/config"
check_perf "perf merchant /webui/" "$MERCHANT_PUBLIC/webui/" 200,301,302
if [ "${CHECK_LANDING:-1}" = "1" ]; then
check_perf "perf merchant /intro/" "$MERCHANT_PUBLIC/intro/"
fi
info "perf note" "RTT measured from this host (outside-in)"
# ---------------------------------------------------------------------------
# Landing load stats (stats.json written *inside* containers, usually public).
# Prefer HTTPS /intro/stats.json (no SSH). If missing and SSH works, cat from
# the container. Report loadavg + RSS + in-container probe ms — one line each.
# ---------------------------------------------------------------------------
: "${STATS_STALE_SECS:=900}" # warn if generated_at older than 15m
_fetch_landing_stats_json() {
# $1=name bank|exchange|merchant $2=base URL → writes $tmp/stats-$1.json, exit 0 if ok
local name="$1" base="$2"
local f="$tmp/stats-${name}.json"
local code ctr path
rm -f "$f"
code=$(http_body "${base}/intro/stats.json" "$f" 2>/dev/null || echo 000)
if [ "$code" = "200" ] && [ -s "$f" ]; then
return 0
fi
# Optional inside-container fallback (no public path or empty)
if [ "${LOCAL_STACK:-0}" = "1" ] && [ "${SKIP_SSH:-0}" != "1" ] && koopa_ssh_ok 2>/dev/null; then
case "$name" in
bank) ctr=taler-hacktivism-bank; path=/var/www/bank-landing/stats.json ;;
exchange) ctr=taler-hacktivism-exchange-ansible; path=/var/www/exchange-landing/stats.json ;;
merchant) ctr=taler-hacktivism; path=/var/www/merchant-landing/stats.json ;;
*) return 1 ;;
esac
if koopa_ssh_run 12 "podman exec ${ctr} cat ${path} 2>/dev/null" >"$f" 2>/dev/null \
&& [ -s "$f" ]; then
return 0
fi
fi
return 1
}
report_landing_load_stats() {
local name="$1" base="$2"
local f="$tmp/stats-${name}.json"
local line age_s
if ! _fetch_landing_stats_json "$name" "$base"; then
if [ "${CHECK_LANDING:-1}" = "1" ] || [ "${LOCAL_STACK:-0}" = "1" ]; then
warn "perf ${name} landing-stats" "stats.json not available (public or SSH)"
fi
return
fi
line=$(python3 - "$f" "${STATS_STALE_SECS}" <<'PY' 2>/dev/null || true
import json, sys, time
path, stale = sys.argv[1], int(sys.argv[2])
try:
d = json.load(open(path))
except Exception as e:
print("ERR parse:" + str(e)[:80])
sys.exit(0)
if not d.get("ok", True):
print("ERR ok=false " + str(d.get("error") or "")[:80])
sys.exit(0)
p = d.get("performance") or {}
if not isinstance(p, dict) or not p:
print("ERR no performance block")
sys.exit(0)
mem = p.get("memory") if isinstance(p.get("memory"), dict) else {}
rss = mem.get("container_rss_human") or mem.get("proc_sum_rss_human") or "?"
load = p.get("loadavg") or "?"
# latency fields differ by site
bits = []
for k in ("config_ms", "integration_ms", "webui_ms", "keys_ms", "terms_ms"):
if k in p and p[k] is not None:
bits.append("%s=%sms" % (k.replace("_ms", ""), p[k]))
lat = " ".join(bits) if bits else "latency=?"
gen = d.get("generated_at_human") or d.get("generated_at") or "?"
# staleness
age = ""
try:
gu = d.get("generated_at_unix")
if gu is not None:
age_s = int(time.time()) - int(gu)
age = " age=%ss" % age_s
if age_s > stale:
print("STALE loadavg=%s RSS=%s %s gen=%s%s" % (load, rss, lat, gen, age))
sys.exit(0)
except Exception:
pass
print("OK loadavg=%s RSS=%s %s gen=%s%s" % (load, rss, lat, gen, age))
PY
)
case "$line" in
OK\ *)
ok "perf ${name} landing-stats" "${line#OK }"
;;
STALE\ *)
warn "perf ${name} landing-stats" "stale · ${line#STALE }"
;;
ERR\ *)
warn "perf ${name} landing-stats" "${line#ERR }"
;;
*)
warn "perf ${name} landing-stats" "unreadable stats.json"
;;
esac
}
if [ "${CHECK_LANDING:-1}" = "1" ] || [ "${LOCAL_STACK:-0}" = "1" ]; then
set_group stats
section "www · performance · landing load stats (stats.json · in-container probes)"
report_landing_load_stats "bank" "$BANK_PUBLIC"
report_landing_load_stats "exchange" "$EXCHANGE_PUBLIC"
report_landing_load_stats "merchant" "$MERCHANT_PUBLIC"
info "perf landing-stats note" "from /intro/stats.json (public); SSH container fallback if needed"
fi
# Rollup + optional JSON for metrics_print_overall
if [ -s "$PERF_TSV" ]; then
PERF_JSON="$tmp/perf-summary.json"
perf_line=$(python3 - "$PERF_TSV" "$PERF_JSON" "$PERF_WARN_MS" "$PERF_FAIL_MS" <<'PY'
import json, sys
rows = []
for line in open(sys.argv[1]):
parts = line.rstrip("\n").split("\t")
if len(parts) < 3:
continue
label, ms_s, code = parts[0], parts[1], parts[2]
try:
ms = int(float(ms_s))
except Exception:
continue
rows.append({"label": label, "ms": ms, "http": code})
vals = sorted(r["ms"] for r in rows)
n = len(vals)
if n == 0:
print("n=0")
json.dump({"n": 0}, open(sys.argv[2], "w"))
raise SystemExit(0)
def pct(p):
if n == 1:
return vals[0]
i = min(n - 1, max(0, int(round((p / 100.0) * (n - 1)))))
return vals[i]
avg = int(round(sum(vals) / n))
warn_ms = int(sys.argv[3])
fail_ms = int(sys.argv[4])
slow = [r for r in rows if r["ms"] >= warn_ms]
rep = {
"n": n,
"min_ms": vals[0],
"p50_ms": pct(50),
"avg_ms": avg,
"max_ms": vals[-1],
"warn_ms": warn_ms,
"fail_ms": fail_ms,
"slow": [{"label": r["label"], "ms": r["ms"]} for r in slow],
"samples": rows,
}
# metrics_print_overall expects named buckets with n/min/p50/avg/max
out = {
"www_public_https": {
"n": n,
"min_ms": vals[0],
"p50_ms": pct(50),
"avg_ms": avg,
"max_ms": vals[-1],
},
**{r["label"].replace(" ", "_"): {"n": 1, "min_ms": r["ms"], "p50_ms": r["ms"], "avg_ms": r["ms"], "max_ms": r["ms"]} for r in rows},
}
json.dump(out, open(sys.argv[2], "w"), indent=2)
extra = ""
if slow:
extra = " slow: " + ", ".join("%s=%dms" % (r["label"].replace("perf ", ""), r["ms"]) for r in slow)
print(
"n=%d min=%dms p50=%dms avg=%dms max=%dms (warn≥%dms fail≥%dms)%s"
% (n, vals[0], pct(50), avg, vals[-1], warn_ms, fail_ms, extra)
)
PY
)
info "perf summary" "$perf_line"
# Keep a copy if METRICS_DIR is set (e2e/ladder overall stats)
if [ -n "${METRICS_DIR:-}" ] && [ -d "${METRICS_DIR}" ]; then
cp -f "$PERF_JSON" "${METRICS_DIR}/perf-summary.json" 2>/dev/null || true
fi
fi
info "perf note" "measured from this host (outside-in); thresholds PERF_WARN_MS=${PERF_WARN_MS} PERF_FAIL_MS=${PERF_FAIL_MS}"
# Exchange terms + privacy (same www.exchange group — issues map to exchange)
set_group exchange
check_legal_doc "exchange /terms" "$EXCHANGE_PUBLIC/terms" "terms|GOA|exploration|FADP|revDSG|privacy"
check_legal_doc "exchange /privacy" "$EXCHANGE_PUBLIC/privacy" "privacy|FADP|revDSG|data|GOA|exploration"
# trailing slash: 200 or redirect to bare path
code=$(http_code "$EXCHANGE_PUBLIC/terms/")
case "$code" in
200|301|302) ok "exchange /terms/" "HTTP $code" ;;
*)
if [ "${LOCAL_STACK:-1}" = "1" ]; then warn "exchange /terms/" "HTTP $code"
else warn "exchange /terms/" "HTTP $code"
fi
;;
esac
# --- bank ---
set_group bank
if [ "${LOCAL_STACK:-1}" = "0" ]; then
check_url_soft "bank /config" 200 "$BANK_PUBLIC/config"
else
check_url "bank /config" 200 "$BANK_PUBLIC/config"
fi
code=$(http_body "$BANK_PUBLIC/config" "$tmp/bc.json")
if [ "$code" = "200" ]; then
expect_currency "bank" "$tmp/bc.json"
if json_has_alt_unit_names "$tmp/bc.json" >/tmp/alt-bank.$$ 2>&1; then
ok "bank /config alt_unit_names" "$(tr '\n' '; ' </tmp/alt-bank.$$ | sed 's/; $//')"
else
fail "bank /config alt_unit_names" "$(tr '\n' '; ' </tmp/alt-bank.$$ | sed 's/; $//')"
fi
rm -f /tmp/alt-bank.$$
# Wallet probes this; GET must be 200 (POST→405 is normal)
if [ "${LOCAL_STACK:-1}" = "1" ]; then
check_url "bank /taler-integration/config" 200 "$BANK_PUBLIC/taler-integration/config"
else
check_url_soft "bank /taler-integration/config" 200 "$BANK_PUBLIC/taler-integration/config"
fi
check_url_soft "bank /webui/" 200 "$BANK_PUBLIC/webui/"
check_url_soft "bank /intro/" 200 "$BANK_PUBLIC/intro/"
check_url_soft "bank /" 302,301,200 "$BANK_PUBLIC/"
# Auto-account: credentials + shared-pool taler://withdraw (like step 2)
aa_code=$(http_body "$BANK_PUBLIC/intro/auto-account.json" "$tmp/aa.json")
case "$aa_code" in
200)
if aa_detail=$(validate_bank_withdraw_json "$tmp/aa.json" auto 2>"$tmp/aa-val"); then
ok "bank /intro/auto-account.json" "$aa_detail"
else
fail "bank /intro/auto-account.json" "invalid withdraw/login ($(tr '\n' ' ' <"$tmp/aa-val" | sed 's/[[:space:]]*$//'))"
fi
;;
405|501|404|502|503|000)
fail "bank /intro/auto-account.json" "HTTP $aa_code (want 200; 405/501 = broken)"
;;
*)
fail "bank /intro/auto-account.json" "HTTP $aa_code want 200"
;;
esac
fi
# Bank legal docs (landing nginx via Caddy /terms* /privacy* or /intro/*)
check_legal_doc "bank /terms" "$BANK_PUBLIC/terms" "terms|GOA|exploration|bank|FADP|revDSG"
# Prefer /privacy; fall back to /intro/privacy.html for older deploys
code=$(http_code "$BANK_PUBLIC/privacy")
if [ "$code" = "200" ]; then
check_legal_doc "bank /privacy" "$BANK_PUBLIC/privacy" "privacy|FADP|revDSG|data|GOA|bank"
else
if [ "${LOCAL_STACK:-1}" = "1" ]; then
check_legal_doc "bank /privacy (or /intro/privacy.html)" \
"$BANK_PUBLIC/intro/privacy.html" "privacy|FADP|revDSG|data|GOA|bank"
# still report bare /privacy failure for local
warn "bank /privacy" "HTTP $code — prefer Caddy handle /privacy* → landing"
else
check_url_soft "bank /privacy" 200 "$BANK_PUBLIC/privacy"
fi
fi
# --- merchant ---
set_group merchant
if [ "${LOCAL_STACK:-1}" = "0" ]; then
check_url_soft "merchant /config" 200 "$MERCHANT_PUBLIC/config"
else
check_url "merchant /config" 200 "$MERCHANT_PUBLIC/config"
fi
code=$(http_body "$MERCHANT_PUBLIC/config" "$tmp/mc.json")
if [ "$code" = "200" ]; then
want="${EXPECT_CURRENCY:-}"
if python3 - "$tmp/mc.json" "$want" <<'PY'
import json,sys
try:
d=json.load(open(sys.argv[1]))
except Exception:
sys.exit(2)
want=sys.argv[2]
if not want:
sys.exit(0)
curs=list((d.get("currencies") or {}).keys())
ex=d.get("exchanges") or []
ok = want in curs or any((e.get("currency") if isinstance(e,dict) else None)==want for e in ex)
if not ok and isinstance(d.get("currency"), str):
ok = d["currency"]==want
sys.exit(0 if ok else 1)
PY
then
ok "merchant /config (${want:-currency} ok)"
else
ec=$?
if [ "$ec" = "2" ]; then
warn "merchant /config" "non-JSON body"
elif [ -n "$want" ]; then
fail "merchant /config currency" "want $want"
else
info "merchant /config" "ok"
fi
fi
# merchant-local currency maps (GOA + CHF, …)
if json_has_alt_unit_names "$tmp/mc.json" >/tmp/alt-mer.$$ 2>&1; then
ok "merchant /config currencies alt_unit_names" "$(tr '\n' '; ' </tmp/alt-mer.$$ | sed 's/; $//')"
else
fail "merchant /config currencies alt_unit_names" "$(tr '\n' '; ' </tmp/alt-mer.$$ | sed 's/; $//')"
fi
rm -f /tmp/alt-mer.$$
# Follow each exchange listed in merchant /config and require its /config alt_unit_names
check_merchant_listed_exchanges_alt_units "$tmp/mc.json"
if [ "${CHECK_LANDING:-1}" = "1" ]; then
check_url_soft "merchant /intro/" 200 "$MERCHANT_PUBLIC/intro/"
fi
check_url_soft "merchant /webui/" 200 "$MERCHANT_PUBLIC/webui/"
check_url_soft "merchant /" 302,301,200 "$MERCHANT_PUBLIC/"
fi
# Merchant legal docs
check_legal_doc "merchant /terms" "$MERCHANT_PUBLIC/terms" "terms|dual|GOA|CHF|explorational|merchant"
check_legal_doc "merchant /privacy" "$MERCHANT_PUBLIC/privacy" "privacy|FADP|revDSG|data|GOA|CHF|merchant"
code=$(http_code "$MERCHANT_PUBLIC/terms/")
case "$code" in
200|301|302) ok "merchant /terms/" "HTTP $code" ;;
*) warn "merchant /terms/" "HTTP $code (expect 302 → /terms)" ;;
esac
# ---------------------------------------------------------------------------
# Paivana paywall (local GOA stack) — public front only; pay path is e2e
# ---------------------------------------------------------------------------
if [ "${LOCAL_STACK:-1}" = "1" ] && [ "${E2E_PAIVANA:-1}" != "0" ]; then
set_group paivana
section "www · paivana paywall"
: "${PAIVANA_PUBLIC:=https://paivana.hacktivism.ch}"
PAIVANA_PUBLIC="${PAIVANA_PUBLIC%/}"
hdr=$(curl -skS -m "${TIMEOUT}" -D - -o /dev/null "${PAIVANA_PUBLIC}/" 2>/dev/null || true)
pcode=$(printf '%s' "$hdr" | awk 'BEGIN{c="000"} /^HTTP/{c=$2} END{print c}')
loc=$(printf '%s' "$hdr" | awk 'BEGIN{IGNORECASE=1} /^location:/{sub(/\r$/,""); sub(/^location:[[:space:]]*/,""); print; exit}')
case "$pcode" in
301|302|303|307|308)
if printf '%s' "$loc" | grep -qiE 'paivana|templates|well-known'; then
ok "paivana /" "HTTP $pcode → template flow"
else
ok "paivana /" "HTTP $pcode redirect"
fi
info "paivana Location" "${loc:0:140}"
;;
200)
warn "paivana /" "HTTP 200 (expected paywall redirect to template)"
;;
*)
warn "paivana /" "HTTP ${pcode:-000}${PAIVANA_PUBLIC}/ (e2e pay may still work via template)"
;;
esac
fi
# ---------------------------------------------------------------------------
# Landing pages: every HTTPS link exposed on bank / merchant / exchange intros
# + required static assets + bank withdraw mint (taler://withdraw only)
# Skipped when CHECK_LANDING=0 (e.g. taler-ops.ch — no GOA-style landings).
# ---------------------------------------------------------------------------
if [ "${CHECK_LANDING:-1}" != "1" ]; then
section "www · landing pages"
info "landing checks" "skipped (CHECK_LANDING=0 · stack has no public /intro landings)"
summary
exit 0
fi
set_group landing
section "www · landing exposed links · bank / merchant / exchange"
# Probe one URL: print code to stdout (200 after following redirects counts as 200).
# Sets _landing_code. Exit 0 if OK (200 or redirect→200), 1 otherwise.
_landing_probe() {
local url="$1"
local code
code=$(http_code "$url")
case "$code" in
200) _landing_code=200; return 0 ;;
301|302|303|307|308)
code=$(curl -skS --max-redirs 5 -L -m "${TIMEOUT}" -o /dev/null -w '%{http_code}' "$url" 2>/dev/null || echo 000)
_landing_code="$code"
[ "$code" = "200" ] && return 0
return 1
;;
*)
_landing_code="$code"
return 1
;;
esac
}
# Known-good landing static paths — one report line via caller aggregate, or
# soft=1 single warn. Returns 0 if ok.
check_landing_asset() {
local label="$1" url="$2" soft="${3:-0}"
if _landing_probe "$url"; then
return 0
fi
if [ "$soft" = "1" ]; then
warn "$label" "HTTP ${_landing_code:-?}$url"
else
fail "$label" "HTTP ${_landing_code:-?}$url"
fi
return 1
}
# Soft external: never ERROR; used only for failures in aggregated external probe.
check_external_soft() {
local label="$1" url="$2"
local code
code=$(curl -skS --max-redirs 5 -L -m "${TIMEOUT}" -o /dev/null -w '%{http_code}' "$url" 2>/dev/null || echo 000)
case "$code" in
200|204|301|302|303|307|308) return 0 ;;
*) warn "$label" "HTTP $code · $url"; return 1 ;;
esac
}
# Parse one landing HTML: collect absolute https + root-relative href/src;
# resolve against base; classify own-stack vs external.
# Writes lists: $1.own $1.ext (one URL per line)
extract_landing_urls() {
local base="$1" html="$2" out_prefix="$3"
python3 - "$base" "$html" "$out_prefix" <<'PY'
import re, sys
from urllib.parse import urljoin, urlparse
base, html_path, out = sys.argv[1], sys.argv[2], sys.argv[3]
html = open(html_path, encoding="utf-8", errors="replace").read()
base = base.rstrip("/") + "/"
parsed_base = urlparse(base)
# Hard-check only the three public Taler hosts for this stack (not git.* etc.)
own_hosts = {
(parsed_base.hostname or "").lower(),
"bank.hacktivism.ch",
"exchange.hacktivism.ch",
"taler.hacktivism.ch",
}
# include configured public hosts when domain differs (demo / ops)
for envu in (
__import__("os").environ.get("BANK_PUBLIC", ""),
__import__("os").environ.get("EXCHANGE_PUBLIC", ""),
__import__("os").environ.get("MERCHANT_PUBLIC", ""),
):
h = urlparse(envu).hostname if envu else None
if h:
own_hosts.add(h.lower())
raw = set()
for m in re.finditer(
r'''(?:href|src|content)=["']([^"'#]+)["']''', html, re.I
):
raw.add(m.group(1).strip())
# bare absolute URLs in scripts (fetch, template strings)
for m in re.finditer(r'''https://[^\s"'<>\\]+''', html):
u = m.group(0).rstrip("\\).,;'\"")
# strip trailing punctuation leftovers
while u and u[-1] in ".,);]}\"'":
u = u[:-1]
if u.startswith("https://"):
raw.add(u)
own, ext = set(), set()
skip_prefix = ("data:", "javascript:", "mailto:", "taler://", "blob:")
skip_exact = {"website", "summary_large_image", "image/png", "en_US"}
for r in raw:
if not r or r in skip_exact:
continue
if r.startswith(skip_prefix):
continue
# meta content noise
if re.fullmatch(r"\d+", r) or r.startswith("width="):
continue
if " " in r and not r.startswith("http"):
continue
if r.startswith("//"):
absu = "https:" + r
elif r.startswith("http://") or r.startswith("https://"):
absu = r
elif r.startswith("/"):
absu = urljoin(base, r)
else:
# relative asset
if "/" in r or r.endswith((".js", ".css", ".png", ".svg", ".html", ".json", ".uri")):
absu = urljoin(base + "intro/", r)
else:
continue
# drop query-only noise / anchors already stripped
p = urlparse(absu)
if p.scheme not in ("http", "https"):
continue
# normalize: drop fragment
absu = absu.split("#", 1)[0]
host = (p.hostname or "").lower()
# og image query ok
if host in own_hosts:
own.add(absu)
else:
ext.add(absu)
open(out + ".own", "w").write("\n".join(sorted(own)) + ("\n" if own else ""))
open(out + ".ext", "w").write("\n".join(sorted(ext)) + ("\n" if ext else ""))
print(f"own={len(own)} ext={len(ext)}")
PY
}
check_one_landing() {
local name="$1" base="$2"
local html="$tmp/landing-${name}.html"
local pref="$tmp/urls-${name}"
local code n own_n ext_n
local a_ok=0 a_fail=0 a_soft=0
local own_ok=0 own_fail=0
local ext_ok=0 ext_fail=0
local fail_sample="" soft_sample=""
code=$(http_body "${base}/intro/" "$html")
if [ "$code" != "200" ]; then
if [ "${LOCAL_STACK:-1}" = "1" ]; then
fail "landing ${name}" "/intro/ HTTP $code — skip assets/links"
else
warn "landing ${name}" "/intro/ HTTP $code — skip assets/links"
fi
return
fi
# Required static assets (hard: qrcode + og; soft: qr-logo)
for url in \
"${base}/intro/qrcode.min.js" \
"${base}/intro/og-goa-shop.png"
do
if _landing_probe "$url"; then
a_ok=$((a_ok + 1))
else
a_fail=$((a_fail + 1))
fail_sample="${fail_sample}${fail_sample:+; }HTTP ${_landing_code} $url"
fi
done
if _landing_probe "${base}/intro/qr-logo.png"; then
a_ok=$((a_ok + 1))
else
a_soft=$((a_soft + 1))
soft_sample="${soft_sample}${soft_sample:+; }HTTP ${_landing_code} qr-logo.png"
fi
n=$(extract_landing_urls "$base" "$html" "$pref" 2>/dev/null || echo "own=0 ext=0")
own_n=0
ext_n=0
[ -f "${pref}.own" ] && own_n=$(grep -c . "${pref}.own" 2>/dev/null || echo 0)
[ -f "${pref}.ext" ] && ext_n=$(grep -c . "${pref}.ext" 2>/dev/null || echo 0)
# strip newlines from grep -c edge cases
own_n=${own_n//[^0-9]/}
ext_n=${ext_n//[^0-9]/}
own_n=${own_n:-0}
ext_n=${ext_n:-0}
# Probe own-stack URLs — count only; list failures
if [ -f "${pref}.own" ]; then
while IFS= read -r u; do
[ -n "$u" ] || continue
if _landing_probe "$u"; then
own_ok=$((own_ok + 1))
else
own_fail=$((own_fail + 1))
# keep a few samples (max ~3)
if [ "$own_fail" -le 3 ]; then
fail_sample="${fail_sample}${fail_sample:+; }own HTTP ${_landing_code} $u"
fi
fi
done < "${pref}.own"
fi
# External: soft counts
if [ -f "${pref}.ext" ]; then
while IFS= read -r u; do
[ -n "$u" ] || continue
code=$(curl -skS --max-redirs 5 -L -m "${TIMEOUT}" -o /dev/null -w '%{http_code}' "$u" 2>/dev/null || echo 000)
case "$code" in
200|204|301|302|303|307|308) ext_ok=$((ext_ok + 1)) ;;
*)
ext_fail=$((ext_fail + 1))
if [ "$ext_fail" -le 3 ]; then
soft_sample="${soft_sample}${soft_sample:+; }ext HTTP $code $u"
fi
;;
esac
done < "${pref}.ext"
fi
# One primary line per landing
local detail
detail="/intro $(wc -c <"$html" | tr -d ' ')B · assets ${a_ok}/$((a_ok + a_fail + a_soft)) · own-links ${own_ok}/${own_n} · external ${ext_ok}/${ext_n}"
if [ "$a_fail" -gt 0 ] || [ "$own_fail" -gt 0 ] || [ "${own_n:-0}" -lt 1 ]; then
if [ "${LOCAL_STACK:-1}" = "1" ]; then
fail "landing ${name}" "$detail${fail_sample:+ · $fail_sample}"
else
warn "landing ${name}" "$detail${fail_sample:+ · $fail_sample}"
fi
else
ok "landing ${name}" "$detail"
fi
if [ "$a_soft" -gt 0 ] || [ "$ext_fail" -gt 0 ]; then
warn "landing ${name} soft" "${soft_sample:-soft issues}"
fi
}
check_one_landing "bank" "$BANK_PUBLIC"
check_one_landing "merchant" "$MERCHANT_PUBLIC"
check_one_landing "exchange" "$EXCHANGE_PUBLIC"
# Cross-links: one line
if [ "${LOCAL_STACK:-1}" = "1" ]; then
_cx_ok=0
_cx_fail=0
_cx_detail=""
for pair in \
"bank→merchant|$MERCHANT_PUBLIC/intro/" \
"bank→exchange|$EXCHANGE_PUBLIC/intro/" \
"merchant→bank|$BANK_PUBLIC/intro/" \
"exchange→bank|$BANK_PUBLIC/intro/"
do
_cx_name="${pair%%|*}"
_cx_url="${pair#*|}"
if _landing_probe "$_cx_url"; then
_cx_ok=$((_cx_ok + 1))
else
_cx_fail=$((_cx_fail + 1))
_cx_detail="${_cx_detail}${_cx_detail:+; }${_cx_name} HTTP ${_landing_code}"
fi
done
if [ "$_cx_fail" -eq 0 ]; then
ok "landing cross-links" "${_cx_ok}/4 intros reachable"
else
fail "landing cross-links" "${_cx_ok}/4 ok · ${_cx_detail}"
fi
fi
# Bank withdraw mint + shop assets — compact
if [ "${LOCAL_STACK:-1}" = "1" ] || [ -n "${BANK_PUBLIC:-}" ]; then
_ba_ok=0
_ba_soft=0
_ba_msg=""
if _landing_probe "$BANK_PUBLIC/intro/shop-pay.js"; then _ba_ok=$((_ba_ok + 1)); else _ba_soft=$((_ba_soft + 1)); _ba_msg="${_ba_msg}shop-pay.js; "; fi
if _landing_probe "$BANK_PUBLIC/intro/shop-pay.css"; then _ba_ok=$((_ba_ok + 1)); else _ba_soft=$((_ba_soft + 1)); _ba_msg="${_ba_msg}shop-pay.css; "; fi
dw_code=$(http_body "$BANK_PUBLIC/intro/demo-withdraw.json" "$tmp/dw.json")
case "$dw_code" in
200)
if dw_out=$(validate_bank_withdraw_json "$tmp/dw.json" demo 2>"$tmp/dw-val"); then
dw_detail=${dw_out%%$'\t'*}
wid=${dw_out#*$'\t'}
[ "$wid" = "$dw_out" ] && wid=
ok "bank /intro/demo-withdraw.json" "$dw_detail"
if [ -n "$wid" ]; then
if _landing_probe "$BANK_PUBLIC/taler-integration/withdrawal-operation/${wid}"; then
_ba_ok=$((_ba_ok + 1))
else
fail "landing bank withdraw-op" "HTTP ${_landing_code} · id=$wid"
fi
fi
ok "landing bank withdraw/shop" "demo-withdraw + shop assets ok (${_ba_ok} checks)"
else
fail "landing bank demo-withdraw" "invalid taler://withdraw shape"
fail "bank /intro/demo-withdraw.json" "invalid taler://withdraw ($(tr '\n' ' ' <"$tmp/dw-val" | sed 's/[[:space:]]*$//'))"
fi
;;
405|501|404|502|503|000)
fail "landing bank demo-withdraw" "HTTP $dw_code (want 200)"
;;
*)
if [ "${LOCAL_STACK:-1}" = "1" ]; then
fail "landing bank demo-withdraw" "HTTP $dw_code want 200"
else
warn "landing bank demo-withdraw" "HTTP $dw_code"
fi
;;
esac
if [ "$_ba_soft" -gt 0 ]; then
warn "landing bank shop assets" "${_ba_msg}soft-missing"
fi
fi
# Merchant shop assets — one soft line
_ma=0
_landing_probe "$MERCHANT_PUBLIC/intro/shop-pay.js" && _ma=$((_ma + 1))
_landing_probe "$MERCHANT_PUBLIC/intro/shop-pay.css" && _ma=$((_ma + 1))
if [ "$_ma" -eq 2 ]; then
ok "landing merchant shop assets" "shop-pay.js + .css"
else
warn "landing merchant shop assets" "${_ma}/2 present (soft)"
fi
summary