Rename user-facing e2e "ATM withdraw" to "Bank withdraw". The ladder amounts (20/50/100/200/…) were labeled ATM because they match typical CHF cash-note sizes (Bankomat), not because anything hits a physical ATM. Keep E2E_ATM_MAX as alias of E2E_BANK_WITHDRAW_MAX; keep e2e.atm-* group ids.
1607 lines
58 KiB
Bash
Executable file
1607 lines
58 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
|
||
}
|
||
|
||
# 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 5–15m)
|
||
# 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
|
||
|
||
# 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
|