331 lines
10 KiB
Bash
Executable file
331 lines
10 KiB
Bash
Executable file
#!/bin/bash
|
|
# Auto-confirm bank withdrawals for the community demo pool ONLY (explorer).
|
|
#
|
|
# Discovery: (1) landing watch files (withdraw.uri / withdraw-watch.ids),
|
|
# (2) libeufin DB via local psql (selected && !confirmed && username=explorer).
|
|
# Watch-file-only misses webui/wallet-selected ops not in the last WATCH_MAX
|
|
# IDs — DB scan is required (same idea as testpaysan; here in-CTR, no podman).
|
|
#
|
|
# Run once: auto-confirm-withdrawals.sh
|
|
# Loop: auto-confirm-withdrawals.sh --loop [SECS]
|
|
#
|
|
# Env:
|
|
# BANK_URL (default http://127.0.0.1:9012)
|
|
# BANK_USER (default explorer)
|
|
# BANK_PASS or /root/bank-explorer-password.txt
|
|
# LANDING_DIR (default /var/www/bank-landing)
|
|
# DB_NAME (default libeufin)
|
|
# DB_LIMIT max selected IDs from DB per tick (default 40)
|
|
# ALLOW_NON_EXPLORER=1
|
|
# WATCH_MAX max IDs kept in withdraw-watch.ids after prune (default 80)
|
|
# QUIET=1 less skip noise (default 1 in --loop)
|
|
# LOCK_FILE default /var/run/auto-confirm-withdrawals.lock
|
|
set -euo pipefail
|
|
|
|
BANK="${BANK_URL:-http://127.0.0.1:9012}"
|
|
BANK="${BANK%/}"
|
|
USER="${BANK_USER:-explorer}"
|
|
LANDING_DIR="${LANDING_DIR:-/var/www/bank-landing}"
|
|
WATCH_FILE="${LANDING_DIR}/withdraw-watch.ids"
|
|
URI_FILE="${LANDING_DIR}/withdraw.uri"
|
|
WATCH_MAX="${WATCH_MAX:-80}"
|
|
DB_NAME="${DB_NAME:-libeufin}"
|
|
DB_LIMIT="${DB_LIMIT:-40}"
|
|
LOCK_FILE="${LOCK_FILE:-/var/run/auto-confirm-withdrawals.lock}"
|
|
LOOP=0
|
|
SLEEP=2
|
|
if [ "${1:-}" = "--loop" ]; then
|
|
LOOP=1
|
|
SLEEP="${2:-2}"
|
|
fi
|
|
# Quiet by default in loop mode
|
|
if [ -z "${QUIET+x}" ]; then
|
|
if [ "$LOOP" -eq 1 ]; then QUIET=1; else QUIET=0; fi
|
|
fi
|
|
|
|
if [ "$USER" != "explorer" ] && [ "${ALLOW_NON_EXPLORER:-0}" != "1" ]; then
|
|
echo "refusing BANK_USER=$USER — auto-confirm is for explorer only (set ALLOW_NON_EXPLORER=1 to override)" >&2
|
|
exit 1
|
|
fi
|
|
|
|
PASS="${BANK_PASS:-}"
|
|
if [ -z "$PASS" ]; then
|
|
for f in "/root/bank-${USER}-password.txt" /root/bank-explorer-password.txt; do
|
|
if [ -f "$f" ]; then PASS=$(tr -d '\n' <"$f"); break; fi
|
|
done
|
|
fi
|
|
[ -n "$PASS" ] || { echo "no password for $USER" >&2; exit 1; }
|
|
|
|
# Single instance (loop mode)
|
|
if [ "$LOOP" -eq 1 ]; then
|
|
mkdir -p "$(dirname "$LOCK_FILE")" 2>/dev/null || true
|
|
exec 9>"$LOCK_FILE"
|
|
if command -v flock >/dev/null 2>&1; then
|
|
if ! flock -n 9; then
|
|
echo "auto-confirm already running (lock $LOCK_FILE) — exit"
|
|
# Under systemd Restart=always, exit 0 on lock contention flaps the unit
|
|
# (activating/auto-restart) while the holder is outside the cgroup.
|
|
if [ -n "${INVOCATION_ID:-}" ]; then
|
|
exit 75
|
|
fi
|
|
exit 0
|
|
fi
|
|
fi
|
|
fi
|
|
|
|
log() { printf '%s\n' "$*"; }
|
|
qlog() { [ "${QUIET}" = "1" ] || log "$@"; }
|
|
|
|
token() {
|
|
curl -sS -m 12 -u "${USER}:${PASS}" \
|
|
-H 'Content-Type: application/json' \
|
|
-d '{"scope":"readwrite","refreshable":true}' \
|
|
"${BANK}/accounts/${USER}/token"
|
|
}
|
|
|
|
# Prefer taler-integration status (has selection_done / transfer_done)
|
|
status_json() {
|
|
local wid="$1"
|
|
local j
|
|
j=$(curl -sS -m 8 "${BANK}/taler-integration/withdrawal-operation/${wid}" 2>/dev/null || true)
|
|
if [ -z "$j" ] || ! printf '%s' "$j" | grep -q '"status"'; then
|
|
j=$(curl -sS -m 8 "${BANK}/withdrawals/${wid}" 2>/dev/null || true)
|
|
fi
|
|
printf '%s' "$j"
|
|
}
|
|
|
|
field() {
|
|
# field name from json on stdin/arg
|
|
local name="$1" data="${2:-}"
|
|
if [ -z "$data" ]; then data=$(cat); fi
|
|
printf '%s' "$data" | sed -n "s/.*\"${name}\"[[:space:]]*:[[:space:]]*\"\\([^\"]*\\)\".*/\\1/p" | head -1
|
|
}
|
|
|
|
known_ids() {
|
|
if [ -f "$URI_FILE" ]; then
|
|
basename "$(tr -d '\n' <"$URI_FILE")"
|
|
fi
|
|
if [ -f "$WATCH_FILE" ]; then
|
|
grep -E '^[0-9a-fA-F-]{36}$' "$WATCH_FILE" || true
|
|
fi
|
|
}
|
|
|
|
# True if WD amount fits explorer debt headroom (or psql unavailable → allow).
|
|
id_affordable() {
|
|
local wid="$1"
|
|
if ! command -v psql >/dev/null 2>&1 || ! id -u postgres >/dev/null 2>&1; then
|
|
return 0
|
|
fi
|
|
local ok
|
|
ok=$(su -s /bin/bash postgres -c "psql -d ${DB_NAME} -At -c \"
|
|
WITH explorer AS (
|
|
SELECT a.bank_account_id, a.has_debt, a.balance, a.max_debt
|
|
FROM libeufin_bank.bank_accounts a
|
|
JOIN libeufin_bank.customers c ON c.customer_id = a.owning_customer_id
|
|
WHERE c.username = '${USER}'
|
|
LIMIT 1
|
|
),
|
|
headroom AS (
|
|
SELECT CASE WHEN has_debt THEN (max_debt).val - (balance).val
|
|
ELSE (max_debt).val + (balance).val END AS val
|
|
FROM explorer
|
|
)
|
|
SELECT 1
|
|
FROM libeufin_bank.taler_withdrawal_operations w
|
|
JOIN explorer e ON e.bank_account_id = w.wallet_bank_account
|
|
CROSS JOIN headroom h
|
|
WHERE w.withdrawal_uuid = '${wid}'::uuid
|
|
AND (w.amount).val <= h.val
|
|
LIMIT 1;
|
|
\"" 2>/dev/null | tr -d '[:space:]')
|
|
[ "$ok" = "1" ]
|
|
}
|
|
|
|
# Selected, not yet confirmed, owner = explorer only (runs inside goa CTR).
|
|
db_selected_explorer_ids() {
|
|
if ! command -v psql >/dev/null 2>&1; then
|
|
qlog "psql missing — skip DB discovery"
|
|
return 0
|
|
fi
|
|
if ! id -u postgres >/dev/null 2>&1; then
|
|
qlog "postgres user missing — skip DB discovery"
|
|
return 0
|
|
fi
|
|
# Prefer affordable amounts (newest-first often hits oversized junk that
|
|
# exceeds explorer debt headroom → HTTP 409 Insufficient funds forever).
|
|
su -s /bin/bash postgres -c "psql -d ${DB_NAME} -At -c \"
|
|
WITH explorer AS (
|
|
SELECT a.bank_account_id, a.has_debt, a.balance, a.max_debt
|
|
FROM libeufin_bank.bank_accounts a
|
|
JOIN libeufin_bank.customers c ON c.customer_id = a.owning_customer_id
|
|
WHERE c.username = '${USER}'
|
|
LIMIT 1
|
|
),
|
|
headroom AS (
|
|
SELECT CASE WHEN has_debt THEN (max_debt).val - (balance).val
|
|
ELSE (max_debt).val + (balance).val END AS val
|
|
FROM explorer
|
|
)
|
|
SELECT w.withdrawal_uuid::text
|
|
FROM libeufin_bank.taler_withdrawal_operations w
|
|
JOIN explorer e ON e.bank_account_id = w.wallet_bank_account
|
|
CROSS JOIN headroom h
|
|
WHERE w.selection_done = true
|
|
AND w.confirmation_done = false
|
|
AND w.aborted = false
|
|
AND (w.amount).val <= h.val
|
|
ORDER BY (w.amount).val ASC, (w.amount).frac ASC, w.creation_date DESC
|
|
LIMIT ${DB_LIMIT};
|
|
\"" 2>/dev/null \
|
|
| grep -E '^[0-9a-fA-F-]{36}$' || true
|
|
}
|
|
|
|
# Keep file small: drop confirmed/aborted; keep pending/selected + tail
|
|
prune_watch() {
|
|
[ -f "$WATCH_FILE" ] || return 0
|
|
local tmp keep=0
|
|
tmp=$(mktemp)
|
|
# Prefer newest IDs; re-check status for tail of file only would be slow —
|
|
# keep last WATCH_MAX unique lines and re-append selected ones found this round.
|
|
if [ -f "${LANDING_DIR}/.auto-confirm-selected" ]; then
|
|
cat "${LANDING_DIR}/.auto-confirm-selected" >>"$tmp" 2>/dev/null || true
|
|
fi
|
|
tail -n "$((WATCH_MAX * 3))" "$WATCH_FILE" 2>/dev/null \
|
|
| grep -E '^[0-9a-fA-F-]{36}$' \
|
|
| awk 'NF && !seen[$0]++' \
|
|
| tail -n "$WATCH_MAX" >>"$tmp" || true
|
|
# unique preserve order
|
|
awk 'NF && !seen[$0]++' "$tmp" >"${tmp}.2"
|
|
mv "${tmp}.2" "$WATCH_FILE"
|
|
rm -f "$tmp"
|
|
keep=$(wc -l <"$WATCH_FILE" | tr -d ' ')
|
|
qlog "prune watch list → ${keep} ids"
|
|
}
|
|
|
|
confirm_one() {
|
|
local wid="$1"
|
|
local tok="$2"
|
|
local info st uname conf
|
|
|
|
info=$(status_json "$wid")
|
|
[ -n "$info" ] || return 0
|
|
|
|
st=$(field status "$info")
|
|
uname=$(field username "$info")
|
|
|
|
if [ -n "$uname" ] && [ "$uname" != "$USER" ]; then
|
|
qlog "skip $wid owner=$uname (only confirm $USER)"
|
|
return 0
|
|
fi
|
|
|
|
case "$st" in
|
|
selected)
|
|
# Watch-list IDs bypass DB headroom filter — skip oversized (HTTP 409 forever).
|
|
if ! id_affordable "$wid"; then
|
|
qlog "skip $wid selected but over explorer headroom"
|
|
return 0
|
|
fi
|
|
log "confirming $wid as $USER (status=selected) ..."
|
|
conf=$(curl -sS -m 15 -o /tmp/acw-conf.out -w '%{http_code}' \
|
|
-X POST \
|
|
-H "Authorization: Bearer ${tok}" \
|
|
-H 'Content-Type: application/json' \
|
|
-d '{}' \
|
|
"${BANK}/accounts/${USER}/withdrawals/${wid}/confirm")
|
|
log " HTTP $conf $(head -c 160 /tmp/acw-conf.out 2>/dev/null || true)"
|
|
# remember for prune keep
|
|
echo "$wid" >>"${LANDING_DIR}/.auto-confirm-selected"
|
|
# verify
|
|
info=$(status_json "$wid")
|
|
st=$(field status "$info")
|
|
log " now status=${st:-?}"
|
|
;;
|
|
confirmed|aborted)
|
|
qlog "skip $wid status=$st"
|
|
;;
|
|
pending|"")
|
|
qlog "skip $wid status=${st:-?} (waiting wallet select)"
|
|
;;
|
|
*)
|
|
qlog "skip $wid status=${st:-?}"
|
|
;;
|
|
esac
|
|
}
|
|
|
|
once() {
|
|
local tjson tok ids n_sel=0 n_conf=0 n_pend=0 n_other=0 n=0
|
|
tjson=$(token)
|
|
tok=$(printf '%s' "$tjson" | sed -n 's/.*"access_token"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -1)
|
|
if [ -z "$tok" ]; then
|
|
echo "token fail for $USER: $tjson" >&2
|
|
return 1
|
|
fi
|
|
|
|
# Watch files (landing) + DB selected(!confirmed) explorer ops
|
|
ids=$( {
|
|
[ -f "$URI_FILE" ] && basename "$(tr -d '\n' <"$URI_FILE")"
|
|
if [ -f "$WATCH_FILE" ]; then
|
|
grep -E '^[0-9a-fA-F-]{36}$' "$WATCH_FILE" | awk 'NF && !seen[$0]++' | tail -n "$WATCH_MAX"
|
|
fi
|
|
db_selected_explorer_ids
|
|
} | awk 'NF && !seen[$0]++' )
|
|
|
|
if [ -z "$ids" ]; then
|
|
qlog "no withdrawal ids (selected or watched)"
|
|
return 0
|
|
fi
|
|
|
|
: >"${LANDING_DIR}/.auto-confirm-selected.tmp"
|
|
while read -r wid; do
|
|
[ -n "$wid" ] || continue
|
|
n=$((n + 1))
|
|
info=$(status_json "$wid")
|
|
st=$(field status "$info")
|
|
case "$st" in
|
|
selected)
|
|
n_sel=$((n_sel + 1))
|
|
echo "$wid" >>"${LANDING_DIR}/.auto-confirm-selected.tmp"
|
|
confirm_one "$wid" "$tok"
|
|
;;
|
|
confirmed) n_conf=$((n_conf + 1)); qlog "skip $wid status=confirmed" ;;
|
|
pending) n_pend=$((n_pend + 1)); qlog "skip $wid status=pending" ;;
|
|
aborted) n_other=$((n_other + 1)); qlog "skip $wid status=aborted" ;;
|
|
*) n_other=$((n_other + 1)); qlog "skip $wid status=${st:-?}" ;;
|
|
esac
|
|
done <<<"$ids"
|
|
|
|
if [ -s "${LANDING_DIR}/.auto-confirm-selected.tmp" ]; then
|
|
mv "${LANDING_DIR}/.auto-confirm-selected.tmp" "${LANDING_DIR}/.auto-confirm-selected"
|
|
else
|
|
rm -f "${LANDING_DIR}/.auto-confirm-selected.tmp"
|
|
fi
|
|
|
|
# Always one summary line per loop (monitoring-friendly)
|
|
log "tick checked=$n selected=$n_sel confirmed=$n_conf pending=$n_pend other=$n_other"
|
|
|
|
# Periodic prune (every loop is ok now that we only scan tail)
|
|
if [ "$n" -gt "$((WATCH_MAX / 2))" ] || [ -f "$WATCH_FILE" ]; then
|
|
wc=$(wc -l <"$WATCH_FILE" 2>/dev/null | tr -d ' ' || echo 0)
|
|
if [ "${wc:-0}" -gt "$((WATCH_MAX * 2))" ]; then
|
|
prune_watch
|
|
fi
|
|
fi
|
|
}
|
|
|
|
if [ "$LOOP" -eq 1 ]; then
|
|
log "auto-confirm loop every ${SLEEP}s user=$USER watch_max=$WATCH_MAX quiet=$QUIET"
|
|
# initial prune if bloated
|
|
if [ -f "$WATCH_FILE" ]; then
|
|
wc=$(wc -l <"$WATCH_FILE" | tr -d ' ')
|
|
if [ "$wc" -gt "$((WATCH_MAX * 2))" ]; then
|
|
log "watch list bloated ($wc) — pruning"
|
|
prune_watch
|
|
fi
|
|
fi
|
|
while true; do
|
|
once || true
|
|
sleep "$SLEEP"
|
|
done
|
|
else
|
|
once
|
|
fi
|