# shellcheck shell=bash # Shared helpers for taler-monitoring (laptop or koopa). # Default stack = GOA / hacktivism (overridden by TALER_DOMAIN / --domain) : "${TALER_DOMAIN:=hacktivism.ch}" : "${BANK_PUBLIC:=https://bank.hacktivism.ch}" : "${EXCHANGE_PUBLIC:=https://exchange.hacktivism.ch}" : "${MERCHANT_PUBLIC:=https://taler.hacktivism.ch}" : "${BANK_LOCAL:=http://127.0.0.1:9012}" : "${EXCHANGE_LOCAL:=http://127.0.0.1:9011}" : "${MERCHANT_LOCAL:=https://127.0.0.1:9010}" : "${LANDING_LOCAL:=http://127.0.0.1:9013}" : "${KOOPA_SSH:=koopa}" : "${MERCHANT_INSTANCE:=goa-demo-cp4zqk}" : "${WITHDRAW_AMT:=GOA:20}" # single-shot fallback; e2e ladder uses ATM notes : "${PAY_AMT:=GOA:0.01}" : "${CREDIT_AMT:=GOA:400}" # covers ATM ladder 20+50+100+200 : "${TIMEOUT:=12}" : "${E2E_TIMEOUT:=55}" # whole e2e budget; skip rest when exceeded : "${E2E_PAY_SECS:=22}" # dedicated seconds for pay handle-uri (avoid Alarm clock) # Devtest: inject reserve credit via wire-gateway admin/add-incoming (optional). # Default off once wirewatch DNS works; set E2E_FAKE_INCOMING=1 to force. : "${E2E_FAKE_INCOMING:=0}" # SSH must never hang the monitoring run : "${SSH_CONNECT_TIMEOUT:=3}" : "${SSH_CMD_TIMEOUT:=12}" # hard cap for whole remote script (seconds) : "${SKIP_SSH:=0}" # Expected currency for public /config checks (empty = report only, don't fail) : "${EXPECT_CURRENCY:=GOA}" # 1 = this is the local koopa/hacktivism stack (inside/e2e/SSH make sense) : "${LOCAL_STACK:=1}" # Probe merchant host candidates when applying a generic domain (0=off) : "${TALER_DOMAIN_PROBE:=1}" BANK_PUBLIC=${BANK_PUBLIC%/} EXCHANGE_PUBLIC=${EXCHANGE_PUBLIC%/} MERCHANT_PUBLIC=${MERCHANT_PUBLIC%/} # --------------------------------------------------------------------------- # Domain presets → public bank / exchange / merchant base URLs # # TALER_DOMAIN=hacktivism.ch (default, GOA, local stack) # TALER_DOMAIN=taler.net → demo.taler.net (KUDOS) # TALER_DOMAIN=demo.taler.net # TALER_DOMAIN=taler-ops.ch → exchange.taler-ops.ch (CHF; bank/merchant if up) # TALER_DOMAIN=example.org → bank/exchange/backend|taler|merchant.example.org # # Explicit BANK_PUBLIC / EXCHANGE_PUBLIC / MERCHANT_PUBLIC still win if set # *after* apply_taler_domain, or pass full URLs via --bank/--exchange/--merchant. # --------------------------------------------------------------------------- _normalize_domain() { local d="$1" d="${d#https://}" d="${d#http://}" d="${d%%/*}" d="${d%%:*}" # Strip service host prefix only if a real domain remains (has a dot). # e.g. bank.demo.taler.net → demo.taler.net, taler.hacktivism.ch → hacktivism.ch # but NOT taler.net → net case "$d" in bank.*|exchange.*|taler.*|backend.*|merchant.*|shop.*|libeufin.*) rest="${d#*.}" if [[ "$rest" == *.* ]]; then d="$rest" fi ;; esac printf '%s' "$d" } _probe_https_config() { # 0 if https://$1/config returns 200 local host="$1" code code=$(curl -skS --max-redirs 0 -m 4 -o /dev/null -w '%{http_code}' "https://${host}/config" 2>/dev/null || echo 000) [ "$code" = "200" ] } apply_taler_domain() { local raw="${1:-}" local d [ -n "$raw" ] || return 0 d=$(_normalize_domain "$raw") TALER_DOMAIN="$d" case "$d" in # Local koopa stack only — SSH / inside / e2e allowed koopa|hacktivism.ch|hacktivism) BANK_PUBLIC="https://bank.hacktivism.ch" EXCHANGE_PUBLIC="https://exchange.hacktivism.ch" MERCHANT_PUBLIC="https://taler.hacktivism.ch" EXPECT_CURRENCY="GOA" LOCAL_STACK=1 SKIP_SSH=0 TALER_DOMAIN="hacktivism.ch" : "${WITHDRAW_AMT:=GOA:20}" : "${PAY_AMT:=GOA:0.01}" : "${CREDIT_AMT:=GOA:400}" ;; taler.net|demo.taler.net) # Official public demo (KUDOS) — public only, never SSH; tiny e2e amounts BANK_PUBLIC="https://bank.demo.taler.net" EXCHANGE_PUBLIC="https://exchange.demo.taler.net" MERCHANT_PUBLIC="https://backend.demo.taler.net" EXPECT_CURRENCY="KUDOS" LOCAL_STACK=0 SKIP_SSH=1 MERCHANT_INSTANCE="${MERCHANT_INSTANCE:-sandbox}" WITHDRAW_AMT="${WITHDRAW_AMT:-KUDOS:20}" PAY_AMT="${PAY_AMT:-KUDOS:0.01}" CREDIT_AMT="${CREDIT_AMT:-KUDOS:100}" TALER_DOMAIN="demo.taler.net" ;; taler-ops.ch) # Public CHF exchange; bank/merchant hosts vary — probe common names EXCHANGE_PUBLIC="https://exchange.taler-ops.ch" BANK_PUBLIC="https://bank.taler-ops.ch" MERCHANT_PUBLIC="https://backend.taler-ops.ch" EXPECT_CURRENCY="CHF" LOCAL_STACK=0 SKIP_SSH=1 WITHDRAW_AMT="${WITHDRAW_AMT:-CHF:20}" PAY_AMT="${PAY_AMT:-CHF:0.01}" CREDIT_AMT="${CREDIT_AMT:-CHF:100}" if [ "${TALER_DOMAIN_PROBE}" = "1" ]; then local h for h in bank.taler-ops.ch bank.demo.taler-ops.ch; do _probe_https_config "$h" && { BANK_PUBLIC="https://$h"; break; } done for h in backend.taler-ops.ch merchant.taler-ops.ch taler.taler-ops.ch shop.taler-ops.ch; do _probe_https_config "$h" && { MERCHANT_PUBLIC="https://$h"; break; } done fi ;; *) # Any other domain — public HTTPS only, never SSH to koopa BANK_PUBLIC="https://bank.${d}" EXCHANGE_PUBLIC="https://exchange.${d}" MERCHANT_PUBLIC="https://backend.${d}" EXPECT_CURRENCY="${EXPECT_CURRENCY:-}" # unknown — don't hard-fail currency LOCAL_STACK=0 SKIP_SSH=1 if [ "${TALER_DOMAIN_PROBE}" = "1" ]; then local h for h in "backend.${d}" "taler.${d}" "merchant.${d}" "shop.${d}"; do _probe_https_config "$h" && { MERCHANT_PUBLIC="https://$h"; break; } done for h in "bank.${d}" "libeufin.${d}"; do _probe_https_config "$h" && { BANK_PUBLIC="https://$h"; break; } done _probe_https_config "exchange.${d}" || true fi ;; esac BANK_PUBLIC=${BANK_PUBLIC%/} EXCHANGE_PUBLIC=${EXCHANGE_PUBLIC%/} MERCHANT_PUBLIC=${MERCHANT_PUBLIC%/} # Hard rule: only the local koopa/hacktivism stack may use SSH if [ "${LOCAL_STACK}" != "1" ]; then SKIP_SSH=1 fi } # Apply TALER_DOMAIN from env once (CLI exports TALER_DOMAIN_APPLIED=1 after overrides). if [ "${TALER_DOMAIN_APPLIED:-0}" != "1" ] \ && [ -n "${TALER_DOMAIN:-}" ] && [ "${TALER_DOMAIN}" != "hacktivism.ch" ]; then apply_taler_domain "$TALER_DOMAIN" TALER_DOMAIN_APPLIED=1 fi # Safe SSH: publickey only, short connect, overall alarm so we never block forever. SSH_BASE_OPTS=( -o BatchMode=yes -o ConnectTimeout="${SSH_CONNECT_TIMEOUT}" -o ConnectionAttempts=1 -o ServerAliveInterval=3 -o ServerAliveCountMax=2 -o StrictHostKeyChecking=accept-new -o PreferredAuthentications=publickey -o PasswordAuthentication=no -o KbdInteractiveAuthentication=no -o GSSAPIAuthentication=no -o NumberOfPasswordPrompts=0 ) # Hard wall-clock timeout so ssh/curl never block the monitoring run forever. with_timeout() { local secs="$1"; shift if command -v gtimeout >/dev/null 2>&1; then gtimeout --kill-after=2 "$secs" "$@" return $? fi if command -v timeout >/dev/null 2>&1; then timeout -k 2 "$secs" "$@" 2>/dev/null || timeout --kill-after=2 "$secs" "$@" return $? fi # Portable: perl alarm + process group kill perl -e ' use strict; use warnings; my $secs = shift @ARGV; my $pid = fork(); die "fork: $!" unless defined $pid; if ($pid == 0) { setpgrp(0, 0); exec @ARGV; exit 127; } $SIG{ALRM} = sub { kill "TERM", -$pid; select(undef, undef, undef, 1.0); kill "KILL", -$pid; exit 124; }; alarm $secs; waitpid($pid, 0); my $code = $? >> 8; alarm 0; exit $code; ' "$secs" "$@" } # Probe: 0 if koopa SSH works quickly koopa_ssh_ok() { [ "${SKIP_SSH}" = "1" ] && return 1 with_timeout $((SSH_CONNECT_TIMEOUT + 3)) \ ssh "${SSH_BASE_OPTS[@]}" "${KOOPA_SSH}" 'echo ok' >/dev/null 2>&1 } # Run remote bash -s with optional stdin script; hard-capped # usage: koopa_ssh_bash [timeout_secs] <<'EOF' ... EOF # or: koopa_ssh_run timeout_secs 'remote command' koopa_ssh_run() { local t="${1:-$SSH_CMD_TIMEOUT}" shift with_timeout "$t" ssh "${SSH_BASE_OPTS[@]}" "${KOOPA_SSH}" "$@" } koopa_ssh_bash() { local t="${1:-$SSH_CMD_TIMEOUT}" with_timeout "$t" ssh "${SSH_BASE_OPTS[@]}" "${KOOPA_SSH}" 'bash -s' } if [ "${NO_COLOR:-0}" = "1" ] || [ ! -t 1 ]; then G= R= Y= C= N= B= else G=$'\e[32m'; R=$'\e[31m'; Y=$'\e[33m'; C=$'\e[36m'; B=$'\e[1m'; N=$'\e[0m' fi PASS_N=0 FAIL_N=0 WARN_N=0 INFO_N=0 BLOCKERS=() # human-readable payment/withdraw blockers ERRORS=() # all ERROR lines (component scope) # Test IDs by area: www-001, e2e-001, inside-001, … # Usage: set_area www then each ok/fail/warn/info/blocker/err auto-numbers. TEST_AREA="" TEST_N=0 # Last issued id (www-001); set by _take_tid — not via $(…) so TEST_N persists. LAST_TID="" set_area() { TEST_AREA="$1" TEST_N=0 LAST_TID="" } # Assign next id into LAST_TID (must not run in a subshell). _take_tid() { LAST_TID="" if [ -z "${TEST_AREA:-}" ]; then return fi TEST_N=$((TEST_N + 1)) LAST_TID=$(printf '%s-%03d' "$TEST_AREA" "$TEST_N") } _fmt_tid() { # prefix "www-001 " or empty if [ -n "${LAST_TID:-}" ]; then printf '%s ' "$LAST_TID" fi } ok() { local label="$1" _take_tid printf '%s[OK]%s %s%s\n' "$G" "$N" "$(_fmt_tid)" "$label" PASS_N=$((PASS_N + 1)) } # component-scoped error: err bank "libeufin down" "detail" err() { local comp="$1" msg="$2" detail="${3:-}" _take_tid printf '%s[ERROR]%s %s%s: %s%s\n' "$R" "$N" "$(_fmt_tid)" "$comp" "$msg" "${detail:+ — $detail}" FAIL_N=$((FAIL_N + 1)) ERRORS+=("${LAST_TID:+$LAST_TID }[$comp] $msg${detail:+ — $detail}") } # legacy fail label ... fail() { local label="$1" detail="${2:-}" _take_tid printf '%s[ERROR]%s %s%s%s\n' "$R" "$N" "$(_fmt_tid)" "$label" "${detail:+ — $detail}" FAIL_N=$((FAIL_N + 1)) ERRORS+=("${LAST_TID:+$LAST_TID }$label${detail:+ — $detail}") } warn() { local label="$1" detail="${2:-}" _take_tid printf '%s[WARN]%s %s%s%s\n' "$Y" "$N" "$(_fmt_tid)" "$label" "${detail:+ — $detail}" WARN_N=$((WARN_N + 1)) } info() { local label="$1" detail="${2:-}" _take_tid if [ -n "$detail" ]; then printf '%s[INFO]%s %s%s — %s\n' "$C" "$N" "$(_fmt_tid)" "$label" "$detail" else printf '%s[INFO]%s %s%s\n' "$C" "$N" "$(_fmt_tid)" "$label" fi INFO_N=$((INFO_N + 1)) } blocker() { # Payment/withdraw path cannot proceed because of this local step="$1" msg="$2" _take_tid printf '%s[BLOCKER]%s %s%s: %s\n' "$R$B" "$N" "$(_fmt_tid)" "$step" "$msg" BLOCKERS+=("${LAST_TID:+$LAST_TID }[$step] $msg") FAIL_N=$((FAIL_N + 1)) ERRORS+=("BLOCKER ${LAST_TID:+$LAST_TID }[$step] $msg") } section() { printf '\n%s== %s ==%s\n' "$B" "$*" "$N"; } summary() { echo "" if [ "${#BLOCKERS[@]}" -gt 0 ]; then printf '%s--- BLOCKERS (fix/withdraw path) ---%s\n' "$R$B" "$N" local b for b in "${BLOCKERS[@]}"; do printf '%s • %s%s\n' "$R" "$b" "$N" done fi if [ "${#ERRORS[@]}" -gt 0 ] && [ "${#BLOCKERS[@]}" -lt "${#ERRORS[@]}" ]; then printf '%s--- ERRORS ---%s\n' "$R" "$N" local e for e in "${ERRORS[@]}"; do case "$e" in BLOCKER*) continue ;; esac printf '%s • %s%s\n' "$R" "$e" "$N" done fi printf 'totals: %s%d OK%s' "$G" "$PASS_N" "$N" [ "$FAIL_N" -gt 0 ] && printf ', %s%d ERROR%s' "$R" "$FAIL_N" "$N" [ "$WARN_N" -gt 0 ] && printf ', %s%d WARN%s' "$Y" "$WARN_N" "$N" [ "$INFO_N" -gt 0 ] && printf ', %d INFO' "$INFO_N" [ "${#BLOCKERS[@]}" -gt 0 ] && printf ', %s%d BLOCKER%s' "$R" "${#BLOCKERS[@]}" "$N" printf '\n' [ "$FAIL_N" -eq 0 ] } http_code() { local url="$1"; shift curl -skS --max-redirs 0 -m "${TIMEOUT}" -o /dev/null -w '%{http_code}' "$@" "$url" 2>/dev/null || echo 000 } http_body() { local url="$1" out="$2"; shift 2 curl -skS --max-redirs 0 -m "${TIMEOUT}" -o "$out" -w '%{http_code}' "$@" "$url" 2>/dev/null || echo 000 } # --------------------------------------------------------------------------- # Currency unit map checks (wallet codec: alt_unit_names must include "0") # --------------------------------------------------------------------------- # Returns 0 if JSON body at $1 has usable alt_unit_names. # Supports: # - exchange/bank: currency_specification.alt_unit_names # - merchant: currencies..alt_unit_names for each code # Optional $2 = required currency code for currency_specification.currency json_has_alt_unit_names() { local file="$1" want_cur="${2:-}" python3 - "$file" "$want_cur" <<'PY' import json, sys path, want = sys.argv[1], sys.argv[2] try: d = json.load(open(path)) except Exception as e: print(f"json-error: {e}") sys.exit(2) def check_au(au, label): if not isinstance(au, dict) or not au: print(f"{label}: missing/empty alt_unit_names") return False if "0" not in au or not str(au.get("0") or "").strip(): print(f"{label}: alt_unit_names missing non-empty key \"0\" (have {sorted(au.keys())})") return False print(f"{label}: alt_unit_names ok (0={au.get('0')!r}, n={len(au)})") return True ok = True cs = d.get("currency_specification") if isinstance(cs, dict): if want and cs.get("currency") and cs.get("currency") != want: print(f"currency_specification.currency={cs.get('currency')!r} want {want!r}") ok = False if not check_au(cs.get("alt_unit_names"), "currency_specification"): ok = False elif "currency_specification" in d: print("currency_specification: not an object") ok = False curs = d.get("currencies") if isinstance(curs, dict) and curs: for code, spec in curs.items(): if not isinstance(spec, dict): print(f"currencies.{code}: not an object") ok = False continue if not check_au(spec.get("alt_unit_names"), f"currencies.{code}"): ok = False if not isinstance(cs, dict) and not (isinstance(curs, dict) and curs): # neither shape — fail print("no currency_specification or currencies map") ok = False sys.exit(0 if ok else 1) PY } # Check one exchange base URL's /config for alt_unit_names. # $1=label $2=base_url $3=expected currency (optional) $4=strict(1) or soft(0) check_exchange_alt_units() { local label="$1" base="$2" want_cur="${3:-}" strict="${4:-1}" local f code base="${base%/}" f=$(mktemp) code=$(http_body "${base}/config" "$f") if [ "$code" != "200" ]; then rm -f "$f" if [ "$strict" = "1" ]; then fail "$label /config" "HTTP $code ($base)" else warn "$label /config" "HTTP $code ($base)" fi return fi local out ec set +e out=$(json_has_alt_unit_names "$f" "$want_cur" 2>&1) ec=$? set -e rm -f "$f" if [ "$ec" -eq 0 ]; then ok "$label alt_unit_names" "$(echo "$out" | tr '\n' '; ' | sed 's/; $//')" else if [ "$strict" = "1" ]; then fail "$label alt_unit_names" "$(echo "$out" | tr '\n' '; ' | sed 's/; $//')" else warn "$label alt_unit_names" "$(echo "$out" | tr '\n' '; ' | sed 's/; $//')" fi fi } # From merchant /config JSON file: walk exchanges[] and check each base_url/config. # Local stack hosts (hacktivism.ch or $EXCHANGE_PUBLIC host) are strict; others soft. check_merchant_listed_exchanges_alt_units() { local mer_json="$1" local list list=$(python3 - "$mer_json" <<'PY' import json, sys d = json.load(open(sys.argv[1])) for e in d.get("exchanges") or []: if not isinstance(e, dict): continue u = (e.get("base_url") or e.get("url") or "").rstrip("/") c = e.get("currency") or "" if u: print(f"{c}\t{u}") PY ) if [ -z "$list" ]; then fail "merchant exchanges[]" "empty — no exchanges to check for alt_unit_names" return fi local line cur url strict host while IFS=$'\t' read -r cur url; do [ -n "$url" ] || continue host="${url#https://}"; host="${host#http://}"; host="${host%%/*}" strict=1 case "$host" in *hacktivism.ch) strict=1 ;; *) # foreign exchange (e.g. taler-ops) — soft unless it is our configured EXCHANGE_PUBLIC if [ "$url" = "${EXCHANGE_PUBLIC}" ] || [ "$url" = "${EXCHANGE_PUBLIC}/" ]; then strict=1 else strict=0 fi ;; esac check_exchange_alt_units "exchange ${cur:-?} ${host}" "$url" "$cur" "$strict" done <<<"$list" } SECRETS_ROOT="${SECRETS_ROOT:-}" if [ -z "$SECRETS_ROOT" ]; then for d in \ "$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)/koopa-admin-secrets/koopa/host-root" \ "/Users/newkamek/src/koopa/koopa-admin-secrets/koopa/host-root" \ "$HOME/src/koopa/koopa-admin-secrets/koopa/host-root" do if [ -d "$d/taler-bank" ]; then SECRETS_ROOT=$d; break; fi done fi read_secret() { local rel="$1" if [ -n "${SECRETS_ROOT:-}" ] && [ -f "${SECRETS_ROOT}/${rel}" ]; then tr -d '\n' <"${SECRETS_ROOT}/${rel}" return 0 fi koopa_ssh_ok || return 1 koopa_ssh_run 10 "tr -d '\\n' /dev/null || true" 2>/dev/null } find_wallet_cli() { if [ -n "${WALLET_CLI:-}" ] && [ -f "$WALLET_CLI" ]; then echo "$WALLET_CLI"; return 0 fi for c in \ /Users/newkamek/src/taler/taler-typescript-core/packages/taler-wallet-cli/bin/taler-wallet-cli.mjs \ "$(command -v taler-wallet-cli 2>/dev/null || true)" do [ -n "$c" ] && [ -f "$c" ] && { echo "$c"; return 0; } done return 1 }