taler-monitoring/check_urls.sh
Hernâni Marques d420043863
fix 1.23.4: normalize taler:// :443/:80 + mon scan all landings
Shared lib.sh normalize_taler_uri for ladder/e2e/pay. urls phase checks
public bank/exchange/merchant intros and bank mint JSON for residual
default ports (ERROR on GOA/local). Unit test included.
2026-07-19 15:38:25 +02:00

1691 lines
61 KiB
Bash
Executable file
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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
}
# Pre-launch remote stack: local=0 and no expected currency (e.g. prod LFP not live yet).
# Missing endpoints → INFO, not WARN (infra not supposed to serve yet).
is_prelaunch_stack() {
[ "${LOCAL_STACK:-1}" = "0" ] && [ -z "${EXPECT_CURRENCY:-}" ]
}
# Soft check: OK on expect, WARN on foreign stack if down, ERROR on local stack.
# Always treats 301/302/303/307/308→200 (follow) as success when 200 is expected.
# Pre-launch (no currency): INFO instead of WARN for missing endpoints.
check_url_soft() {
local label="$1" expect="$2" url="$3"
local code
code=$(http_code "$url")
case ",$expect," in
*",$code,"*) ok "$label $url" ;;
*)
# Follow redirects (308 Permanent Redirect is common for trailing slash)
case "$code" in
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)
case ",$expect," in
*",$code,"*) ok "$label $url" "via redirect → HTTP $code"; return ;;
esac
;;
esac
if [ "${LOCAL_STACK:-1}" = "0" ]; then
if is_prelaunch_stack; then
info "$label $url" "HTTP $code — pre-launch / not serving yet"
else
warn "$label $url" "got $code (optional on remote domain)"
fi
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
if is_prelaunch_stack; then
info "$label" "HTTP $code — pre-launch / not serving yet · $url"
else
warn "$label" "HTTP $code$url"
fi
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; hard on local stack, soft on remote) --- www.exchange-NN
set_group exchange
if [ "${LOCAL_STACK:-1}" = "1" ]; then
check_url "exchange /config" 200 "$EXCHANGE_PUBLIC/config"
else
check_url_soft "exchange /config" 200 "$EXCHANGE_PUBLIC/config"
fi
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
if [ "${LOCAL_STACK:-1}" = "1" ]; then
check_url "exchange /keys" 200 "$EXCHANGE_PUBLIC/keys"
else
check_url_soft "exchange /keys" 200 "$EXCHANGE_PUBLIC/keys"
fi
# /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 → suite fail (hard).
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"
elif is_prelaunch_stack; then
info "$label" "HTTP $code · ${ms} ms · pre-launch · $url"
else
warn "$label" "HTTP $code want $expect · ${ms} ms · $url"
fi
;;
esac
}
# Bank first (wallet-critical paths before UI chrome) — skip when CHECK_BANK=0 (e.g. mytops stage mon)
if [ "${CHECK_BANK:-1}" = "1" ]; then
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
else
info "perf bank" "skipped (CHECK_BANK=0)"
fi
# 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 stats.json (public outside-in) — freshness + display fields.
# Timestamps (required for external measurement):
# generated_at_unix — epoch seconds (preferred)
# generated_at — ISO-8601
# generated_at_human — footer / “updated” UI string
# Bank collector always sets all three; exchange/merchant scripts set unix too.
# Env:
# STATS_STALE_SECS warn if age > this (default 900 = 15m; timer often 515m)
# STATS_FAIL_SECS hard fail if age > this (default 3600); 0 = never hard-fail on age
# ---------------------------------------------------------------------------
: "${STATS_STALE_SECS:=900}"
: "${STATS_FAIL_SECS:=3600}"
_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
}
# Validate one stats.json for monitoring display + outside age measurement.
# Prints lines: OK|WARN|ERR|STALE|FAIL <detail>
# Sets $tmp/stats-meta-$name.txt with gen_unix|source for shared-feed compare.
check_landing_stats_json() {
local name="$1" base="$2"
local f="$tmp/stats-${name}.json"
local line hard_missing=0
if ! _fetch_landing_stats_json "$name" "$base"; then
if [ "${CHECK_LANDING:-1}" = "1" ] || [ "${LOCAL_STACK:-0}" = "1" ]; then
fail "stats ${name}" "HTTP/body missing · ${base}/intro/stats.json"
else
warn "stats ${name}" "stats.json not available"
fi
return 1
fi
ok "stats ${name} reachable" "${base}/intro/stats.json"
# Multi-line verdict from python (STATUS detail)
while IFS= read -r line; do
case "$line" in
META\ *)
printf '%s\n' "${line#META }" >"$tmp/stats-meta-${name}.txt"
;;
OK\ *)
ok "stats ${name}" "${line#OK }"
;;
WARN\ *)
warn "stats ${name}" "${line#WARN }"
;;
STALE\ *)
warn "stats ${name} freshness" "stale · ${line#STALE }"
;;
FAIL\ *)
fail "stats ${name}" "${line#FAIL }"
hard_missing=1
;;
ERR\ *)
if [ "${LOCAL_STACK:-0}" = "1" ] || [ "${CHECK_LANDING:-1}" = "1" ]; then
fail "stats ${name}" "${line#ERR }"
else
warn "stats ${name}" "${line#ERR }"
fi
hard_missing=1
;;
*)
[ -n "$line" ] && warn "stats ${name}" "unparsed · $line"
;;
esac
done < <(python3 - "$f" "$name" "${STATS_STALE_SECS}" "${STATS_FAIL_SECS}" <<'PY' 2>/dev/null || echo "ERR python check failed"
import json, sys, time, re
from datetime import datetime, timezone
path, role, stale_s, fail_s = sys.argv[1], sys.argv[2], int(sys.argv[3]), int(sys.argv[4])
now = int(time.time())
def out(kind, msg):
print("%s %s" % (kind, msg))
try:
d = json.load(open(path))
except Exception as e:
out("ERR", "JSON parse: %s" % str(e)[:100])
sys.exit(0)
if not isinstance(d, dict):
out("ERR", "body is not a JSON object")
sys.exit(0)
if d.get("ok") is False:
out("ERR", "ok=false · %s" % str(d.get("error") or d.get("hint") or "")[:80])
sys.exit(0)
# --- timestamps (outside-in age measurement) ---
gu = d.get("generated_at_unix")
giso = d.get("generated_at") or d.get("generated_at_iso")
ghum = d.get("generated_at_human")
age = None
src_u = None
if gu is not None and str(gu).strip() != "":
try:
src_u = int(float(gu))
# tolerate ms accidental timestamps
if src_u > 10_000_000_000:
src_u //= 1000
age = now - src_u
except Exception:
out("ERR", "generated_at_unix not numeric: %r" % gu)
src_u = None
elif giso:
# parse ISO when unix missing (legacy exchange/merchant before unix field)
try:
s = str(giso).strip()
if s.endswith("Z"):
s = s[:-1] + "+00:00"
# allow +0200 without colon
s = re.sub(r"([+-]\d{2})(\d{2})$", r"\1:\2", s)
dt = datetime.fromisoformat(s)
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
src_u = int(dt.timestamp())
age = now - src_u
out("WARN", "no generated_at_unix — derived age from generated_at ISO (deploy collector with unix)")
except Exception as e:
out("ERR", "cannot parse generated_at=%r (%s)" % (giso, e))
else:
out("ERR", "missing generated_at_unix and generated_at — cannot measure freshness outside")
if not ghum and not giso:
out("ERR", "missing generated_at_human/generated_at — UI cannot show “updated” time")
elif ghum:
out("OK", "timestamp human=%s" % ghum)
else:
out("OK", "timestamp iso=%s" % giso)
if src_u is not None:
out("META", "%s|%s|%s" % (src_u, d.get("source") or "", d.get("currency") or ""))
if age is not None and age < -120:
out("WARN", "clock skew · generated_at_unix in the future by %ss" % (-age))
if age is not None:
out("OK", "generated_at_unix=%s age=%ss (now=%s)" % (src_u, age, now))
if fail_s > 0 and age > fail_s:
out("FAIL", "age=%ss > STATS_FAIL_SECS=%s · collector not updating" % (age, fail_s))
elif age > stale_s:
out("STALE", "age=%ss > STATS_STALE_SECS=%s · timer lag or stuck publish" % (age, stale_s))
# --- display fields for landings / monitoring ---
src = str(d.get("source") or "")
# Bank-shaped (incl. stage shared feed on all three landings)
bankish = (
role == "bank"
or "collect_bank_stats" in src
or "shared" in src
or isinstance(d.get("withdraws"), dict)
or isinstance(d.get("bank_accounts"), dict)
)
if bankish:
missing = []
if not isinstance(d.get("withdraws"), dict):
missing.append("withdraws")
ba = d.get("bank_accounts")
wl = d.get("wallets")
if not isinstance(ba, dict) and not isinstance(wl, dict):
missing.append("bank_accounts|wallets")
if d.get("currency") in (None, ""):
missing.append("currency")
if missing:
out("ERR", "bank-shaped display missing: %s" % ",".join(missing))
else:
w = d.get("withdraws") or {}
ba = ba if isinstance(ba, dict) else {}
n_acc = ba.get("total") if ba.get("total") is not None else ba.get("users")
out(
"OK",
"display bank-shape currency=%s accounts=%s withdraws.count=%s source=%s"
% (d.get("currency"), n_acc, w.get("count"), (src or "?")[:48]),
)
elif src == "exchange-db" or (role == "exchange" and "reserves" in d):
need = []
for k in ("reserves", "wire_in_count", "known_coins"):
if k not in d and k.replace("known_coins", "coins_live") not in d:
if k == "known_coins" and ("coins_live" in d or "coins_remaining_amount" in d):
continue
need.append(k)
if need:
out("WARN", "exchange display keys missing: %s" % ",".join(need))
else:
out(
"OK",
"display exchange-db reserves=%s wire_in=%s coins=%s"
% (d.get("reserves"), d.get("wire_in_count"), d.get("known_coins") or d.get("coins_live")),
)
elif src == "merchant-db" or (role == "merchant" and "instances" in d):
if "instances" not in d and "orders" not in d:
out("ERR", "merchant display missing instances/orders")
else:
out(
"OK",
"display merchant-db instances=%s orders=%s paid=%s"
% (d.get("instances"), d.get("orders"), d.get("paid")),
)
else:
out("WARN", "unknown stats schema role=%s source=%s keys=%s" % (role, src[:40], ",".join(list(d.keys())[:8])))
# performance block optional (stage shared bank feed may omit heavy probes)
p = d.get("performance")
if isinstance(p, dict) and p:
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 "—"
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=%s" % (k.replace("_ms", ""), p[k]))
lat = " ".join(bits) if bits else "latency=—"
out("OK", "performance loadavg=%s RSS=%s %s" % (load, rss, lat))
else:
out("OK", "performance block optional/absent")
PY
)
return 0
}
if [ "${CHECK_LANDING:-1}" = "1" ] || [ "${LOCAL_STACK:-0}" = "1" ]; then
set_group stats
section "www · landing stats.json (freshness + display fields · outside-in)"
# Avoid the bare word ERROR in INFO text (older HTML converters treated it as a check failure / first-error).
info "stats policy" "STALE≥${STATS_STALE_SECS}s→WARN · FAIL≥${STATS_FAIL_SECS}s→suite-fail (0=off) · need generated_at_unix"
check_landing_stats_json "bank" "$BANK_PUBLIC" || true
check_landing_stats_json "exchange" "$EXCHANGE_PUBLIC" || true
check_landing_stats_json "merchant" "$MERCHANT_PUBLIC" || true
# Shared feed: stage-style publish copies same bank stats to all three URLs
if [ -f "$tmp/stats-meta-bank.txt" ] && [ -f "$tmp/stats-meta-exchange.txt" ] && [ -f "$tmp/stats-meta-merchant.txt" ]; then
shared_line=$(python3 - "$tmp/stats-meta-bank.txt" "$tmp/stats-meta-exchange.txt" "$tmp/stats-meta-merchant.txt" <<'PY' 2>/dev/null || true
import sys
def parse(p):
try:
t = open(p).read().strip().split("|", 2)
return t[0], (t[1] if len(t) > 1 else ""), (t[2] if len(t) > 2 else "")
except Exception:
return "", "", ""
b, bs, bc = parse(sys.argv[1])
e, es, ec = parse(sys.argv[2])
m, ms, mc = parse(sys.argv[3])
if not b or not e or not m:
print("SKIP incomplete meta")
elif b == e == m:
print("SHARED gen_unix=%s bank_src=%s" % (b, (bs or "?")[:50]))
else:
# Shared feed only when all three claim the same bank-collector source
# (e.g. stage TESTPAYSAN "… shared"). GOA uses independent exchange-db /
# merchant-db collectors — different gen_unix is expected.
def bankish(s):
s = (s or "").lower()
return "shared" in s or "collect_bank_stats" in s
if bankish(bs) and bankish(es) and bankish(ms):
print("DRIFT bank=%s exchange=%s merchant=%s (expected equal for shared feed)" % (b, e, m))
else:
print("INDEPENDENT bank=%s exchange=%s merchant=%s" % (b, e, m))
PY
)
case "$shared_line" in
SHARED\ *)
ok "stats shared feed" "${shared_line#SHARED }"
;;
DRIFT\ *)
warn "stats shared feed" "timestamps differ · ${shared_line#DRIFT }"
;;
INDEPENDENT\ *)
info "stats feeds" "independent collectors · ${shared_line#INDEPENDENT }"
;;
*)
info "stats feeds" "${shared_line:-n/a}"
;;
esac
fi
info "stats note" "public GET /intro/stats.json; measure age via generated_at_unix vs wall clock"
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|303|307|308) ok "exchange /terms/" "HTTP $code" ;;
*)
if is_prelaunch_stack; then
info "exchange /terms/" "HTTP $code — pre-launch"
else
warn "exchange /terms/" "HTTP $code"
fi
;;
esac
# --- bank ---
set_group bank
if [ "${CHECK_BANK:-1}" != "1" ]; then
info "bank" "skipped (CHECK_BANK=0 — not in scope for this mon job)"
code=""
elif [ "${LOCAL_STACK:-1}" = "0" ]; then
check_url_soft "bank /config" 200 "$BANK_PUBLIC/config"
code=$(http_body "$BANK_PUBLIC/config" "$tmp/bc.json")
else
check_url "bank /config" 200 "$BANK_PUBLIC/config"
code=$(http_body "$BANK_PUBLIC/config" "$tmp/bc.json")
fi
if [ "${CHECK_BANK:-1}" = "1" ] && [ "$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: GOA shared-pool mint (hacktivism). Not used on TESTPAYSAN stage.
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
;;
404|405|501)
if [ "${EXPECT_CURRENCY:-}" = "GOA" ] || [ "${LOCAL_STACK:-0}" = "1" ]; then
fail "bank /intro/auto-account.json" "HTTP $aa_code (want 200; 405/501 = broken)"
else
info "bank /intro/auto-account.json" "HTTP $aa_code — skip (not a GOA auto-account stack)"
fi
;;
502|503|000)
fail "bank /intro/auto-account.json" "HTTP $aa_code want 200"
;;
*)
if [ "${EXPECT_CURRENCY:-}" = "GOA" ] || [ "${LOCAL_STACK:-0}" = "1" ]; then
fail "bank /intro/auto-account.json" "HTTP $aa_code want 200"
else
warn "bank /intro/auto-account.json" "HTTP $aa_code (optional off-GOA)"
fi
;;
esac
fi
# Bank legal docs (landing nginx via Caddy /terms* /privacy* or /intro/*)
if [ "${CHECK_BANK:-1}" = "1" ]; then
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
fi
# --- merchant ---
set_group merchant
# MERCHANT_REQUIRED=1: hard ERROR if /config fails even when LOCAL_STACK=0 (mytops stage)
if [ "${LOCAL_STACK:-1}" = "0" ] && [ "${MERCHANT_REQUIRED:-0}" != "1" ]; 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/"
# --- merchant SPA fingerprints (selfbuild / package overlay) ---
# version.txt = SPA package version (e.g. 1.6.11)
# version-overlay.txt = optional deploy stamp (selfbuild-v1.6.11-0e4807845)
# Env:
# EXPECT_WEBUI_VERSION=1.6.11 → fail if version.txt ≠
# EXPECT_WEBUI_OVERLAY=substr → fail if overlay missing that substr
# WEBUI_OVERLAY_DENY=regex → fail if overlay matches (e.g. master-11340)
# CHECK_WEBUI_SPA=0 → skip this block
if [ "${CHECK_WEBUI_SPA:-1}" = "1" ]; then
set_group webui
section "www · merchant SPA /webui/ fingerprints"
vcode=$(http_body "$MERCHANT_PUBLIC/webui/version.txt" "$tmp/webui-version.txt")
if [ "$vcode" = "200" ] && [ -s "$tmp/webui-version.txt" ]; then
got_ver=$(tr -d ' \n\r\t' <"$tmp/webui-version.txt")
ok "merchant /webui/version.txt" "HTTP 200 · ${got_ver}"
if [ -n "${EXPECT_WEBUI_VERSION:-}" ]; then
if [ "$got_ver" = "${EXPECT_WEBUI_VERSION}" ]; then
ok "merchant webui version pin" "EXPECT_WEBUI_VERSION=${EXPECT_WEBUI_VERSION}"
else
fail "merchant webui version pin" "got ${got_ver} want ${EXPECT_WEBUI_VERSION}"
fi
else
info "merchant webui version" "${got_ver} (set EXPECT_WEBUI_VERSION= to pin)"
fi
else
if [ "${LOCAL_STACK:-1}" = "1" ]; then
fail "merchant /webui/version.txt" "HTTP ${vcode:-000} (SPA missing version stamp)"
else
warn "merchant /webui/version.txt" "HTTP ${vcode:-000}"
fi
fi
ocode=$(http_body "$MERCHANT_PUBLIC/webui/version-overlay.txt" "$tmp/webui-overlay.txt")
if [ "$ocode" = "200" ] && [ -s "$tmp/webui-overlay.txt" ]; then
got_ov=$(tr -d '\n\r' <"$tmp/webui-overlay.txt" | head -c 200)
ok "merchant /webui/version-overlay.txt" "HTTP 200 · ${got_ov}"
if [ -n "${EXPECT_WEBUI_OVERLAY:-}" ]; then
case "$got_ov" in
*"${EXPECT_WEBUI_OVERLAY}"*)
ok "merchant webui overlay pin" "contains ${EXPECT_WEBUI_OVERLAY}"
;;
*)
fail "merchant webui overlay pin" "got «${got_ov}» want substr ${EXPECT_WEBUI_OVERLAY}"
;;
esac
fi
if [ -n "${WEBUI_OVERLAY_DENY:-}" ]; then
if printf '%s' "$got_ov" | grep -qE -- "${WEBUI_OVERLAY_DENY}"; then
fail "merchant webui overlay deny" "«${got_ov}» matches WEBUI_OVERLAY_DENY=${WEBUI_OVERLAY_DENY}"
else
ok "merchant webui overlay deny" "no match for ${WEBUI_OVERLAY_DENY}"
fi
fi
else
# Overlay is optional for pure package installs; warn on local selfbuild stacks
if [ "${LOCAL_STACK:-1}" = "1" ]; then
warn "merchant /webui/version-overlay.txt" "HTTP ${ocode:-000} (optional; selfbuild writes it)"
else
info "merchant /webui/version-overlay.txt" "HTTP ${ocode:-000} (optional)"
fi
fi
# Core SPA assets (paths used by selfbuild dist/prod).
# index.js is large — longer timeout; normalize curl "200000" (200 + ||000).
_webui_asset_code() {
local url="$1" tmo="${2:-${TIMEOUT}}"
local raw
raw=$(curl -skS --max-redirs 0 -m "$tmo" -o /dev/null -w '%{http_code}' "$url" 2>/dev/null || true)
# take first 3 digits only (avoid 200000 glue when curl exits non-zero after writing code)
printf '%s' "$raw" | tr -cd '0-9' | head -c 3
[ -n "$(printf '%s' "$raw" | tr -cd '0-9')" ] || printf '000'
}
for asset in index.html index.js; do
tmo="${TIMEOUT}"
[ "$asset" = "index.js" ] && tmo="${WEBUI_INDEX_JS_TIMEOUT:-60}"
acode=$(_webui_asset_code "$MERCHANT_PUBLIC/webui/${asset}" "$tmo")
case "$acode" in
200) ok "merchant /webui/${asset}" "HTTP 200" ;;
*)
if [ "${LOCAL_STACK:-1}" = "1" ]; then
fail "merchant /webui/${asset}" "HTTP ${acode:-000}"
else
warn "merchant /webui/${asset}" "HTTP ${acode:-000}"
fi
;;
esac
done
# return group for any later merchant checks in this block
set_group merchant
fi
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|303|307|308) ok "merchant /terms/" "HTTP $code" ;;
*)
if is_prelaunch_stack; then
info "merchant /terms/" "HTTP $code — pre-launch"
else
warn "merchant /terms/" "HTTP $code (expect 302 → /terms)"
fi
;;
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}"
;;
402)
# Paywall may answer Payment Required with body (healthy, not 502)
ok "paivana /" "HTTP 402 Payment Required (paywall up)"
;;
200)
warn "paivana /" "HTTP 200 (expected paywall redirect/402 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: qrcode.min.js. og-goa-shop.png only on GOA/local.
if _landing_probe "${base}/intro/qrcode.min.js"; then
a_ok=$((a_ok + 1))
else
a_fail=$((a_fail + 1))
fail_sample="${fail_sample}${fail_sample:+; }HTTP ${_landing_code} ${base}/intro/qrcode.min.js"
fi
if [ "${EXPECT_CURRENCY:-}" = "GOA" ] || [ "${LOCAL_STACK:-0}" = "1" ]; then
if _landing_probe "${base}/intro/og-goa-shop.png"; then
a_ok=$((a_ok + 1))
else
a_fail=$((a_fail + 1))
fail_sample="${fail_sample}${fail_sample:+; }HTTP ${_landing_code} ${base}/intro/og-goa-shop.png"
fi
fi
# qr-logo: hard on GOA (wallet QR frame); soft elsewhere if missing
if _landing_probe "${base}/intro/qr-logo.png"; then
a_ok=$((a_ok + 1))
else
if [ "${EXPECT_CURRENCY:-}" = "GOA" ] || [ "${LOCAL_STACK:-0}" = "1" ]; then
a_soft=$((a_soft + 1))
soft_sample="${soft_sample}${soft_sample:+; }HTTP ${_landing_code} qr-logo.png"
fi
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 (stores can be slow — longer timeout + browser UA)
if [ -f "${pref}.ext" ]; then
local _ext_to="${EXT_TIMEOUT:-25}"
while IFS= read -r u; do
[ -n "$u" ] || continue
code=$(curl -skS --max-redirs 5 -L -m "${_ext_to}" \
-A "Mozilla/5.0 (compatible; taler-monitoring/1.0)" \
-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=""
# GOA landings ship shop-pay.*; stage TESTPAYSAN uses monnaies /assets/shop-ui.js
if [ "${EXPECT_CURRENCY:-}" = "GOA" ] || [ "${LOCAL_STACK:-0}" = "1" ]; then
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
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
;;
404|405|501)
# GOA bank landing mints demo-withdraw.json; stage TESTPAYSAN uses bank withdraw/wallet guide only
if [ "${EXPECT_CURRENCY:-}" = "GOA" ] || [ "${LOCAL_STACK:-0}" = "1" ]; then
fail "landing bank demo-withdraw" "HTTP $dw_code (want 200)"
else
info "landing bank demo-withdraw" "HTTP $dw_code — skip (not a GOA demo-withdraw stack)"
fi
;;
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
# ---------------------------------------------------------------------------
# taler:// URIs must not carry default ports :443 / :80 (wallet / QR rule)
# Scans public landings on this stack (bank + exchange + merchant intros,
# bank mint JSON). Fail hard on GOA/local; soft warn elsewhere.
# ---------------------------------------------------------------------------
set_group landings
section "www · taler:// default ports (:443/:80 must be absent)"
_port_scan_fail=0
_port_scan_ok=0
_port_scan_one() {
local label="$1" url="$2"
local f code n detail
f="$tmp/portscan-$$.body"
code=$(http_body "$url" "$f")
if [ "$code" != "200" ]; then
if [ "${LOCAL_STACK:-1}" = "1" ] || [ "${EXPECT_CURRENCY:-}" = "GOA" ]; then
# mint JSON is hard; HTML intros soft if missing
case "$url" in
*demo-withdraw.json|*auto-account.json)
fail "taler-uri ports · $label" "HTTP $code · $url"
_port_scan_fail=$((_port_scan_fail + 1))
;;
*)
warn "taler-uri ports · $label" "HTTP $code · skip · $url"
;;
esac
else
info "taler-uri ports · $label" "HTTP $code · skip"
fi
rm -f "$f"
return 0
fi
detail=$(python3 - "$f" <<'PY' 2>/dev/null || true
import re, sys, json
from pathlib import Path
raw = Path(sys.argv[1]).read_text(errors="replace")
# collect taler:// from JSON fields and raw HTML/JS
uris = set(re.findall(r"taler://[A-Za-z0-9._~:/?#\[\]@!$&'()*+,;=%-]+", raw))
# JSON-escaped sequences
uris.update(re.findall(r"taler:\\/\\/[A-Za-z0-9._~:/?#\[\]@!$&'()*+,;=%\\-]+", raw))
uris = {u.replace("\\/", "/") for u in uris}
bad = []
pat = re.compile(r"taler://\S+:(443|80)(/|$|\?|#)", re.I)
for u in sorted(uris):
if pat.search(u):
bad.append(u[:120])
if bad:
print("BAD\t%d\t%s" % (len(bad), bad[0]))
else:
print("OK\t%d\t" % len(uris))
PY
)
n_kind=${detail%%$'\t'*}
rest=${detail#*$'\t'}
n_count=${rest%%$'\t'*}
sample=${rest#*$'\t'}
if [ "$n_kind" = "BAD" ]; then
if [ "${LOCAL_STACK:-1}" = "1" ] || [ "${EXPECT_CURRENCY:-}" = "GOA" ]; then
fail "taler-uri ports · $label" "default :443/:80 still present · n=${n_count} e.g. ${sample}"
_port_scan_fail=$((_port_scan_fail + 1))
else
warn "taler-uri ports · $label" "default port in URI · ${sample}"
fi
else
ok "taler-uri ports · $label" "no default ports · taler_uris=${n_count:-0} · $url"
_port_scan_ok=$((_port_scan_ok + 1))
fi
rm -f "$f"
}
if [ "${CHECK_LANDING:-1}" = "1" ]; then
_port_scan_one "bank intro HTML" "${BANK_PUBLIC}/intro/"
_port_scan_one "bank demo-withdraw.json" "${BANK_PUBLIC}/intro/demo-withdraw.json"
_port_scan_one "bank auto-account.json" "${BANK_PUBLIC}/intro/auto-account.json"
_port_scan_one "exchange intro HTML" "${EXCHANGE_PUBLIC}/intro/"
_port_scan_one "merchant intro HTML" "${MERCHANT_PUBLIC}/intro/"
# stats may embed demo withdraw URI
_port_scan_one "bank stats.json" "${BANK_PUBLIC}/intro/stats.json"
if [ "$_port_scan_fail" -eq 0 ]; then
ok "taler-uri ports summary" "clean · ${_port_scan_ok} resource(s) checked (bank+exchange+merchant)"
fi
else
info "taler-uri ports" "skipped (CHECK_LANDING=0)"
fi
# Merchant shop assets — GOA: shop-pay.*; stage TESTPAYSAN: /assets/shop-ui.js +
# shops.css + QR encoder (shop-ui modal needs /intro/qrcode.min.js or /qrcode.min.js).
_ma=0
_ma_need=2
_ma_label="shop-pay.js + .css"
if [ "${EXPECT_CURRENCY:-}" = "GOA" ] || [ "${LOCAL_STACK:-0}" = "1" ]; then
_landing_probe "$MERCHANT_PUBLIC/intro/shop-pay.js" && _ma=$((_ma + 1))
_landing_probe "$MERCHANT_PUBLIC/intro/shop-pay.css" && _ma=$((_ma + 1))
else
# stage farmer shops: UI + stylesheet + qrcode (pay modal QR_Taler)
_ma_need=3
_ma_label="shop-ui.js + shops.css + qrcode.min.js"
_landing_probe "$MERCHANT_PUBLIC/assets/shop-ui.js" && _ma=$((_ma + 1))
_landing_probe "$MERCHANT_PUBLIC/assets/shops.css" && _ma=$((_ma + 1))
# Prefer /intro/ (always on landings tree); root alias is Caddy-only convenience
if _landing_probe "$MERCHANT_PUBLIC/intro/qrcode.min.js" \
|| _landing_probe "$MERCHANT_PUBLIC/qrcode.min.js"; then
_ma=$((_ma + 1))
fi
fi
if [ "$_ma" -eq "$_ma_need" ]; then
ok "landing merchant shop assets" "${_ma_label}"
else
if [ "${EXPECT_CURRENCY:-}" = "GOA" ] || [ "${LOCAL_STACK:-0}" = "1" ]; then
warn "landing merchant shop assets" "${_ma}/${_ma_need} present (soft) · want ${_ma_label}"
else
# stage: hard-ish — missing qrcode broke pay QR («bibliothèque manquante»)
if [ "$_ma" -lt 2 ]; then
fail "landing merchant shop assets" "${_ma}/${_ma_need} · want ${_ma_label}"
elif [ "$_ma" -lt "$_ma_need" ]; then
warn "landing merchant shop assets" "${_ma}/${_ma_need} · want ${_ma_label}"
else
info "landing merchant shop assets" "${_ma}/${_ma_need} · ${_ma_label}"
fi
fi
fi
# ---------------------------------------------------------------------------
# QR payloads — form + encode/decode roundtrip (qrencode + zbarimg)
# Collect taler:// + payto:// (+ app-store https from data-qr-url) from landings
# and bank mint JSON; re-encode PNG; decode; require exact payload match.
# Env: QR_CHECK=0 to skip; QR_ECC=M (default) for qrencode -l
# ---------------------------------------------------------------------------
if [ "${QR_CHECK:-1}" = "1" ] && [ "${CHECK_LANDING:-1}" = "1" ]; then
set_group qr
section "www · QR payloads (form + encode/decode · taler:// payto://)"
QR_ECC="${QR_ECC:-M}"
qr_dir="$tmp/qr-check"
mkdir -p "$qr_dir"
_qr_tools=1
if ! command -v qrencode >/dev/null 2>&1; then
warn "qr tools" "qrencode missing — form checks only (apt install qrencode)"
_qr_tools=0
fi
if ! command -v zbarimg >/dev/null 2>&1; then
warn "qr tools" "zbarimg missing — form checks only (apt install zbar-tools)"
_qr_tools=0
fi
if [ "$_qr_tools" = "1" ]; then
ok "qr tools" "qrencode + zbarimg"
fi
# Fetch sources (HTML landings + mint JSON when present)
_qr_args=()
for pair in \
"bank-intro|$BANK_PUBLIC/intro/" \
"merchant-intro|$MERCHANT_PUBLIC/intro/" \
"exchange-intro|$EXCHANGE_PUBLIC/intro/"
do
_qn="${pair%%|*}"
_qu="${pair#*|}"
_qf="$qr_dir/${_qn}.html"
_qc=$(http_body "$_qu" "$_qf" 2>/dev/null || echo 000)
if [ "$_qc" = "200" ] && [ -s "$_qf" ]; then
_qr_args+=("$_qn" "$_qf")
fi
done
for pair in \
"demo-withdraw|$BANK_PUBLIC/intro/demo-withdraw.json" \
"auto-account|$BANK_PUBLIC/intro/auto-account.json"
do
_qn="${pair%%|*}"
_qu="${pair#*|}"
_qf="$qr_dir/${_qn}.json"
_qc=$(http_body "$_qu" "$_qf" 2>/dev/null || echo 000)
if [ "$_qc" = "200" ] && [ -s "$_qf" ]; then
_qr_args+=("$_qn" "$_qf")
fi
done
if [ "${#_qr_args[@]}" -eq 0 ]; then
warn "qr sources" "no landing HTML / mint JSON fetched — skip payload checks"
else
_qr_tsv="$qr_dir/payloads.tsv"
if ! python3 "$ROOT/check_qr_payloads.py" "${_qr_args[@]}" >"$_qr_tsv" 2>"$qr_dir/harvest.err"; then
warn "qr harvest" "collector failed · $(tr '\n' ' ' <"$qr_dir/harvest.err" | head -c 160)"
fi
_qr_n=0
_qr_form_ok=0
_qr_form_bad=0
_qr_rt_ok=0
_qr_rt_bad=0
_qr_taler=0
_qr_payto=0
_qr_bad_sample=""
_qr_i=0
while IFS=$'\t' read -r src kind uri status detail || [ -n "${src:-}" ]; do
[ -n "${src:-}" ] || continue
_qr_n=$((_qr_n + 1))
case "$kind" in
withdraw|pay|pay-template|refund|other) _qr_taler=$((_qr_taler + 1)) ;;
payto) _qr_payto=$((_qr_payto + 1)) ;;
esac
if [ "$status" = "ok" ]; then
_qr_form_ok=$((_qr_form_ok + 1))
else
_qr_form_bad=$((_qr_form_bad + 1))
_qr_bad_sample="${_qr_bad_sample}${_qr_bad_sample:+; }form/${kind} ${uri:0:56}"
# taler:// and payto:// form errors are hard on local/GOA
case "$kind" in
withdraw|pay|pay-template|payto)
if [ "${LOCAL_STACK:-1}" = "1" ] || [ "${EXPECT_CURRENCY:-}" = "GOA" ]; then
fail "qr form ${kind}" "${src} · ${detail} · ${uri:0:80}"
else
warn "qr form ${kind}" "${src} · ${detail} · ${uri:0:80}"
fi
;;
*)
warn "qr form ${kind}" "${src} · ${detail} · ${uri:0:80}"
;;
esac
continue
fi
# Encode → PNG → zbarimg (exact match)
if [ "$_qr_tools" = "1" ] && [ -n "$uri" ]; then
_qr_i=$((_qr_i + 1))
_png="$qr_dir/p-${_qr_i}.png"
# ECC: H for wallet-style long URIs, L for long https store links, else QR_ECC
_ecc="$QR_ECC"
case "$kind" in
https) _ecc=L ;;
withdraw|pay|pay-template) _ecc=M ;;
payto) _ecc=M ;;
esac
if ! qrencode -l "$_ecc" -s 4 -m 2 -o "$_png" "$uri" 2>/dev/null; then
_qr_rt_bad=$((_qr_rt_bad + 1))
fail "qr encode ${kind}" "qrencode failed · ${uri:0:72}"
continue
fi
_got=$(zbarimg --raw -q "$_png" 2>/dev/null | head -n1 | tr -d '\r' || true)
if [ "$_got" = "$uri" ]; then
_qr_rt_ok=$((_qr_rt_ok + 1))
else
_qr_rt_bad=$((_qr_rt_bad + 1))
_qr_bad_sample="${_qr_bad_sample}${_qr_bad_sample:+; }decode/${kind}"
fail "qr decode ${kind}" "zbar mismatch · want ${uri:0:48}… · got ${_got:0:48}"
fi
fi
done <"$_qr_tsv"
# Download any obvious static QR images from bank intro and decode (soft)
if [ "$_qr_tools" = "1" ] && [ -f "$qr_dir/bank-intro.html" ]; then
python3 - "$qr_dir/bank-intro.html" "$BANK_PUBLIC" "$qr_dir" <<'PY' >"$qr_dir/img-urls.txt" 2>/dev/null || true
import re, sys
from urllib.parse import urljoin
html = open(sys.argv[1], encoding="utf-8", errors="replace").read()
base = sys.argv[2].rstrip("/") + "/"
out = sys.argv[3]
seen = set()
for m in re.finditer(r'''(?:src|href)=["']([^"']+\.(?:png|jpe?g|gif|svg))["']''', html, re.I):
u = m.group(1)
if "qr" not in u.lower() and "withdraw" not in u.lower():
continue
if u.startswith("data:"):
continue
absu = urljoin(base + "intro/", u)
if absu in seen:
continue
seen.add(absu)
print(absu)
PY
_img_n=0
_img_ok=0
while read -r _img_url; do
[ -n "$_img_url" ] || continue
_img_n=$((_img_n + 1))
_imgf="$qr_dir/img-${_img_n}.bin"
_ic=$(http_body "$_img_url" "$_imgf" 2>/dev/null || echo 000)
if [ "$_ic" != "200" ] || [ ! -s "$_imgf" ]; then
warn "qr image fetch" "HTTP ${_ic} · ${_img_url}"
continue
fi
# zbarimg needs image format; skip non-raster
if ! file "$_imgf" 2>/dev/null | grep -qiE 'PNG|JPEG|PBM|PGM|PPM|GIF'; then
info "qr image skip" "not raster · ${_img_url}"
continue
fi
_dec=$(zbarimg --raw -q "$_imgf" 2>/dev/null | head -n1 | tr -d '\r' || true)
if [ -z "$_dec" ]; then
warn "qr image decode" "no barcode · ${_img_url}"
continue
fi
_img_ok=$((_img_ok + 1))
# validate decoded payload form
printf '%s\n' "$_dec" >"$qr_dir/img-decoded.txt"
_line=$(python3 "$ROOT/check_qr_payloads.py" "img" "$qr_dir/img-decoded.txt" 2>/dev/null | head -1 || true)
if [ -n "$_line" ]; then
_st=$(printf '%s' "$_line" | cut -f4)
_kd=$(printf '%s' "$_line" | cut -f2)
if [ "$_st" = "ok" ]; then
ok "qr image ${_kd}" "${_dec:0:64}"
else
case "$_kd" in
withdraw|pay|pay-template|payto)
fail "qr image ${_kd}" "bad form · ${_dec:0:72}"
;;
*)
warn "qr image ${_kd}" "bad form · ${_dec:0:72}"
;;
esac
fi
else
ok "qr image decode" "${_dec:0:64}"
fi
done <"$qr_dir/img-urls.txt"
if [ "$_img_n" -gt 0 ]; then
info "qr images" "fetched ${_img_n} · decoded ${_img_ok}"
fi
fi
# Summaries
if [ "$_qr_n" -eq 0 ]; then
warn "qr payloads" "none found in landings/mint JSON"
else
if [ "$_qr_form_bad" -eq 0 ]; then
ok "qr form" "${_qr_form_ok}/${_qr_n} ok · taler=${_qr_taler} payto=${_qr_payto}"
else
if [ "${LOCAL_STACK:-1}" = "1" ]; then
fail "qr form" "${_qr_form_ok}/${_qr_n} ok · ${_qr_form_bad} bad${_qr_bad_sample:+ · $_qr_bad_sample}"
else
warn "qr form" "${_qr_form_ok}/${_qr_n} ok · ${_qr_form_bad} bad"
fi
fi
if [ "$_qr_tools" = "1" ]; then
if [ "$_qr_rt_bad" -eq 0 ] && [ "$_qr_rt_ok" -gt 0 ]; then
ok "qr encode/decode" "${_qr_rt_ok} roundtrips (ecc default ${QR_ECC})"
elif [ "$_qr_rt_ok" -eq 0 ] && [ "$_qr_rt_bad" -eq 0 ]; then
info "qr encode/decode" "no payloads to encode"
else
fail "qr encode/decode" "${_qr_rt_ok} ok · ${_qr_rt_bad} fail"
fi
fi
fi
fi
else
if [ "${QR_CHECK:-1}" != "1" ]; then
info "qr checks" "skipped (QR_CHECK=0)"
fi
fi
summary