#!/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' '; ' "$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 # 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/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' '; ' /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