#!/bin/bash # Run INSIDE taler-hacktivism-exchange-ansible. # Writes /var/www/exchange-landing/stats.json # # Data lives in Postgres DB taler-exchange, schema exchange.* # (reserves, reserves_in, known_coins, withdraw, denominations, …) # # IMPORTANT: never use psql -F$'\t' -v ON_ERROR_STOP=1 # If the tab arg is lost, -F eats -v and ON_ERROR_STOP=1 becomes the # *username* → peer auth fails → silent empty counts (all zeros). # Never overwrite stats.json on failure — write stats-run.json instead. set -euo pipefail export TZ="${TZ:-Europe/Zurich}" export PATH="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin${PATH:+:$PATH}" LANDING_DIR="${LANDING_DIR:-/var/www/exchange-landing}" OUT="$LANDING_DIR/stats.json" RUN="$LANDING_DIR/stats-run.json" TMP="${OUT}.tmp.$$" DB="${EXCHANGE_DB:-taler-exchange}" BASE_URL="${EXCHANGE_BASE_URL:-https://exchange.hacktivism.ch}" ERRLOG="${LANDING_STATS_ERRLOG:-/var/log/landing-stats-exchange.err}" mkdir -p "$LANDING_DIR" now_iso() { date +%Y-%m-%dT%H:%M%z | sed -E 's/([+-][0-9]{2})([0-9]{2})$/\1:\2/'; } now_human() { date +"%Y-%m-%d %H:%M %Z"; } json_str() { printf '"%s"' "$(printf '%s' "$1" | sed 's/\\/\\\\/g; s/"/\\"/g' | tr '\n\r\t' ' ')" } write_run() { local ok_json="$1" msg="${2:-}" cat >"$RUN" <&2 rm -f "$TMP" 2>/dev/null || true exit 1 } # Prefer runuser; fall back to su. Always absolute-ish via PATH. as_postgres() { if command -v runuser >/dev/null 2>&1; then runuser -u postgres -- "$@" elif command -v su >/dev/null 2>&1; then su -s /bin/bash postgres -c "$*" else return 127 fi } # Single value or one row with '|' separators (no -F, no -v flags). psqlq() { local sql="$1" out ec set +e out=$(as_postgres psql -d "$DB" -At -c "$sql" 2>>"$ERRLOG") ec=$? set -e if [ "$ec" -ne 0 ]; then abort "psql failed (ec=$ec): ${sql:0:120}" fi printf '%s' "$out" } fmt_goa() { local v="${1:-0}" f="${2:-0}" v=${v//[^0-9-]/}; f=${f//[^0-9-]/}; v=${v:-0}; f=${f:-0} if [ "$f" = "0" ] || [ -z "$f" ]; then printf 'GOA:%s' "$v" return fi awk -v v="$v" -v f="$f" 'BEGIN{ frac = sprintf("%08d", f+0); sub(/0+$/, "", frac) if (frac == "") printf "GOA:%d", v+0 else printf "GOA:%d.%s", v+0, frac }' } q_count() { local n n=$(psqlq "SELECT count(*)::text FROM $1;") # strip whitespace/newlines n=$(printf '%s' "$n" | tr -d '[:space:]') [[ "$n" =~ ^[0-9]+$ ]] || abort "bad count for $1: '$n'" printf '%s' "$n" } # --- preflight --- command -v psql >/dev/null 2>&1 || abort "psql not in PATH ($PATH)" command -v runuser >/dev/null 2>&1 || command -v su >/dev/null 2>&1 || abort "neither runuser nor su in PATH" PROBE=$(psqlq "SELECT 1;") [ "$PROBE" = "1" ] || abort "postgres not reachable (SELECT 1 → '$PROBE')" # --- coin lifecycle --- KNOWN_COINS=$(q_count "exchange.known_coins") COINS_LIVE=$(psqlq "SELECT count(*)::text FROM exchange.known_coins WHERE (remaining).val > 0 OR (remaining).frac > 0;") COINS_LIVE=$(printf '%s' "${COINS_LIVE:-0}" | tr -d '[:space:]') COINS_LIVE=${COINS_LIVE:-0} COINS_SPENT=$(( KNOWN_COINS - COINS_LIVE )) REM_ROW=$(psqlq "SELECT coalesce(sum((remaining).val),0)::text || '|' || coalesce(sum((remaining).frac),0)::text FROM exchange.known_coins;") IFS='|' read -r REM_VAL REM_FRAC <<<"${REM_ROW:-0|0}" REMAINING_AMT=$(fmt_goa "${REM_VAL:-0}" "${REM_FRAC:-0}") DENOMS_TOTAL=$(q_count "exchange.denominations") DENOM_VALUES=$(psqlq "SELECT count(DISTINCT ((coin).val, (coin).frac))::text FROM exchange.denominations;") DENOM_VALUES=$(printf '%s' "${DENOM_VALUES:-0}" | tr -d '[:space:]') NOW_US=$(date +%s)000000 DENOMS_WITHDRAWABLE=$(psqlq "SELECT count(*)::text FROM exchange.denominations WHERE valid_from <= ${NOW_US} AND expire_withdraw > ${NOW_US};") DENOMS_WITHDRAWABLE=$(printf '%s' "${DENOMS_WITHDRAWABLE:-0}" | tr -d '[:space:]') RESERVES=$(q_count "exchange.reserves") RESERVES_IN=$(q_count "exchange.reserves_in") WIN_ROW=$(psqlq "SELECT coalesce(sum((credit).val),0)::text || '|' || coalesce(sum((credit).frac),0)::text FROM exchange.reserves_in;") IFS='|' read -r WIN_VAL WIN_FRAC <<<"${WIN_ROW:-0|0}" WIRE_IN_AMT=$(fmt_goa "${WIN_VAL:-0}" "${WIN_FRAC:-0}") WITHDRAW_OPS=$(q_count "exchange.withdraw") WOUT_ROW=$(psqlq "SELECT coalesce(sum((amount_with_fee).val),0)::text || '|' || coalesce(sum((amount_with_fee).frac),0)::text FROM exchange.withdraw;") IFS='|' read -r WO_VAL WO_FRAC <<<"${WOUT_ROW:-0|0}" WITHDRAW_AMT=$(fmt_goa "${WO_VAL:-0}" "${WO_FRAC:-0}") REFRESH_OPS=$(q_count "exchange.refresh") RECOUP=$(q_count "exchange.recoup") REFUNDS=$(q_count "exchange.refunds") COIN_DEPOSITS=$(q_count "exchange.coin_deposits") BATCH_DEPOSITS=$(q_count "exchange.batch_deposits") WIRE_OUT=$(q_count "exchange.wire_out") COIN_HISTORY=$(q_count "exchange.coin_history") WIRE_ACCTS=$(q_count "exchange.wire_accounts") # known coins by denom value (pipe-separated) BY_DENOM_TSV=$(psqlq " SELECT (d.coin).val::text || '|' || (d.coin).frac::text || '|' || count(*)::text || '|' || count(*) FILTER (WHERE (k.remaining).val > 0 OR (k.remaining).frac > 0)::text FROM exchange.known_coins k JOIN exchange.denominations d ON d.denominations_serial = k.denominations_serial GROUP BY (d.coin).val, (d.coin).frac ORDER BY (d.coin).val, (d.coin).frac; ") BY_DENOM_JSON="[" bf=1 while IFS='|' read -r dv df cnt live; do [ -z "${dv:-}" ] && continue amt=$(fmt_goa "$dv" "$df") if [ "$bf" = 1 ]; then bf=0; else BY_DENOM_JSON="${BY_DENOM_JSON},"; fi BY_DENOM_JSON="${BY_DENOM_JSON} {\"value\": $(json_str "$amt"), \"coins\": ${cnt:-0}, \"live\": ${live:-0}}" done <<<"$BY_DENOM_TSV" BY_DENOM_JSON="${BY_DENOM_JSON} ]" LADDER_TSV=$(psqlq " SELECT (coin).val::text || '|' || (coin).frac::text || '|' || count(*)::text FROM exchange.denominations GROUP BY (coin).val, (coin).frac ORDER BY (coin).val, (coin).frac; ") LADDER_JSON="[" lf=1 while IFS='|' read -r dv df nkeys; do [ -z "${dv:-}" ] && continue amt=$(fmt_goa "$dv" "$df") if [ "$lf" = 1 ]; then lf=0; else LADDER_JSON="${LADDER_JSON},"; fi LADDER_JSON="${LADDER_JSON} {\"value\": $(json_str "$amt"), \"keys\": ${nkeys:-0}}" done <<<"$LADDER_TSV" LADDER_JSON="${LADDER_JSON} ]" # recent wire-in / withdraw activity (no personal names — reserve_pub hex truncated) RECENT_JSON="[" rf=1 while IFS='|' read -r ts val frac; do [ -z "${ts:-}" ] && continue sec=$(awk -v t="$ts" 'BEGIN{printf "%d", int(t/1000000)}') human=$(date -d "@${sec}" +"%Y-%m-%d %H:%M %Z" 2>/dev/null || echo "$sec") amt=$(fmt_goa "${val:-0}" "${frac:-0}") if [ "$rf" = 1 ]; then rf=0; else RECENT_JSON="${RECENT_JSON},"; fi RECENT_JSON="${RECENT_JSON} {\"kind\": \"wire_in\", \"amount\": $(json_str "$amt"), \"ts_human\": $(json_str "$human"), \"ts_us\": ${ts:-0}}" done < <(psqlq " SELECT execution_date::text || '|' || (credit).val::text || '|' || (credit).frac::text FROM exchange.reserves_in ORDER BY execution_date DESC LIMIT 8; ") while IFS='|' read -r ts val frac; do [ -z "${ts:-}" ] && continue sec=$(awk -v t="$ts" 'BEGIN{printf "%d", int(t/1000000)}') human=$(date -d "@${sec}" +"%Y-%m-%d %H:%M %Z" 2>/dev/null || echo "$sec") amt=$(fmt_goa "${val:-0}" "${frac:-0}") if [ "$rf" = 1 ]; then rf=0; else RECENT_JSON="${RECENT_JSON},"; fi RECENT_JSON="${RECENT_JSON} {\"kind\": \"withdraw\", \"amount\": $(json_str "$amt"), \"ts_human\": $(json_str "$human"), \"ts_us\": ${ts:-0}}" done < <(psqlq " SELECT execution_date::text || '|' || (amount_with_fee).val::text || '|' || (amount_with_fee).frac::text FROM exchange.withdraw ORDER BY execution_date DESC LIMIT 6; ") RECENT_JSON="${RECENT_JSON} ]" # live performance measure_ms() { local url="$1" t t=$(curl -sS -o /dev/null -m 8 -w '%{time_total}' "$url" 2>/dev/null || echo "") [ -z "$t" ] && { echo "null"; return; } awk -v t="$t" 'BEGIN{printf "%d", (t+0)*1000}' } KEYS_MS=$(measure_ms "${BASE_URL}/keys") CONFIG_MS=$(measure_ms "${BASE_URL}/config") KEYS_HTTP=$(curl -sS -o /dev/null -m 8 -w '%{http_code}' "${BASE_URL}/keys" 2>/dev/null || echo "000") CONFIG_HTTP=$(curl -sS -o /dev/null -m 8 -w '%{http_code}' "${BASE_URL}/config" 2>/dev/null || echo "000") LOADAVG="" [ -r /proc/loadavg ] && LOADAVG=$(awk '{print $1","$2","$3}' /proc/loadavg) WW="false" pgrep -f taler-exchange-wirewatch >/dev/null 2>&1 && WW="true" STAT_EVENTS=$(q_count "exchange.exchange_statistic_counter_event") # Fail closed: zero denoms usually means broken query path (table always has keys) if [ "${DENOMS_TOTAL:-0}" = "0" ]; then abort "denominations count is 0 — refusing to publish (check peer auth / PATH)" fi GEN=$(now_iso) HUMAN=$(now_human) num_or_null() { case "${1:-}" in ''|null) echo null ;; *) echo "$1" ;; esac; } MEM_JSON='"container_rss_human": "—"' MEM_HELPER="${MEM_HELPER:-/usr/local/lib/landing-mem-snapshot.sh}" if [ -f "$MEM_HELPER" ]; then # shellcheck disable=SC1090 . "$MEM_HELPER" mem_snapshot_json || true fi cat >"$TMP" < $OUT"