From 956c8ab71a0d5361a1e11a692e1e5e9afef2a4c9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hern=C3=A2ni=20Marques?= Date: Thu, 16 Jul 2026 15:04:00 +0200 Subject: [PATCH 01/57] configs/nym: add Nym (nym.com) node README skeleton --- configs/nym/README.md | 54 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) create mode 100644 configs/nym/README.md diff --git a/configs/nym/README.md b/configs/nym/README.md new file mode 100644 index 0000000..94a2a78 --- /dev/null +++ b/configs/nym/README.md @@ -0,0 +1,54 @@ +# Nym node — koopa (`koopa-nym`) + +Podman container **`koopa-nym`** runs a [**nym-node**](https://nym.com/docs/operators/nodes/nym-node) +from [nym.com](https://nym.com/) (Nym mixnet / NymVPN network). +Host layout (live): `~/koopa-nym/`. +Mirror in this repo: `configs/nym/`. + +| Setting | Value | +|---------|--------| +| Container | `koopa-nym` | +| Image | `localhost/koopa-nym:latest` | +| Default mode | **`mixnode`** (safest on a home host; no open-internet exit) | +| Optional modes | `entry-gateway`, `exit-gateway` (+ WireGuard for dVPN) — see env | +| Local ID | `koopa-nym` | +| Data | `~/.nym/nym-nodes/koopa-nym/` inside volume `./data` | +| Operator T&Cs | must pass `--accept-operator-terms-and-conditions` every run | + +## Ports (host) — avoid Caddy **9000/9001** and Tor **8080** + +| Role | nym-node default | Host publish | +|------|------------------|--------------| +| HTTP API / swagger | `8080` | **9080** | +| Mixnet Sphinx | `1789` | **1789** | +| Verloc | `1790` | **1790** | +| Entry client WS | `9000` | **19000** (only if gateway mode) | +| WireGuard | `51822` | **51822** (only if WG enabled) | + +VeciGate / firewall: open only what the chosen mode needs. +**Exit-gateway** and **WireGuard** expose the host IP to abuse complaints — read +[Nym exit counsel](https://nym.com/docs/operators/community-counsel/exit-gateway) +before enabling. + +## Files + +| File | Role | +|------|------| +| `Containerfile` | Debian slim + nym-node binary | +| `entrypoint.sh` | env → `nym-node run …` | +| `compose.yml` | podman/docker compose | +| `.env.example` | non-secret knobs | +| `container-koopa-nym.service` | systemd --user unit template | + +## Ops (sketch) + +```bash +cd ~/koopa-nym # or this mirror +cp .env.example .env # edit PUBLIC_IPS, LOCATION, MODE +podman build -t localhost/koopa-nym:latest -f Containerfile . +podman compose up -d # or podman run … +# bonding: use Nym wallet / harbourmaster; node must accept operator T&Cs +curl -sS http://127.0.0.1:9080/api/v1/roles | jq . +``` + +Secrets / wallet mnemonics never live in this repo — see root `SECRETS.md`. From 166c157b672aad681edfe34379cc914e4269e966 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hern=C3=A2ni=20Marques?= Date: Thu, 16 Jul 2026 15:09:46 +0200 Subject: [PATCH 02/57] configs/nym: Containerfile for nym-node binary image --- configs/nym/Containerfile | 39 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 configs/nym/Containerfile diff --git a/configs/nym/Containerfile b/configs/nym/Containerfile new file mode 100644 index 0000000..ac6f003 --- /dev/null +++ b/configs/nym/Containerfile @@ -0,0 +1,39 @@ +# koopa-nym — nym-node (nym.com mixnet / NymVPN network) +# Docs: https://nym.com/docs/operators/nodes/nym-node +FROM docker.io/library/debian:bookworm-slim + +ENV DEBIAN_FRONTEND=noninteractive \ + NYM_HOME=/var/lib/nym \ + PATH=/usr/local/bin:$PATH + +# Pin via build-arg when a new binary is released: +# https://github.com/nymtech/nym/releases +ARG NYM_NODE_VERSION=1.35.0 +ARG NYM_NODE_URL="" + +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + ca-certificates curl ca-certificates-java \ + libssl3 \ + && rm -rf /var/lib/apt/lists/* \ + && mkdir -p /var/lib/nym /usr/local/bin + +# Prefer explicit URL; else GitHub release asset pattern for linux x86_64. +RUN set -eux; \ + if [ -n "$NYM_NODE_URL" ]; then \ + curl -fsSL -o /usr/local/bin/nym-node "$NYM_NODE_URL"; \ + else \ + curl -fsSL -o /usr/local/bin/nym-node \ + "https://github.com/nymtech/nym/releases/download/nym-binaries-v${NYM_NODE_VERSION}/nym-node"; \ + fi; \ + chmod +x /usr/local/bin/nym-node; \ + /usr/local/bin/nym-node --version || true + +COPY entrypoint.sh /usr/local/bin/nym-entrypoint +RUN chmod +x /usr/local/bin/nym-entrypoint + +# Defaults inside container (host maps different ports — see compose.yml) +EXPOSE 1789/udp 1789/tcp 1790 8080 9000 51822/udp + +WORKDIR /var/lib/nym +ENTRYPOINT ["/usr/local/bin/nym-entrypoint"] From c0ab02a44db56c48d81725aaf2a113fff8238d4a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hern=C3=A2ni=20Marques?= Date: Thu, 16 Jul 2026 15:15:32 +0200 Subject: [PATCH 03/57] configs/nym: entrypoint maps env to nym-node run --- configs/nym/entrypoint.sh | 49 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100755 configs/nym/entrypoint.sh diff --git a/configs/nym/entrypoint.sh b/configs/nym/entrypoint.sh new file mode 100755 index 0000000..191d575 --- /dev/null +++ b/configs/nym/entrypoint.sh @@ -0,0 +1,49 @@ +#!/bin/bash +# Map env → nym-node run. Bonding / wallet stay outside this container. +set -euo pipefail + +export HOME="${NYM_HOME:-/var/lib/nym}" +ID="${NYMNODE_ID:-koopa-nym}" +MODE="${NYMNODE_MODE:-mixnode}" +PUBLIC_IPS="${NYMNODE_PUBLIC_IPS:-}" +LOCATION="${NYMNODE_LOCATION:-}" +HOSTNAME_OPT="${NYMNODE_HOSTNAME:-}" +WG="${NYMNODE_WG_ENABLED:-false}" +HTTP_BIND="${NYMNODE_HTTP_BIND_ADDRESS:-[::]:8080}" +MIX_BIND="${NYMNODE_MIXNET_BIND_ADDRESS:-[::]:1789}" +VERLOC_BIND="${NYMNODE_VERLOC_BIND_ADDRESS:-[::]:1790}" +ENTRY_BIND="${NYMNODE_ENTRY_BIND_ADDRESS:-[::]:9000}" +ACCEPT_TC="${NYMNODE_ACCEPT_OPERATOR_TERMS:-true}" + +ARGS=(run --id "$ID" --mode "$MODE" --no-banner) +ARGS+=(--http-bind-address "$HTTP_BIND") +ARGS+=(--mixnet-bind-address "$MIX_BIND") +ARGS+=(--verloc-bind-address "$VERLOC_BIND") +ARGS+=(--entry-bind-address "$ENTRY_BIND") + +if [ -n "$PUBLIC_IPS" ]; then + ARGS+=(--public-ips "$PUBLIC_IPS") +fi +if [ -n "$LOCATION" ]; then + ARGS+=(--location "$LOCATION") +fi +if [ -n "$HOSTNAME_OPT" ]; then + ARGS+=(--hostname "$HOSTNAME_OPT") +fi +if [ "$WG" = "true" ] || [ "$WG" = "1" ]; then + ARGS+=(--wireguard-enabled true) + # containers often lack kernel WG + ARGS+=(--wireguard-userspace true) +fi +if [ "$ACCEPT_TC" = "true" ] || [ "$ACCEPT_TC" = "1" ]; then + ARGS+=(--accept-operator-terms-and-conditions) +fi + +# Extra flags from operator (space-separated) +if [ -n "${NYMNODE_EXTRA_ARGS:-}" ]; then + # shellcheck disable=SC2206 + EXTRA=( $NYMNODE_EXTRA_ARGS ) + ARGS+=("${EXTRA[@]}") +fi + +exec nym-node "${ARGS[@]}" From bffcfcd9463481d9a7b7bb42c3f47d6adbbea2ad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hern=C3=A2ni=20Marques?= Date: Thu, 16 Jul 2026 15:21:18 +0200 Subject: [PATCH 04/57] configs/nym: compose service koopa-nym with host port map --- configs/nym/compose.yml | 45 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 configs/nym/compose.yml diff --git a/configs/nym/compose.yml b/configs/nym/compose.yml new file mode 100644 index 0000000..f639fce --- /dev/null +++ b/configs/nym/compose.yml @@ -0,0 +1,45 @@ +# koopa-nym — nym-node (nym.com mixnet / NymVPN network) +# Live host tree: ~/koopa-nym/ (copy this directory there) +# +# Host ports chosen to avoid Caddy 9000/9001 and Tor ORPort 8080. +services: + nym: + build: + context: . + dockerfile: Containerfile + image: localhost/koopa-nym:latest + container_name: koopa-nym + restart: unless-stopped + env_file: + - .env + environment: + NYM_HOME: /var/lib/nym + NYMNODE_ID: ${NYMNODE_ID:-koopa-nym} + NYMNODE_MODE: ${NYMNODE_MODE:-mixnode} + NYMNODE_PUBLIC_IPS: ${NYMNODE_PUBLIC_IPS:-} + NYMNODE_LOCATION: ${NYMNODE_LOCATION:-} + NYMNODE_HOSTNAME: ${NYMNODE_HOSTNAME:-} + NYMNODE_WG_ENABLED: ${NYMNODE_WG_ENABLED:-false} + NYMNODE_ACCEPT_OPERATOR_TERMS: ${NYMNODE_ACCEPT_OPERATOR_TERMS:-true} + NYMNODE_HTTP_BIND_ADDRESS: "[::]:8080" + NYMNODE_MIXNET_BIND_ADDRESS: "[::]:1789" + NYMNODE_VERLOC_BIND_ADDRESS: "[::]:1790" + NYMNODE_ENTRY_BIND_ADDRESS: "[::]:9000" + NYMNODE_EXTRA_ARGS: ${NYMNODE_EXTRA_ARGS:-} + volumes: + # identity + config.toml (bond keys live here — back up) + - ./data:/var/lib/nym + ports: + - "9080:8080" # HTTP API / swagger + - "1789:1789/tcp" # mixnet + - "1789:1789/udp" + - "1790:1790" # verloc + # gateway / WG — uncomment when mode needs them: + # - "19000:9000" # entry client websocket + # - "51822:51822/udp" # WireGuard + labels: + org.hacktivism.service: nym + org.hacktivism.container: koopa-nym + org.hacktivism.managed_by: koopa-admin + org.hacktivism.network: nym.com + org.hacktivism.host_http_port: "9080" From 57508e2cc160ae44721baa36956a25e186541534 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hern=C3=A2ni=20Marques?= Date: Thu, 16 Jul 2026 15:27:04 +0200 Subject: [PATCH 05/57] configs/nym: env example for mode public-ips location WG --- configs/nym/.env.example | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 configs/nym/.env.example diff --git a/configs/nym/.env.example b/configs/nym/.env.example new file mode 100644 index 0000000..d403cc8 --- /dev/null +++ b/configs/nym/.env.example @@ -0,0 +1,25 @@ +# Copy to .env on the host (never commit .env). +# nym-node env — see https://nym.com/docs/operators/nodes/nym-node/setup + +NYMNODE_ID=koopa-nym + +# One mode only: mixnode | entry-gateway | exit-gateway +NYMNODE_MODE=mixnode + +# Public IPv4 (and IPv6 if any), comma-separated — required for bonding +# NYMNODE_PUBLIC_IPS=203.0.113.10 + +# Physical location (country name or ISO code) +# NYMNODE_LOCATION=Switzerland + +# Optional hostname if reverse-proxy / WSS is configured +# NYMNODE_HOSTNAME=nym.example.invalid + +# dVPN WireGuard path (exit/entry gateway; abuse surface — default off) +NYMNODE_WG_ENABLED=false + +# Must be true for active set (operator T&Cs) +NYMNODE_ACCEPT_OPERATOR_TERMS=true + +# Extra flags appended to nym-node run (space-separated) +# NYMNODE_EXTRA_ARGS=--write-changes From ee2b350b8d4904815eb5f7d81be4ec1a5553272e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hern=C3=A2ni=20Marques?= Date: Thu, 16 Jul 2026 15:32:50 +0200 Subject: [PATCH 06/57] configs/nym: systemd user unit to start koopa-nym --- configs/nym/container-koopa-nym.service | 28 +++++++++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 configs/nym/container-koopa-nym.service diff --git a/configs/nym/container-koopa-nym.service b/configs/nym/container-koopa-nym.service new file mode 100644 index 0000000..d5d3caf --- /dev/null +++ b/configs/nym/container-koopa-nym.service @@ -0,0 +1,28 @@ +# container-koopa-nym.service +# Install on koopa as hernani: +# mkdir -p ~/.config/systemd/user +# cp configs/nym/container-koopa-nym.service ~/.config/systemd/user/ +# systemctl --user daemon-reload +# systemctl --user enable --now container-koopa-nym.service +# Requires: loginctl enable-linger hernani (root, once) +# +# Container must already exist (podman compose up -d once). + +[Unit] +Description=Podman container-koopa-nym.service (nym.com nym-node) +Documentation=https://nym.com/docs/operators/nodes/nym-node +Wants=network-online.target +After=network-online.target +RequiresMountsFor=/run/user/1000/containers + +[Service] +Environment=PODMAN_SYSTEMD_UNIT=%n +Restart=always +TimeoutStopSec=90 +ExecStart=/usr/bin/podman start koopa-nym +ExecStop=/usr/bin/podman stop -t 20 koopa-nym +ExecStopPost=/usr/bin/podman stop -t 20 koopa-nym +Type=forking + +[Install] +WantedBy=default.target From 6814aea27f739f4f8859113bce5f5c5cc2ef2b4e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hern=C3=A2ni=20Marques?= Date: Thu, 16 Jul 2026 15:38:36 +0200 Subject: [PATCH 07/57] scripts/nym: helper script index --- scripts/nym/README.md | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 scripts/nym/README.md diff --git a/scripts/nym/README.md b/scripts/nym/README.md new file mode 100644 index 0000000..ad6939a --- /dev/null +++ b/scripts/nym/README.md @@ -0,0 +1,9 @@ +# scripts/nym — helpers for `koopa-nym` + +| Script | Role | +|--------|------| +| `build.sh` | `podman build` image `localhost/koopa-nym:latest` | +| `up.sh` | compose up in `~/koopa-nym` or `configs/nym` | +| `status.sh` | container + HTTP API probe | + +Config mirror: `configs/nym/`. Live host tree: `~/koopa-nym/`. From 2c3f1b1aa38d88573f8895a7c13c36f37e471050 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hern=C3=A2ni=20Marques?= Date: Thu, 16 Jul 2026 15:44:22 +0200 Subject: [PATCH 08/57] scripts/nym: build.sh for localhost/koopa-nym image --- scripts/nym/build.sh | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100755 scripts/nym/build.sh diff --git a/scripts/nym/build.sh b/scripts/nym/build.sh new file mode 100755 index 0000000..fe6a2d7 --- /dev/null +++ b/scripts/nym/build.sh @@ -0,0 +1,10 @@ +#!/bin/bash +# Build localhost/koopa-nym:latest from configs/nym/Containerfile +set -euo pipefail +ROOT=$(cd "$(dirname "$0")/../.." && pwd) +SRC="${NYM_SRC:-$ROOT/configs/nym}" +TAG="${NYM_IMAGE:-localhost/koopa-nym:latest}" +cd "$SRC" +podman build -t "$TAG" -f Containerfile . +echo "built $TAG" +podman image inspect "$TAG" --format '{{.Id}} {{.Created}}' 2>/dev/null || true From 4b6038ae68f8c1d949b14e1d5c9edda01c9737d2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hern=C3=A2ni=20Marques?= Date: Thu, 16 Jul 2026 15:50:08 +0200 Subject: [PATCH 09/57] scripts/nym: up.sh compose start for live or mirror tree --- scripts/nym/up.sh | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100755 scripts/nym/up.sh diff --git a/scripts/nym/up.sh b/scripts/nym/up.sh new file mode 100755 index 0000000..fc41378 --- /dev/null +++ b/scripts/nym/up.sh @@ -0,0 +1,22 @@ +#!/bin/bash +# Start koopa-nym via compose (prefers ~/koopa-nym on live host) +set -euo pipefail +ROOT=$(cd "$(dirname "$0")/../.." && pwd) +if [ -d "${HOME}/koopa-nym" ] && [ -f "${HOME}/koopa-nym/compose.yml" ]; then + DIR="${HOME}/koopa-nym" +else + DIR="${NYM_SRC:-$ROOT/configs/nym}" +fi +cd "$DIR" +if [ ! -f .env ] && [ -f .env.example ]; then + echo "note: no .env — copy .env.example and set NYMNODE_PUBLIC_IPS" >&2 +fi +if command -v podman-compose >/dev/null 2>&1; then + podman-compose up -d --build +elif podman compose version >/dev/null 2>&1; then + podman compose up -d --build +else + echo "need podman compose or podman-compose" >&2 + exit 1 +fi +podman ps --filter name=koopa-nym From 0f343eba5ba70dcc2674b6c85d45736c9da07503 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hern=C3=A2ni=20Marques?= Date: Thu, 16 Jul 2026 15:55:54 +0200 Subject: [PATCH 10/57] scripts/nym: status.sh probe container and HTTP API --- scripts/nym/status.sh | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100755 scripts/nym/status.sh diff --git a/scripts/nym/status.sh b/scripts/nym/status.sh new file mode 100755 index 0000000..a126d2a --- /dev/null +++ b/scripts/nym/status.sh @@ -0,0 +1,13 @@ +#!/bin/bash +# Probe koopa-nym container + local HTTP API +set -euo pipefail +HTTP="${NYM_HTTP:-http://127.0.0.1:9080}" +echo "=== podman ===" +podman ps -a --filter name=koopa-nym --format 'table {{.Names}}\t{{.Status}}\t{{.Ports}}' || true +echo "=== listen ===" +ss -lntp 2>/dev/null | grep -E '9080|1789|1790|19000|51822' || true +echo "=== api /roles ===" +curl -sS -m 5 "${HTTP}/api/v1/roles" 2>/dev/null | head -c 500 || echo "(unreachable)" +echo +echo "=== api /health (if present) ===" +curl -sS -m 3 -o /dev/null -w "http=%{http_code}\n" "${HTTP}/api/v1/health" 2>/dev/null || true From 989bcfdadb31e802a34c367b7a578fca3622748a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hern=C3=A2ni=20Marques?= Date: Thu, 16 Jul 2026 16:01:40 +0200 Subject: [PATCH 11/57] ports: document koopa-nym mixnet verloc and 9080 API --- configs/ports.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/configs/ports.md b/configs/ports.md index 24aad5d..7ee6181 100644 --- a/configs/ports.md +++ b/configs/ports.md @@ -24,3 +24,10 @@ VeciGate: WAN **80→9000**, WAN **443→9001**. Public apps: Caddy vhosts on **9001** → 127.0.0.1:{9010–9015, 9020–9025, 9090–9092}. Git SSH needs separate NAT/firewall **9200/tcp** if exposed to WAN. + +| **1789** | podman **`koopa-nym`** mixnet (nym.com nym-node) | +| **1790** | podman **`koopa-nym`** verloc | +| **9080** | podman **`koopa-nym`** HTTP API (mapped from container 8080; Tor keeps **8080**) | +| **19000** | optional nym entry WS (gateway mode) | +| **51822/udp** | optional nym WireGuard (dVPN path) | + From 9aeb9acd2b1b77376fc17c6e443a01fa491ed183 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hern=C3=A2ni=20Marques?= Date: Thu, 16 Jul 2026 16:07:26 +0200 Subject: [PATCH 12/57] configs: list nym/ next to tor in inventory table --- configs/README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/configs/README.md b/configs/README.md index f1f03fb..ce67cd3 100644 --- a/configs/README.md +++ b/configs/README.md @@ -13,6 +13,7 @@ Directories are named to match **live podman container names** where possible. | `tops/` | `koopa-tops-ng1` … `ng3` | `nginx:1.27-alpine` | | `caddy/` `firewalld/` `systemd/` | host services | | | `tor/` | **`koopa-tor-relay`** (podman host net) | `localhost/koopa-tor-relay:latest` | +| `nym/` | **`koopa-nym`** (nym.com nym-node) | `localhost/koopa-nym:latest` | | `paivana/` | **`koopa-paivana`** (+ upstream) | `localhost/koopa-paivana:latest` | **Authoritative running inventory:** `host/overview/LIVE.md`. From cd3b6530df19068d532e10b60298fd5997713d5b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hern=C3=A2ni=20Marques?= Date: Thu, 16 Jul 2026 16:13:12 +0200 Subject: [PATCH 13/57] host/overview: plan koopa-nym in LIVE inventory --- host/overview/LIVE.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/host/overview/LIVE.md b/host/overview/LIVE.md index f235056..e9213b5 100644 --- a/host/overview/LIVE.md +++ b/host/overview/LIVE.md @@ -9,7 +9,8 @@ hostname; date -R podman ps --format 'table {{.Names}}\t{{.Image}}\t{{.Status}}\t{{.Ports}}' systemctl is-active caddy systemctl --user is-active container-koopa-tor-relay -ss -lntp | grep -E '90[0-9]{2}|9200|8080' +systemctl --user is-active container-koopa-nym +ss -lntp | grep -E '90[0-9]{2}|9200|8080|9080|1789' ``` ## Host @@ -35,6 +36,7 @@ ss -lntp | grep -E '90[0-9]{2}|9200|8080' | `koopa-forgejo` (+ postgres) | `forgejo:11-rootless` | **9024**, **9200** | `git.hacktivism.ch` | | **`koopa-paivana`** (+ upstream) | `localhost/koopa-paivana:latest` | **9025** | `paivana.hacktivism.ch` | | **`koopa-tor-relay`** | `localhost/koopa-tor-relay:latest` | **8080**, **9051** (host net) | Tor OR (non-exit) | +| **`koopa-nym`** | `localhost/koopa-nym:latest` | **9080**, **1789**, **1790** (+ opt. WG) | Nym mixnet node (nym.com) | | `koopa-tops-ng1` | `nginx` | **9090** | `tops.ng1.hacktivism.ch` | | `koopa-tops-ng2` | `nginx` | **9091** | `tops.ng2.hacktivism.ch` | | `koopa-tops-ng3` | `nginx` | **9092** | `tops.ng3.hacktivism.ch` | @@ -76,6 +78,7 @@ Config: `/etc/caddy/Caddyfile` (mirror `configs/caddy/Caddyfile`). | `~/koopa-tops/` | tops.ng1–ng3 (`koopa-tops-ng*`) | | `~/koopa-caddy/` | Caddyfile working tree on host | | `~/koopa-tor-relay/` | Tor relay container (torrc, data/identity, log) | +| `~/koopa-nym/` | Nym nym-node (nym.com mixnet / NymVPN network) | ## Start models From 77abb7d22cfa641aa2c6e2231b1d8fe8e607d66e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hern=C3=A2ni=20Marques?= Date: Thu, 16 Jul 2026 16:18:58 +0200 Subject: [PATCH 14/57] firewalld: Nym ports 1789 1790 9080 WG notes --- configs/firewalld/public-ports.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/configs/firewalld/public-ports.md b/configs/firewalld/public-ports.md index 12ea2b2..ee433d2 100644 --- a/configs/firewalld/public-ports.md +++ b/configs/firewalld/public-ports.md @@ -59,3 +59,16 @@ Forgejo advertises `SSH_PORT=9200`. Traffic hits **host:9200** (not Caddy). With |------|--------| | 2026-07-09 | 443/tcp re-added for IPv6 | | 2026-07-10 | **9200/tcp** for Forgejo git-SSH (verified LAN + hairpin + SSH auth) | + +## Nym (`koopa-nym`, nym.com) + +| Port | Proto | Role | +|------|-------|------| +| 1789 | tcp/udp | mixnet Sphinx | +| 1790 | tcp | verloc | +| 9080 | tcp | HTTP API (optional public; often loopback-only via Caddy if proxied) | +| 19000 | tcp | entry gateway websocket (if mode=entry/exit-gateway) | +| 51822 | udp | WireGuard (if NYMNODE_WG_ENABLED) | + +Do **not** collide with Tor ORPort **8080** or Caddy **9000/9001**. + From ec24c93aa83a24b4c516598849c7943dbd3b89e0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hern=C3=A2ni=20Marques?= Date: Thu, 16 Jul 2026 16:24:45 +0200 Subject: [PATCH 15/57] docs: SECRETS path day log and scripts index for nym --- 2026/2026-07-16--koopa-nym.md | 15 +++++++++++++++ SECRETS.md | 3 +++ scripts/README.md | 1 + 3 files changed, 19 insertions(+) create mode 100644 2026/2026-07-16--koopa-nym.md diff --git a/2026/2026-07-16--koopa-nym.md b/2026/2026-07-16--koopa-nym.md new file mode 100644 index 0000000..3c9a94e --- /dev/null +++ b/2026/2026-07-16--koopa-nym.md @@ -0,0 +1,15 @@ +# koopa-nym (nym.com nym-node) — 2026-07-16 + +Admin-log mirror for a new podman container **`koopa-nym`** running +[nym-node](https://nym.com/docs/operators/nodes/nym-node) for the Nym mixnet / +NymVPN network. + +| Item | Value | +|------|--------| +| Config | `configs/nym/` | +| Scripts | `scripts/nym/` | +| Default mode | `mixnode` | +| HTTP host port | **9080** (avoids Tor **8080**) | +| Mixnet | **1789** | + +Bonding and wallet mnemonics stay on the host secrets layout — not in this repo. diff --git a/SECRETS.md b/SECRETS.md index e74c10a..d5d77b9 100644 --- a/SECRETS.md +++ b/SECRETS.md @@ -33,3 +33,6 @@ Live passwords/keys live in sibling **`koopa-admin-secrets`**. - `MASTER_PUBLIC_KEY` / merchant `MASTER_KEY` (exchange public master key) - Port maps, unit ladders, non-secret overrides + +| Nym node (`koopa-nym`) | `koopa/home-hernani/koopa-nym/` (data volume, wallet mnemonic if any) | `~/koopa-nym/data/` | + diff --git a/scripts/README.md b/scripts/README.md index 34ef2bc..5570301 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -8,6 +8,7 @@ | `taler-sanity/` | host root checks (stack, settlement, helpers) | | `taler-monitoring/` | **outside-in** public URL walk (`/config` → keys/terms/integration/webui) | | `monitoring/` | host `/home/hernani/scripts` (tor relay stats) | +| `nym/` | **koopa-nym** build/up/status (nym.com nym-node) | | `taler-wallet-cli/` | thin wrappers; **benchmarks live in** `../benchmarks/` | | `castopod/` | host `hernani` podman-compose `~/koopa-castopod` — see `castopod/README.md` | From c0e0beb2b48f40adc134d09e986fce86db8c45bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hern=C3=A2ni=20Marques?= Date: Thu, 16 Jul 2026 16:29:19 +0200 Subject: [PATCH 16/57] docs: map koopa-nym secrets to koopa-admin-secrets paths Document live ~/koopa-nym/.env and host-only data volume; no secret values. --- SECRETS.md | 4 +--- configs/firewalld/public-ports.md | 2 ++ configs/nym/README.md | 9 ++++++++- 3 files changed, 11 insertions(+), 4 deletions(-) diff --git a/SECRETS.md b/SECRETS.md index d5d77b9..3b6d709 100644 --- a/SECRETS.md +++ b/SECRETS.md @@ -22,6 +22,7 @@ Live passwords/keys live in sibling **`koopa-admin-secrets`**. | Prime (Jellyfin + qBittorrent) | `koopa/home-hernani/koopa-prime/{.env,users.env}` | `~/koopa-prime/` | | Forgejo (**rootless**, git.hacktivism.ch) | `koopa/home-hernani/koopa-forgejo/{.env,users.env,compose.yml}` | `~/koopa-forgejo/` | | Paivana (GOA paywall) | `koopa/home-hernani/koopa-paivana/secrets/*` | `~/koopa-paivana/secrets/` | +| Nym (`koopa-nym`, nym.com) | `koopa/home-hernani/koopa-nym/.env` | `~/koopa-nym/.env` (+ `~/koopa-nym/data/` identity, host-only) | ## Rules @@ -33,6 +34,3 @@ Live passwords/keys live in sibling **`koopa-admin-secrets`**. - `MASTER_PUBLIC_KEY` / merchant `MASTER_KEY` (exchange public master key) - Port maps, unit ladders, non-secret overrides - -| Nym node (`koopa-nym`) | `koopa/home-hernani/koopa-nym/` (data volume, wallet mnemonic if any) | `~/koopa-nym/data/` | - diff --git a/configs/firewalld/public-ports.md b/configs/firewalld/public-ports.md index ee433d2..2ae7378 100644 --- a/configs/firewalld/public-ports.md +++ b/configs/firewalld/public-ports.md @@ -55,6 +55,8 @@ Forgejo advertises `SSH_PORT=9200`. Traffic hits **host:9200** (not Caddy). With ## History +| 2026-07-16 | **1789/tcp+udp**, **1790/tcp**, **51822/udp** for Nym (`koopa-nym`) — open with firewall-cmd when node is live | + | When | Change | |------|--------| | 2026-07-09 | 443/tcp re-added for IPv6 | diff --git a/configs/nym/README.md b/configs/nym/README.md index 94a2a78..55e089a 100644 --- a/configs/nym/README.md +++ b/configs/nym/README.md @@ -51,4 +51,11 @@ podman compose up -d # or podman run … curl -sS http://127.0.0.1:9080/api/v1/roles | jq . ``` -Secrets / wallet mnemonics never live in this repo — see root `SECRETS.md`. +Secrets layout (values in **koopa-admin-secrets**): + +| Live | Secrets mirror | +|------|----------------| +| `~/koopa-nym/.env` | `koopa-admin-secrets/koopa/home-hernani/koopa-nym/.env` | +| `~/koopa-nym/data/` | host-only (node identity; not git) | + +Path map: `koopa-admin-log/SECRETS.md`. From 4775d106cdc58c558b20aaf51ea4420478248f42 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hern=C3=A2ni=20Marques?= Date: Thu, 16 Jul 2026 16:30:03 +0200 Subject: [PATCH 17/57] docs: cleanup day notes --- 2026/2026-07-16.md | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 2026/2026-07-16.md diff --git a/2026/2026-07-16.md b/2026/2026-07-16.md new file mode 100644 index 0000000..1a421aa --- /dev/null +++ b/2026/2026-07-16.md @@ -0,0 +1,3 @@ +# 2026-07-16 + +- **koopa-nym** (nym.com) mirror → `2026-07-16--koopa-nym.md` From 798d8aaf518cfa44900fc9d733316a75e1da9801 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hern=C3=A2ni=20Marques?= Date: Thu, 16 Jul 2026 16:34:17 +0200 Subject: [PATCH 18/57] configs/nym: fix entrypoint and host-side binary download for build Drop unsupported --no-banner; COPY pre-fetched nym-node; ignore binary/env/data. --- configs/nym/.gitignore | 3 +++ configs/nym/Containerfile | 27 +++++++-------------------- configs/nym/entrypoint.sh | 4 +--- scripts/nym/build.sh | 12 ++++++++++-- 4 files changed, 21 insertions(+), 25 deletions(-) create mode 100644 configs/nym/.gitignore diff --git a/configs/nym/.gitignore b/configs/nym/.gitignore new file mode 100644 index 0000000..d5945aa --- /dev/null +++ b/configs/nym/.gitignore @@ -0,0 +1,3 @@ +nym-node +.env +data/ diff --git a/configs/nym/Containerfile b/configs/nym/Containerfile index ac6f003..3f4f504 100644 --- a/configs/nym/Containerfile +++ b/configs/nym/Containerfile @@ -1,39 +1,26 @@ # koopa-nym — nym-node (nym.com mixnet / NymVPN network) -# Docs: https://nym.com/docs/operators/nodes/nym-node +# Fetch binary on host first (see scripts/nym/build.sh), then: +# podman build -t localhost/koopa-nym:latest -f Containerfile . FROM docker.io/library/debian:bookworm-slim ENV DEBIAN_FRONTEND=noninteractive \ NYM_HOME=/var/lib/nym \ PATH=/usr/local/bin:$PATH -# Pin via build-arg when a new binary is released: -# https://github.com/nymtech/nym/releases -ARG NYM_NODE_VERSION=1.35.0 -ARG NYM_NODE_URL="" - RUN apt-get update \ && apt-get install -y --no-install-recommends \ - ca-certificates curl ca-certificates-java \ - libssl3 \ + ca-certificates libssl3 \ && rm -rf /var/lib/apt/lists/* \ && mkdir -p /var/lib/nym /usr/local/bin -# Prefer explicit URL; else GitHub release asset pattern for linux x86_64. -RUN set -eux; \ - if [ -n "$NYM_NODE_URL" ]; then \ - curl -fsSL -o /usr/local/bin/nym-node "$NYM_NODE_URL"; \ - else \ - curl -fsSL -o /usr/local/bin/nym-node \ - "https://github.com/nymtech/nym/releases/download/nym-binaries-v${NYM_NODE_VERSION}/nym-node"; \ - fi; \ - chmod +x /usr/local/bin/nym-node; \ - /usr/local/bin/nym-node --version || true +# Pre-downloaded on host into build context as ./nym-node +COPY nym-node /usr/local/bin/nym-node +RUN chmod +x /usr/local/bin/nym-node \ + && /usr/local/bin/nym-node --version || true COPY entrypoint.sh /usr/local/bin/nym-entrypoint RUN chmod +x /usr/local/bin/nym-entrypoint -# Defaults inside container (host maps different ports — see compose.yml) EXPOSE 1789/udp 1789/tcp 1790 8080 9000 51822/udp - WORKDIR /var/lib/nym ENTRYPOINT ["/usr/local/bin/nym-entrypoint"] diff --git a/configs/nym/entrypoint.sh b/configs/nym/entrypoint.sh index 191d575..374e90c 100755 --- a/configs/nym/entrypoint.sh +++ b/configs/nym/entrypoint.sh @@ -15,7 +15,7 @@ VERLOC_BIND="${NYMNODE_VERLOC_BIND_ADDRESS:-[::]:1790}" ENTRY_BIND="${NYMNODE_ENTRY_BIND_ADDRESS:-[::]:9000}" ACCEPT_TC="${NYMNODE_ACCEPT_OPERATOR_TERMS:-true}" -ARGS=(run --id "$ID" --mode "$MODE" --no-banner) +ARGS=(run --id "$ID" --mode "$MODE") ARGS+=(--http-bind-address "$HTTP_BIND") ARGS+=(--mixnet-bind-address "$MIX_BIND") ARGS+=(--verloc-bind-address "$VERLOC_BIND") @@ -32,14 +32,12 @@ if [ -n "$HOSTNAME_OPT" ]; then fi if [ "$WG" = "true" ] || [ "$WG" = "1" ]; then ARGS+=(--wireguard-enabled true) - # containers often lack kernel WG ARGS+=(--wireguard-userspace true) fi if [ "$ACCEPT_TC" = "true" ] || [ "$ACCEPT_TC" = "1" ]; then ARGS+=(--accept-operator-terms-and-conditions) fi -# Extra flags from operator (space-separated) if [ -n "${NYMNODE_EXTRA_ARGS:-}" ]; then # shellcheck disable=SC2206 EXTRA=( $NYMNODE_EXTRA_ARGS ) diff --git a/scripts/nym/build.sh b/scripts/nym/build.sh index fe6a2d7..271c6a4 100755 --- a/scripts/nym/build.sh +++ b/scripts/nym/build.sh @@ -1,10 +1,18 @@ #!/bin/bash -# Build localhost/koopa-nym:latest from configs/nym/Containerfile +# Download nym-node + build localhost/koopa-nym:latest set -euo pipefail ROOT=$(cd "$(dirname "$0")/../.." && pwd) SRC="${NYM_SRC:-$ROOT/configs/nym}" TAG="${NYM_IMAGE:-localhost/koopa-nym:latest}" +REL="${NYM_RELEASE_TAG:-nym-binaries-v2026.13-ziller}" +URL="${NYM_NODE_URL:-https://github.com/nymtech/nym/releases/download/${REL}/nym-node}" cd "$SRC" +if [ ! -x nym-node ] || [ "${NYM_FORCE_DOWNLOAD:-0}" = "1" ]; then + echo "download $URL" + curl -fsSL -L -o nym-node.tmp "$URL" + chmod +x nym-node.tmp + mv nym-node.tmp nym-node +fi +./nym-node --version || true podman build -t "$TAG" -f Containerfile . echo "built $TAG" -podman image inspect "$TAG" --format '{{.Id}} {{.Created}}' 2>/dev/null || true From 05cd6931c80dbe2f7385901b8cc57dfb19fb0e6c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hern=C3=A2ni=20Marques?= Date: Thu, 16 Jul 2026 16:35:36 +0200 Subject: [PATCH 19/57] docs: all production containers autostart on koopa reboot Record podman-restart.service, unless-stopped policies, and per-container user units. --- 2026/2026-07-16--container-autostart.md | 37 +++++++++++++++++++++++++ 2026/2026-07-16.md | 1 + 2 files changed, 38 insertions(+) create mode 100644 2026/2026-07-16--container-autostart.md diff --git a/2026/2026-07-16--container-autostart.md b/2026/2026-07-16--container-autostart.md new file mode 100644 index 0000000..ef27dae --- /dev/null +++ b/2026/2026-07-16--container-autostart.md @@ -0,0 +1,37 @@ +# Container autostart on reboot — 2026-07-16 + +## How it is set (hernani@koopa, rootless podman) + +1. **`loginctl` Linger=yes** for `hernani` — user systemd runs without login. +2. **`podman-restart.service` (user)** enabled — on boot starts all containers with + restart policy that should come up (`unless-stopped` / `always`, not manually stopped). +3. **Per-container (or stack) user units** under `~/.config/systemd/user/container-*.service` + also enabled (belt-and-suspenders + DB ordering drop-ins). +4. **Restart policy** on every production container: **`unless-stopped`**. + +## Production set (should-start-on-boot) + +| Container | +|-----------| +| `taler-hacktivism`, `taler-hacktivism-bank`, `taler-hacktivism-exchange-ansible` | +| `koopa-forgejo`, `koopa-forgejo-db` | +| `koopa-prime-jellyfin`, `koopa-prime-qbittorrent` | +| `koopa-tor-relay`, `koopa-nym` | +| `koopa-bonfire`, `koopa-bonfire-db` | +| `koopa-castopod`, `koopa-castopod-mariadb`, `koopa-castopod-redis` | +| `koopa-paivana`, `koopa-paivana-upstream` | +| `koopa-tops-ng1` … `ng3` (+ `container-koopa-tops.service` ensure) | + +## Changed this day + +- Taler stack: `restart=no` → **`unless-stopped`** +- New units: forgejo(+db), prime jellyfin/qbittorrent, paivana-upstream, three taler containers +- Enabled **`podman-restart.service`** + +## Checks after reboot + +```bash +systemctl --user is-enabled podman-restart.service +podman ps -a --filter should-start-on-boot=true --format '{{.Names}} {{.Status}}' +systemctl --user --failed +``` diff --git a/2026/2026-07-16.md b/2026/2026-07-16.md index 1a421aa..876e42c 100644 --- a/2026/2026-07-16.md +++ b/2026/2026-07-16.md @@ -1,3 +1,4 @@ # 2026-07-16 - **koopa-nym** (nym.com) mirror → `2026-07-16--koopa-nym.md` +- **container autostart** (podman-restart + units) → `2026-07-16--container-autostart.md` From 9c1c8f22496dc797325c4fca92fd32c66baa79e2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hern=C3=A2ni=20Marques?= Date: Thu, 16 Jul 2026 16:36:28 +0200 Subject: [PATCH 20/57] docs: add visible ISSUES.md for live koopa findings Separate issue list: firewalld Nym ports, nym bond, podman-restart untested, stray containers, API bind, cleanup candidates. --- 2026/2026-07-16.md | 1 + ISSUES.md | 123 +++++++++++++++++++++++++++++++++++++++++++++ README.md | 4 ++ 3 files changed, 128 insertions(+) create mode 100644 ISSUES.md diff --git a/2026/2026-07-16.md b/2026/2026-07-16.md index 876e42c..e3a3f81 100644 --- a/2026/2026-07-16.md +++ b/2026/2026-07-16.md @@ -2,3 +2,4 @@ - **koopa-nym** (nym.com) mirror → `2026-07-16--koopa-nym.md` - **container autostart** (podman-restart + units) → `2026-07-16--container-autostart.md` +- **Offene Issues** → [`ISSUES.md`](../ISSUES.md) diff --git a/ISSUES.md b/ISSUES.md new file mode 100644 index 0000000..09c5b4c --- /dev/null +++ b/ISSUES.md @@ -0,0 +1,123 @@ +# Offene Issues (sichtbar) + +Stand: **2026-07-16** (koopa live scan). Keine Secrets. + +--- + +## I-2026-07-16-01 — firewalld: Nym-Ports auf koopa unbestätigt + +| | | +|--|--| +| **Severity** | high (für öffentliche Nym-Erreichbarkeit) | +| **Host** | koopa | +| **Status** | open | + +VeciGate DNATed **1789/tcp+udp**, **1790/tcp**, **51822/udp** → koopa. +Ohne passende **firewalld**-Freigabe auf koopa bleibt der Traffic am Host stecken. + +Agent konnte `firewall-cmd` nicht prüfen/ändern (**sudo Passwort nötig**). + +```bash +# auf koopa (mit sudo) +sudo firewall-cmd --permanent --add-port=1789/tcp +sudo firewall-cmd --permanent --add-port=1789/udp +sudo firewall-cmd --permanent --add-port=1790/tcp +sudo firewall-cmd --permanent --add-port=51822/udp +sudo firewall-cmd --reload +sudo firewall-cmd --list-ports +``` + +--- + +## I-2026-07-16-02 — Nym-Node nicht gebondet / nicht im Explorer + +| | | +|--|--| +| **Severity** | medium (ops / Sichtbarkeit im Nym-Netz) | +| **Host** | koopa (`koopa-nym`) | +| **Status** | open | + +Container **läuft** (mixnode), API `9080` antwortet. +Ohne **Bond** in der Nym-Wallet erscheint die Node typischerweise **nicht** in Harbour Master / Explorer (anders als Tor nach ORPort-Publish). + +Logs: WARN `validator.nymtech.net/api/.../refresh-described` → **404** (API-Pfad/Version; Node mischt trotzdem). + +--- + +## I-2026-07-16-03 — `podman-restart.service` enabled, session noch inactive + +| | | +|--|--| +| **Severity** | low (bis Reboot getestet) | +| **Host** | koopa (user hernani) | +| **Status** | open / expected until reboot | + +`systemctl --user is-enabled podman-restart` = **enabled**, +`is-active` = **inactive** (oneshot, diese Boot-Session noch nicht gelaufen). + +Nach Reboot prüfen: + +```bash +systemctl --user is-active podman-restart.service +podman ps -a --filter should-start-on-boot=true --format '{{.Names}} {{.Status}}' +``` + +--- + +## I-2026-07-16-04 — Streucontainer `kind_taussig` + +| | | +|--|--| +| **Severity** | low (Hygiene) | +| **Host** | koopa | +| **Status** | open | + +Container **Up** seit ~5 Tagen: `kind_taussig` (`debian:bookworm-slim`), +einmaliges Wallet-/DEB-Testskript, **`restart=no`**, kein Prod-Name. + +Kandidat zum **Stoppen/Entfernen**, sobald Logs nicht mehr gebraucht werden: + +```bash +podman stop kind_taussig +podman rm kind_taussig +``` + +--- + +## I-2026-07-16-05 — Alte exited Podman-Container (Müll) + +| | | +|--|--| +| **Severity** | low (Hygiene) | +| **Host** | koopa | +| **Status** | open | + +Mehrere **Exited**-Container (Monate/Jahre alt), u. a. `pensive_dubinsky`, +`hardcore_edison`, `taler-exchange-no-network`, `debian00`, … +Kein Autostart; belasten nur `podman ps -a`. + +Optional aufräumen: `podman container prune` (nur exited, nach Sichtprüfung). + +--- + +## I-2026-07-16-06 — Nym HTTP-API `*:9080` lauscht host-weit + +| | | +|--|--| +| **Severity** | low–medium (Angriffsfläche) | +| **Host** | koopa | +| **Status** | open (bewusst so im compose) | + +`ss` zeigt **\*:9080** (rootlessport). **Kein** VeciGate-DNAT auf 9080. +Von WAN ohne NAT i. d. R. nicht erreichbar; von LAN erreichbar. + +Härten optional: Publish nur `127.0.0.1:9080:8080` in compose. + +--- + +## Erledigt / kein Issue (Scan) + +- Alle `should-start-on-boot` Prod-Container **Up** +- Keine failed user units +- Taler 9010–9015 antworten (401/302 ok) +- Linger=yes, container-* units enabled diff --git a/README.md b/README.md index c165035..d3aadb1 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,10 @@ Ops log and config mirror for host **koopa** (openSUSE Tumbleweed). **Secrets:** not in this repo — see **`SECRETS.md`** and sibling **`koopa-admin-secrets`**. +## Open issues + +**Sichtbare Issue-Liste:** [`ISSUES.md`](ISSUES.md) + ## Git origin | | | From 1aa4d1bab8eb5f9d0c4618f13e2dc5f88f26a382 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hern=C3=A2ni=20Marques?= Date: Thu, 16 Jul 2026 16:37:57 +0200 Subject: [PATCH 21/57] docs: English ISSUES and Nym public listing locations Document harbourmaster/explorer URLs and identity key; translate ISSUES to English. --- 2026/2026-07-16.md | 2 +- ISSUES.md | 77 ++++++++++++++++++----------------- README.md | 2 +- configs/nym/PUBLIC-LISTING.md | 62 ++++++++++++++++++++++++++++ configs/nym/README.md | 2 + 5 files changed, 106 insertions(+), 39 deletions(-) create mode 100644 configs/nym/PUBLIC-LISTING.md diff --git a/2026/2026-07-16.md b/2026/2026-07-16.md index e3a3f81..df8251d 100644 --- a/2026/2026-07-16.md +++ b/2026/2026-07-16.md @@ -2,4 +2,4 @@ - **koopa-nym** (nym.com) mirror → `2026-07-16--koopa-nym.md` - **container autostart** (podman-restart + units) → `2026-07-16--container-autostart.md` -- **Offene Issues** → [`ISSUES.md`](../ISSUES.md) +- **Open issues** → [`ISSUES.md`](../ISSUES.md) diff --git a/ISSUES.md b/ISSUES.md index 09c5b4c..2f70914 100644 --- a/ISSUES.md +++ b/ISSUES.md @@ -1,24 +1,24 @@ -# Offene Issues (sichtbar) +# Open issues -Stand: **2026-07-16** (koopa live scan). Keine Secrets. +As of **2026-07-16** (live scan on koopa). No secrets. --- -## I-2026-07-16-01 — firewalld: Nym-Ports auf koopa unbestätigt +## I-2026-07-16-01 — firewalld: Nym ports on koopa unverified | | | |--|--| -| **Severity** | high (für öffentliche Nym-Erreichbarkeit) | +| **Severity** | high (for public Nym reachability) | | **Host** | koopa | | **Status** | open | -VeciGate DNATed **1789/tcp+udp**, **1790/tcp**, **51822/udp** → koopa. -Ohne passende **firewalld**-Freigabe auf koopa bleibt der Traffic am Host stecken. +VeciGate DNATs **1789/tcp+udp**, **1790/tcp**, **51822/udp** → koopa. +Without matching **firewalld** allow rules on koopa, traffic dies on the host. -Agent konnte `firewall-cmd` nicht prüfen/ändern (**sudo Passwort nötig**). +Agent could not run `firewall-cmd` (**sudo password required**). ```bash -# auf koopa (mit sudo) +# on koopa (with sudo) sudo firewall-cmd --permanent --add-port=1789/tcp sudo firewall-cmd --permanent --add-port=1789/udp sudo firewall-cmd --permanent --add-port=1790/tcp @@ -29,33 +29,37 @@ sudo firewall-cmd --list-ports --- -## I-2026-07-16-02 — Nym-Node nicht gebondet / nicht im Explorer +## I-2026-07-16-02 — Nym node not bonded / not in public explorers | | | |--|--| -| **Severity** | medium (ops / Sichtbarkeit im Nym-Netz) | +| **Severity** | medium (visibility on the Nym network) | | **Host** | koopa (`koopa-nym`) | | **Status** | open | -Container **läuft** (mixnode), API `9080` antwortet. -Ohne **Bond** in der Nym-Wallet erscheint die Node typischerweise **nicht** in Harbour Master / Explorer (anders als Tor nach ORPort-Publish). +Container **is running** (mixnode); API on **9080** answers. +Without a **bond** in the Nym wallet the node typically does **not** show up in Harbour Master / network explorers (unlike Tor after ORPort publish). -Logs: WARN `validator.nymtech.net/api/.../refresh-described` → **404** (API-Pfad/Version; Node mischt trotzdem). +See also: `configs/nym/PUBLIC-LISTING.md`. + +Logs: WARN `validator.nymtech.net/api/.../refresh-described` → **404** (API path/version; node still runs mixmode). + +**Identity key (public):** `55gPqeyHHj4CwpVZXLEQy9MjSvVTVXM8t2pMmNCH2MsW` --- -## I-2026-07-16-03 — `podman-restart.service` enabled, session noch inactive +## I-2026-07-16-03 — `podman-restart.service` enabled, inactive this session | | | |--|--| -| **Severity** | low (bis Reboot getestet) | +| **Severity** | low (until reboot is tested) | | **Host** | koopa (user hernani) | | **Status** | open / expected until reboot | `systemctl --user is-enabled podman-restart` = **enabled**, -`is-active` = **inactive** (oneshot, diese Boot-Session noch nicht gelaufen). +`is-active` = **inactive** (oneshot; has not run this boot session). -Nach Reboot prüfen: +After reboot: ```bash systemctl --user is-active podman-restart.service @@ -64,18 +68,17 @@ podman ps -a --filter should-start-on-boot=true --format '{{.Names}} {{.Status}} --- -## I-2026-07-16-04 — Streucontainer `kind_taussig` +## I-2026-07-16-04 — Stray container `kind_taussig` | | | |--|--| -| **Severity** | low (Hygiene) | +| **Severity** | low (hygiene) | | **Host** | koopa | | **Status** | open | -Container **Up** seit ~5 Tagen: `kind_taussig` (`debian:bookworm-slim`), -einmaliges Wallet-/DEB-Testskript, **`restart=no`**, kein Prod-Name. +Container **Up** ~5 days: `kind_taussig` (`debian:bookworm-slim`), one-off wallet/DEB test script, **`restart=no`**, not a production name. -Kandidat zum **Stoppen/Entfernen**, sobald Logs nicht mehr gebraucht werden: +Candidate to stop/remove when logs are no longer needed: ```bash podman stop kind_taussig @@ -84,40 +87,40 @@ podman rm kind_taussig --- -## I-2026-07-16-05 — Alte exited Podman-Container (Müll) +## I-2026-07-16-05 — Old exited Podman containers (clutter) | | | |--|--| -| **Severity** | low (Hygiene) | +| **Severity** | low (hygiene) | | **Host** | koopa | | **Status** | open | -Mehrere **Exited**-Container (Monate/Jahre alt), u. a. `pensive_dubinsky`, +Several **Exited** containers (months/years old), e.g. `pensive_dubinsky`, `hardcore_edison`, `taler-exchange-no-network`, `debian00`, … -Kein Autostart; belasten nur `podman ps -a`. +No autostart; only clutter `podman ps -a`. -Optional aufräumen: `podman container prune` (nur exited, nach Sichtprüfung). +Optional cleanup: `podman container prune` (exited only, after review). --- -## I-2026-07-16-06 — Nym HTTP-API `*:9080` lauscht host-weit +## I-2026-07-16-06 — Nym HTTP API listens on `*:9080` | | | |--|--| -| **Severity** | low–medium (Angriffsfläche) | +| **Severity** | low–medium (attack surface) | | **Host** | koopa | -| **Status** | open (bewusst so im compose) | +| **Status** | open (as configured in compose) | -`ss` zeigt **\*:9080** (rootlessport). **Kein** VeciGate-DNAT auf 9080. -Von WAN ohne NAT i. d. R. nicht erreichbar; von LAN erreichbar. +`ss` shows **\*:9080** (rootlessport). **No** VeciGate DNAT for 9080. +Usually not reachable from WAN without NAT; reachable from LAN. -Härten optional: Publish nur `127.0.0.1:9080:8080` in compose. +Optional harden: publish only `127.0.0.1:9080:8080` in compose. --- -## Erledigt / kein Issue (Scan) +## Not an issue (scan) -- Alle `should-start-on-boot` Prod-Container **Up** -- Keine failed user units -- Taler 9010–9015 antworten (401/302 ok) +- All production `should-start-on-boot` containers **Up** +- No failed user units +- Taler ports 9010–9015 respond (401/302 OK) - Linger=yes, container-* units enabled diff --git a/README.md b/README.md index d3aadb1..13bdec7 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ Ops log and config mirror for host **koopa** (openSUSE Tumbleweed). ## Open issues -**Sichtbare Issue-Liste:** [`ISSUES.md`](ISSUES.md) +**Visible issue list:** [`ISSUES.md`](ISSUES.md) ## Git origin diff --git a/configs/nym/PUBLIC-LISTING.md b/configs/nym/PUBLIC-LISTING.md new file mode 100644 index 0000000..880accb --- /dev/null +++ b/configs/nym/PUBLIC-LISTING.md @@ -0,0 +1,62 @@ +# Where is koopa listed publicly? (Nym / nym.com) + +Similar idea to **Tor relay directories**, but Nym uses different explorers and +requires a **bond** on-chain before the node shows up reliably. + +## Public directories / explorers + +| Site | Role | +|------|------| +| [https://harbourmaster.nymtech.net/](https://harbourmaster.nymtech.net/) | Operator / performance view (search by identity key or node id) | +| [https://explorer.nymtech.net/](https://explorer.nymtech.net/) | Network explorer (mixnodes / components) | +| [https://nym.com/explorer](https://nym.com/explorer) | Nym.com explorer UI | + +After bonding, a typical deep link shape is: + +`https://nym.com/explorer/nym-node/` + +or Harbour Master mixnode pages by numeric id once assigned. + +## This host (`koopa-nym`) + +| Field | Value | +|-------|--------| +| Container | `koopa-nym` (running, mode **mixnode**) | +| Announced public IP | `212.51.151.254` (WAN / VeciGate) | +| Mixnet | host **1789** tcp/udp | +| Verloc | host **1790** tcp | +| Local HTTP API | `http://127.0.0.1:9080` (not DNATed to WAN) | +| **Ed25519 identity (public)** | `55gPqeyHHj4CwpVZXLEQy9MjSvVTVXM8t2pMmNCH2MsW` | +| Location (aux) | `CH` | +| Operator T&Cs accepted | yes (run flag) | + +### How to re-read identity on the host + +```bash +podman exec -e HOME=/var/lib/nym koopa-nym \ + nym-node node-details --id koopa-nym +``` + +Search explorers with the **ed25519 identity** string above. + +## When it becomes visible + +1. Node process running (done). +2. Ports reachable from the internet (VeciGate DNAT + **firewalld** on koopa). +3. **Bond** the node with the Nym wallet (NYM stake / bond flow). +4. Wait for network / epoch updates; inactive nodes may lag in Harbour Master. + +Until step 3, public explorers often show **nothing** for this identity — that is expected. + +## Not the same as Tor + +| Tor | Nym | +|-----|-----| +| Relay Search / metrics after ORPort publish | Explorer / Harbour Master after **bond** + healthy announce | +| Nickname optional | Identity key is the stable public id | + +## Related + +- Ops mirror: `configs/nym/` +- Open issues: root [`ISSUES.md`](../../ISSUES.md) (I-2026-07-16-01, I-2026-07-16-02) +- VeciGate NAT: `vecigate-admin-log/ip/firewall/nat/README.md` diff --git a/configs/nym/README.md b/configs/nym/README.md index 55e089a..3b3c01d 100644 --- a/configs/nym/README.md +++ b/configs/nym/README.md @@ -59,3 +59,5 @@ Secrets layout (values in **koopa-admin-secrets**): | `~/koopa-nym/data/` | host-only (node identity; not git) | Path map: `koopa-admin-log/SECRETS.md`. + +**Public listing (explorers):** see [`PUBLIC-LISTING.md`](PUBLIC-LISTING.md). From bd5272ebac77418acdb1074c36ee1ccebb791847 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hern=C3=A2ni=20Marques?= Date: Thu, 16 Jul 2026 19:14:12 +0200 Subject: [PATCH 22/57] Automate Taler merchant and bank app start after container boot. Containers only run sleep infinity; host ensure-taler-apps plus user systemd units start postgres, httpd, libeufin, and auto-confirm. --- .../order.conf | 4 + .../apps.conf | 3 + .../apps.conf | 4 + configs/systemd/user/taler-bank-apps.service | 14 ++ .../systemd/user/taler-merchant-apps.service | 15 ++ scripts/taler-shared/ensure-taler-apps.sh | 163 ++++++++++++++++++ .../taler-shared/install-ensure-taler-apps.sh | 43 +++++ 7 files changed, 246 insertions(+) create mode 100644 configs/systemd/user/container-koopa-paivana.service.d/order.conf create mode 100644 configs/systemd/user/container-taler-hacktivism-bank.service.d/apps.conf create mode 100644 configs/systemd/user/container-taler-hacktivism.service.d/apps.conf create mode 100644 configs/systemd/user/taler-bank-apps.service create mode 100644 configs/systemd/user/taler-merchant-apps.service create mode 100644 scripts/taler-shared/ensure-taler-apps.sh create mode 100644 scripts/taler-shared/install-ensure-taler-apps.sh diff --git a/configs/systemd/user/container-koopa-paivana.service.d/order.conf b/configs/systemd/user/container-koopa-paivana.service.d/order.conf new file mode 100644 index 0000000..c0678cc --- /dev/null +++ b/configs/systemd/user/container-koopa-paivana.service.d/order.conf @@ -0,0 +1,4 @@ +[Unit] +# Paivana needs merchant private API — start after merchant apps, not only container shell +After=taler-merchant-apps.service container-taler-hacktivism.service +Wants=taler-merchant-apps.service diff --git a/configs/systemd/user/container-taler-hacktivism-bank.service.d/apps.conf b/configs/systemd/user/container-taler-hacktivism-bank.service.d/apps.conf new file mode 100644 index 0000000..5bcc386 --- /dev/null +++ b/configs/systemd/user/container-taler-hacktivism-bank.service.d/apps.conf @@ -0,0 +1,3 @@ +[Unit] +Wants=taler-bank-apps.service +Before=taler-bank-apps.service diff --git a/configs/systemd/user/container-taler-hacktivism.service.d/apps.conf b/configs/systemd/user/container-taler-hacktivism.service.d/apps.conf new file mode 100644 index 0000000..e831845 --- /dev/null +++ b/configs/systemd/user/container-taler-hacktivism.service.d/apps.conf @@ -0,0 +1,4 @@ +[Unit] +# Pull in in-container app start after the empty sleep infinity shell is up +Wants=taler-merchant-apps.service +Before=taler-merchant-apps.service diff --git a/configs/systemd/user/taler-bank-apps.service b/configs/systemd/user/taler-bank-apps.service new file mode 100644 index 0000000..7eb3ef2 --- /dev/null +++ b/configs/systemd/user/taler-bank-apps.service @@ -0,0 +1,14 @@ +[Unit] +Description=Start Taler bank apps inside taler-hacktivism-bank +Documentation=file:%h/src/koopa/koopa-admin-log/scripts/taler-shared/ensure-taler-apps.sh +After=network-online.target container-taler-hacktivism-bank.service +Wants=network-online.target container-taler-hacktivism-bank.service + +[Service] +Type=oneshot +RemainAfterExit=yes +TimeoutStartSec=300 +ExecStart=%h/.local/bin/ensure-taler-apps.sh bank + +[Install] +WantedBy=default.target diff --git a/configs/systemd/user/taler-merchant-apps.service b/configs/systemd/user/taler-merchant-apps.service new file mode 100644 index 0000000..22deacd --- /dev/null +++ b/configs/systemd/user/taler-merchant-apps.service @@ -0,0 +1,15 @@ +[Unit] +Description=Start Taler merchant apps inside taler-hacktivism +Documentation=file:%h/src/koopa/koopa-admin-log/scripts/taler-shared/ensure-taler-apps.sh +After=network-online.target container-taler-hacktivism.service +Wants=network-online.target container-taler-hacktivism.service + +[Service] +Type=oneshot +RemainAfterExit=yes +# Merchant start_base + helpers can take a while after cold boot +TimeoutStartSec=300 +ExecStart=%h/.local/bin/ensure-taler-apps.sh merchant + +[Install] +WantedBy=default.target diff --git a/scripts/taler-shared/ensure-taler-apps.sh b/scripts/taler-shared/ensure-taler-apps.sh new file mode 100644 index 0000000..ed59020 --- /dev/null +++ b/scripts/taler-shared/ensure-taler-apps.sh @@ -0,0 +1,163 @@ +#!/usr/bin/env bash +# Host (hernani@koopa): start Taler *apps inside* merchant/bank containers. +# +# Containers use CMD sleep infinity — podman start alone is not enough. +# Called by user systemd units after container-taler-*.service. +# +# Usage: +# ensure-taler-apps.sh # merchant + bank + helpers +# ensure-taler-apps.sh merchant +# ensure-taler-apps.sh bank +# ensure-taler-apps.sh status +set -euo pipefail + +MER_CTR="${MER_CTR:-taler-hacktivism}" +BANK_CTR="${BANK_CTR:-taler-hacktivism-bank}" +AUTO_LOOP_SECS="${AUTO_CONFIRM_LOOP_SECS:-2}" +WAIT_SECS="${WAIT_SECS:-90}" + +log() { printf '%s %s\n' "$(date -Iseconds)" "$*"; } + +# Oneshot container-*.service stays "active" after podman stop — always start here. +ensure_container() { + local name="$1" + if podman inspect -f '{{.State.Running}}' "$name" 2>/dev/null | grep -qx true; then + return 0 + fi + log "container $name: not running — podman start" + podman start "$name" >/dev/null +} + +wait_running() { + local name="$1" i + ensure_container "$name" + for i in $(seq 1 "$WAIT_SECS"); do + if podman inspect -f '{{.State.Running}}' "$name" 2>/dev/null | grep -qx true; then + return 0 + fi + sleep 1 + done + log "ERROR: container $name not running after ${WAIT_SECS}s" + return 1 +} + +ctr_has_proc() { + local ctr="$1" pattern="$2" + podman exec "$ctr" bash -lc "ps -eo args= | grep -F -- '$pattern' | grep -v grep" >/dev/null 2>&1 +} + +ensure_merchant() { + wait_running "$MER_CTR" + if ctr_has_proc "$MER_CTR" 'taler-merchant-httpd'; then + log "merchant: httpd already up" + return 0 + fi + log "merchant: start base (postgres/nginx)…" + podman exec -u root "$MER_CTR" \ + /root/start_base_services_for_taler.sh --no-shell + log "merchant: start_merchant.sh…" + podman exec -u root "$MER_CTR" \ + runuser -u taler-merchant-httpd -- /usr/local/bin/start_merchant.sh + log "merchant: done" +} + +ensure_bank() { + wait_running "$BANK_CTR" + if ! ctr_has_proc "$BANK_CTR" 'libeufin-bank'; then + log "bank: start base + libeufin-bank…" + podman exec -u root "$BANK_CTR" \ + /root/start_base_services_for_taler_bank.sh --no-shell --start-bank + else + log "bank: libeufin-bank already up" + fi + + # Landing nginx on :9013 (separate from bank API :9012) + if ! podman exec "$BANK_CTR" bash -lc "ss -tln 2>/dev/null | grep -q ':9013 '" 2>/dev/null; then + log "bank: start landing nginx :9013…" + podman exec -u root "$BANK_CTR" bash -lc ' + if [ -x /etc/init.d/nginx ]; then /etc/init.d/nginx start || true + else nginx || true + fi + ' + else + log "bank: nginx :9013 already listening" + fi + + ensure_bank_helpers + log "bank: done" +} + +# Single auto-confirm loop + demo-withdraw API (idempotent) +ensure_bank_helpers() { + local n + # Collapse duplicate loops (common after manual restarts) + n=$(podman exec "$BANK_CTR" bash -lc \ + "ps -eo pid=,args= | awk '/auto-confirm-withdrawals\\.sh --loop/ {print \$1}'" 2>/dev/null || true) + set -- $n + if [ "$#" -gt 1 ]; then + log "bank: stop $(($# - 1)) extra auto-confirm pid(s)…" + shift # keep first + for pid in "$@"; do + podman exec -u root "$BANK_CTR" kill "$pid" 2>/dev/null || true + done + sleep 0.3 + fi + if [ "$#" -eq 0 ]; then + log "bank: start auto-confirm --loop ${AUTO_LOOP_SECS}…" + podman exec -u root -d "$BANK_CTR" \ + /usr/local/bin/auto-confirm-withdrawals.sh --loop "$AUTO_LOOP_SECS" \ + || log "WARN: auto-confirm start failed (script missing?)" + else + log "bank: auto-confirm already running" + fi + + if podman exec "$BANK_CTR" test -f /usr/local/bin/demo-withdraw-api.py 2>/dev/null; then + if ! ctr_has_proc "$BANK_CTR" 'demo-withdraw-api.py'; then + log "bank: start demo-withdraw-api…" + podman exec -u root -d "$BANK_CTR" \ + python3 /usr/local/bin/demo-withdraw-api.py \ + || log "WARN: demo-withdraw-api start failed" + else + log "bank: demo-withdraw-api already running" + fi + fi +} + +status() { + echo "=== containers ===" + podman ps -a --filter name='taler-hacktivism' --format '{{.Names}} {{.Status}}' + echo "=== merchant procs (sample) ===" + podman exec "$MER_CTR" bash -lc "ps -eo args= | grep -E 'taler-merchant-httpd|postgres -D|nginx: master' | grep -v grep || true" 2>/dev/null || echo "(container down)" + echo "=== bank procs (sample) ===" + podman exec "$BANK_CTR" bash -lc "ps -eo args= | grep -E 'libeufin-bank|auto-confirm|demo-withdraw|nginx: master' | grep -v grep || true" 2>/dev/null || echo "(container down)" + echo "=== public ===" + for u in \ + https://taler.hacktivism.ch/config \ + https://taler.hacktivism.ch/intro/ \ + https://bank.hacktivism.ch/config \ + https://bank.hacktivism.ch/intro/ + do + code=$(curl -sk -o /dev/null -w '%{http_code}' --connect-timeout 4 --max-redirs 0 "$u" || echo err) + printf ' %s %s\n' "$code" "$u" + done +} + +cmd="${1:-all}" +case "$cmd" in + all|"") + ensure_merchant + ensure_bank + ;; + merchant) ensure_merchant ;; + bank) ensure_bank ;; + helpers) ensure_bank_helpers ;; + status) status ;; + -h|--help) + sed -n '2,16p' "$0" + exit 0 + ;; + *) + echo "unknown: $cmd (all|merchant|bank|helpers|status)" >&2 + exit 2 + ;; +esac diff --git a/scripts/taler-shared/install-ensure-taler-apps.sh b/scripts/taler-shared/install-ensure-taler-apps.sh new file mode 100644 index 0000000..60f2367 --- /dev/null +++ b/scripts/taler-shared/install-ensure-taler-apps.sh @@ -0,0 +1,43 @@ +#!/usr/bin/env bash +# Install ensure-taler-apps + user systemd units on this host (hernani@koopa). +# Run from admin-log checkout or with ADMIN_LOG set. +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/../.." && pwd)" +ADMIN_LOG="${ADMIN_LOG:-$ROOT}" +UNIT_SRC="$ADMIN_LOG/configs/systemd/user" +BIN_DST="${HOME}/.local/bin" +UNIT_DST="${HOME}/.config/systemd/user" + +mkdir -p "$BIN_DST" "$UNIT_DST" + +install -m 0755 "$ADMIN_LOG/scripts/taler-shared/ensure-taler-apps.sh" \ + "$BIN_DST/ensure-taler-apps.sh" + +install -m 0644 "$UNIT_SRC/taler-merchant-apps.service" "$UNIT_DST/" +install -m 0644 "$UNIT_SRC/taler-bank-apps.service" "$UNIT_DST/" + +mkdir -p \ + "$UNIT_DST/container-taler-hacktivism.service.d" \ + "$UNIT_DST/container-taler-hacktivism-bank.service.d" \ + "$UNIT_DST/container-koopa-paivana.service.d" + +install -m 0644 "$UNIT_SRC/container-taler-hacktivism.service.d/apps.conf" \ + "$UNIT_DST/container-taler-hacktivism.service.d/apps.conf" +install -m 0644 "$UNIT_SRC/container-taler-hacktivism-bank.service.d/apps.conf" \ + "$UNIT_DST/container-taler-hacktivism-bank.service.d/apps.conf" +install -m 0644 "$UNIT_SRC/container-koopa-paivana.service.d/order.conf" \ + "$UNIT_DST/container-koopa-paivana.service.d/order.conf" + +systemctl --user daemon-reload +systemctl --user enable taler-merchant-apps.service taler-bank-apps.service + +echo "Installed:" +echo " $BIN_DST/ensure-taler-apps.sh" +echo " $UNIT_DST/taler-{merchant,bank}-apps.service (enabled)" +echo " drop-ins: container-taler-hacktivism{,-bank}.service.d/apps.conf" +echo " drop-in: container-koopa-paivana.service.d/order.conf" +echo +echo "Start now (if containers already up):" +echo " systemctl --user start taler-merchant-apps.service taler-bank-apps.service" +echo " $BIN_DST/ensure-taler-apps.sh status" From b5e9b02146ac30450c22e32553e99b3290c770b5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hern=C3=A2ni=20Marques?= Date: Thu, 16 Jul 2026 19:59:02 +0200 Subject: [PATCH 23/57] Document reboot recovery and Taler in-container app autostart. Record post-reboot 502 causes and the ensure-taler-apps automation path. --- 2026/2026-07-16--container-autostart.md | 13 +++++++ 2026/2026-07-16--reboot-recovery.md | 51 +++++++++++++++++++++++++ 2026/2026-07-16.md | 2 + 3 files changed, 66 insertions(+) create mode 100644 2026/2026-07-16--reboot-recovery.md diff --git a/2026/2026-07-16--container-autostart.md b/2026/2026-07-16--container-autostart.md index ef27dae..840a329 100644 --- a/2026/2026-07-16--container-autostart.md +++ b/2026/2026-07-16--container-autostart.md @@ -34,4 +34,17 @@ systemctl --user is-enabled podman-restart.service podman ps -a --filter should-start-on-boot=true --format '{{.Names}} {{.Status}}' systemctl --user --failed +systemctl --user is-active taler-merchant-apps.service taler-bank-apps.service +~/.local/bin/ensure-taler-apps.sh status ``` + +## Taler apps inside containers (added same day, later) + +Merchant/bank containers only run **`sleep infinity`**. Autostart of the +*apps* is **not** podman-restart alone — see: + +- `scripts/taler-shared/ensure-taler-apps.sh` +- user units **`taler-merchant-apps.service`**, **`taler-bank-apps.service`** +- drop-ins under `configs/systemd/user/container-taler-*.service.d/apps.conf` +- install: `scripts/taler-shared/install-ensure-taler-apps.sh` +- narrative: `2026-07-16--reboot-recovery.md` diff --git a/2026/2026-07-16--reboot-recovery.md b/2026/2026-07-16--reboot-recovery.md new file mode 100644 index 0000000..e7d60aa --- /dev/null +++ b/2026/2026-07-16--reboot-recovery.md @@ -0,0 +1,51 @@ +# Reboot recovery — 2026-07-16 + +Host **koopa** rebooted (~20:23 CEST). Containers came up via +`unless-stopped` + user units, but **Taler app processes inside** merchant/bank +did **not** (containers run `sleep infinity`). + +## What was broken after boot + +| Symptom | Cause | Outcome | +|---------|--------|---------| +| `taler.hacktivism.ch/intro` **502** | only `sleep infinity` in merchant | fixed manually, then **automated** | +| `bank.hacktivism.ch/intro` **502** | bank same; landing nginx off | fixed + automated | +| Bank **auto-confirm** missing | not started after boot | automated (`--loop 2`) | +| `koopa-tor-relay` crash-loop | host **`tor.service`** binds **8080**/**9051** | still open → `ISSUES.md` | +| `koopa-paivana` **502** | merchant private API down at start | unit now **After** merchant-apps | +| Exchange / forgejo / bonfire / castopod / tops / nym / prime | mostly OK | — | + +## Automation (removes manual post-boot) + +Installed on koopa (user **hernani**): + +| Piece | Role | +|-------|------| +| `~/.local/bin/ensure-taler-apps.sh` | start base + merchant/bank apps + auto-confirm | +| `taler-merchant-apps.service` | oneshot after `container-taler-hacktivism` | +| `taler-bank-apps.service` | oneshot after `container-taler-hacktivism-bank` | +| drop-ins `container-taler-*.service.d/apps.conf` | `Wants=` the apps units | +| paivana `order.conf` | `After=taler-merchant-apps.service` | + +Repo sources: `scripts/taler-shared/`, `configs/systemd/user/`. +Install/reinstall: `scripts/taler-shared/install-ensure-taler-apps.sh`. + +```bash +# status / force now +systemctl --user start taler-merchant-apps.service taler-bank-apps.service +~/.local/bin/ensure-taler-apps.sh status +``` + +Auto-confirm poll: **2 s** (was 4 s). + +## Quick health + +```bash +curl -sk -o /dev/null -w '%{http_code}\n' https://taler.hacktivism.ch/config +curl -sk -o /dev/null -w '%{http_code}\n' https://taler.hacktivism.ch/intro/ +curl -sk -o /dev/null -w '%{http_code}\n' https://bank.hacktivism.ch/config +curl -sk -o /dev/null -w '%{http_code}\n' https://bank.hacktivism.ch/intro/ +curl -sk -o /dev/null -w '%{http_code}\n' https://exchange.hacktivism.ch/keys +``` + +Expected: **200** on config/intro/keys (site root may **302** → `/intro/`). diff --git a/2026/2026-07-16.md b/2026/2026-07-16.md index df8251d..7893b00 100644 --- a/2026/2026-07-16.md +++ b/2026/2026-07-16.md @@ -2,4 +2,6 @@ - **koopa-nym** (nym.com) mirror → `2026-07-16--koopa-nym.md` - **container autostart** (podman-restart + units) → `2026-07-16--container-autostart.md` +- **reboot recovery + Taler in-container app autostart** → `2026-07-16--reboot-recovery.md` + (`ensure-taler-apps.sh`, `taler-*-apps.service`, auto-confirm **2 s**) - **Open issues** → [`ISSUES.md`](../ISSUES.md) From c4c6a7f2c244bec91558086cd2921bb6dc837b56 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hern=C3=A2ni=20Marques?= Date: Thu, 16 Jul 2026 20:34:57 +0200 Subject: [PATCH 24/57] Update ISSUES for reboot apps mitigation and tor-relay clash. Mark Taler-apps post-reboot issue mitigated; document host vs container tor ports. --- ISSUES.md | 147 +++++++++++++++++++++++++++++------------------------- 1 file changed, 80 insertions(+), 67 deletions(-) diff --git a/ISSUES.md b/ISSUES.md index 2f70914..33f4f2a 100644 --- a/ISSUES.md +++ b/ISSUES.md @@ -1,24 +1,32 @@ # Open issues -As of **2026-07-16** (live scan on koopa). No secrets. +Stand **2026-07-16** (koopa, live). Keine Secrets. + +## Index + +| ID | Severity | Status | Kurz | +|----|----------|--------|------| +| [I-01](#i-2026-07-16-01) | high | open | firewalld: Nym-Ports unbestätigt | +| [I-02](#i-2026-07-16-02) | medium | open | Nym nicht bonded / nicht in Explorern | +| [I-03](#i-2026-07-16-03) | medium | mitigated | Taler-Apps nach Reboot | +| [I-04](#i-2026-07-16-04) | low | open | Stray `kind_taussig` | +| [I-05](#i-2026-07-16-05) | low | open | Alte exited Podman-Container | +| [I-06](#i-2026-07-16-06) | low–medium | open | Nym HTTP API `*:9080` | +| [I-07](#i-2026-07-16-07) | medium | **fixed** | Tor host vs `koopa-tor-relay` | --- -## I-2026-07-16-01 — firewalld: Nym ports on koopa unverified +### I-2026-07-16-01 -| | | -|--|--| -| **Severity** | high (for public Nym reachability) | -| **Host** | koopa | -| **Status** | open | +**firewalld: Nym-Ports auf koopa unbestätigt** +Severity: high · Host: koopa · Status: open -VeciGate DNATs **1789/tcp+udp**, **1790/tcp**, **51822/udp** → koopa. -Without matching **firewalld** allow rules on koopa, traffic dies on the host. +VeciGate DNAT **1789/tcp+udp**, **1790/tcp**, **51822/udp** → koopa. +Ohne passende **firewalld**-Allow-Regeln stirbt der Traffic am Host. -Agent could not run `firewall-cmd` (**sudo password required**). +Agent konnte `firewall-cmd` nicht ausführen (sudo-Passwort). ```bash -# on koopa (with sudo) sudo firewall-cmd --permanent --add-port=1789/tcp sudo firewall-cmd --permanent --add-port=1789/udp sudo firewall-cmd --permanent --add-port=1790/tcp @@ -29,98 +37,103 @@ sudo firewall-cmd --list-ports --- -## I-2026-07-16-02 — Nym node not bonded / not in public explorers +### I-2026-07-16-02 -| | | -|--|--| -| **Severity** | medium (visibility on the Nym network) | -| **Host** | koopa (`koopa-nym`) | -| **Status** | open | +**Nym-Node nicht bonded / nicht in öffentlichen Explorern** +Severity: medium · Host: koopa (`koopa-nym`) · Status: open -Container **is running** (mixnode); API on **9080** answers. -Without a **bond** in the Nym wallet the node typically does **not** show up in Harbour Master / network explorers (unlike Tor after ORPort publish). +Container läuft (mixnode); API auf **9080** antwortet. +Ohne **Bond** im Nym-Wallet erscheint der Node typischerweise nicht in Harbour Master / Explorern (anders als Tor nach ORPort-Publish). -See also: `configs/nym/PUBLIC-LISTING.md`. +Siehe `configs/nym/PUBLIC-LISTING.md`. -Logs: WARN `validator.nymtech.net/api/.../refresh-described` → **404** (API path/version; node still runs mixmode). +Logs: WARN `validator.nymtech.net/api/.../refresh-described` → **404** (API-Pfad; Node läuft trotzdem im Mixmode). **Identity key (public):** `55gPqeyHHj4CwpVZXLEQy9MjSvVTVXM8t2pMmNCH2MsW` --- -## I-2026-07-16-03 — `podman-restart.service` enabled, inactive this session +### I-2026-07-16-03 -| | | -|--|--| -| **Severity** | low (until reboot is tested) | -| **Host** | koopa (user hernani) | -| **Status** | open / expected until reboot | +**`podman-restart` / Taler-Apps nach Reboot** +Severity: medium → mitigated · Host: koopa (hernani) · Status: mitigated 2026-07-16 Abend -`systemctl --user is-enabled podman-restart` = **enabled**, -`is-active` = **inactive** (oneshot; has not run this boot session). +**Beobachtet:** Container up, aber Merchant/Bank nur `sleep infinity` → öffentlich **502** bis manuelles `start_base` / `start_*.sh`. -After reboot: +**Mitigation:** User-Units `taler-merchant-apps.service` / `taler-bank-apps.service` + `~/.local/bin/ensure-taler-apps.sh` +→ `2026/2026-07-16--reboot-recovery.md`. Nächster voller Reboot als Test. ```bash -systemctl --user is-active podman-restart.service -podman ps -a --filter should-start-on-boot=true --format '{{.Names}} {{.Status}}' +systemctl --user is-active podman-restart.service \ + taler-merchant-apps.service taler-bank-apps.service +~/.local/bin/ensure-taler-apps.sh status ``` --- -## I-2026-07-16-04 — Stray container `kind_taussig` +### I-2026-07-16-04 -| | | -|--|--| -| **Severity** | low (hygiene) | -| **Host** | koopa | -| **Status** | open | +**Stray-Container `kind_taussig`** +Severity: low · Host: koopa · Status: open -Container **Up** ~5 days: `kind_taussig` (`debian:bookworm-slim`), one-off wallet/DEB test script, **`restart=no`**, not a production name. - -Candidate to stop/remove when logs are no longer needed: +Container **Up** ~5 Tage: `kind_taussig` (`debian:bookworm-slim`), einmaliger Wallet/DEB-Test, `restart=no`, kein Prod-Name. ```bash -podman stop kind_taussig -podman rm kind_taussig +podman stop kind_taussig && podman rm kind_taussig ``` --- -## I-2026-07-16-05 — Old exited Podman containers (clutter) +### I-2026-07-16-05 -| | | -|--|--| -| **Severity** | low (hygiene) | -| **Host** | koopa | -| **Status** | open | +**Alte exited Podman-Container (Müll)** +Severity: low · Host: koopa · Status: open -Several **Exited** containers (months/years old), e.g. `pensive_dubinsky`, -`hardcore_edison`, `taler-exchange-no-network`, `debian00`, … -No autostart; only clutter `podman ps -a`. +Mehrere **Exited**-Container (Monate/Jahre), z. B. `pensive_dubinsky`, `hardcore_edison`, `taler-exchange-no-network`, `debian00`, … +Kein Autostart; nur Unordnung in `podman ps -a`. -Optional cleanup: `podman container prune` (exited only, after review). +Optional: `podman container prune` (nur exited, nach Review). --- -## I-2026-07-16-06 — Nym HTTP API listens on `*:9080` +### I-2026-07-16-06 -| | | -|--|--| -| **Severity** | low–medium (attack surface) | -| **Host** | koopa | -| **Status** | open (as configured in compose) | +**Nym HTTP-API lauscht auf `*:9080`** +Severity: low–medium · Host: koopa · Status: open (compose so konfiguriert) -`ss` shows **\*:9080** (rootlessport). **No** VeciGate DNAT for 9080. -Usually not reachable from WAN without NAT; reachable from LAN. +`ss` zeigt **\*:9080** (rootlessport). **Kein** VeciGate-DNAT für 9080 → WAN normalerweise nicht erreichbar; LAN ja. -Optional harden: publish only `127.0.0.1:9080:8080` in compose. +Härten optional: in compose nur `127.0.0.1:9080:8080` publishen. + +Hinweis: `nym-node` startet intern mit `--http-bind-address [::]:8080` (Container-intern). Öffentlich relevant ist das Host-Mapping **9080**, nicht WAN :8080 (das ist Tor OR). --- -## Not an issue (scan) +### I-2026-07-16-07 -- All production `should-start-on-boot` containers **Up** -- No failed user units -- Taler ports 9010–9015 respond (401/302 OK) -- Linger=yes, container-* units enabled +**`koopa-tor-relay` vs host `tor.service` (Port-Clash)** +Severity: medium · Host: koopa · Status: **fixed** 2026-07-16 + +**War:** Nach Reboot crash-loopte `koopa-tor-relay` — Host-`tor.service` hielt **8080**/**9051**. +**Soll:** nur Container (`KoopaRelay`, ORPort **8080**, VeciGate WAN→koopa:8080). + +**Fix:** Host-Tor gestoppt/disabled; ggf. hängenden Prozess gekillt; User-Unit: + +```bash +sudo systemctl disable --now tor +sudo systemctl mask tor # optional +systemctl --user enable --now container-koopa-tor-relay.service +ss -lntp | grep -E '8080|9051' +podman ps --filter name=koopa-tor-relay +``` + +**Live (nach Fix):** Host-`tor` inactive/disabled; `koopa-tor-relay` Up; 8080 + 127.0.0.1:9051 vom Relay-Prozess. + +--- + +## Kein Issue (Scan) + +- Prod-Container mit Boot-Autostart: Up +- Taler-Ports 9010–9015 antworten (401/302 OK) +- Linger=yes, container-*-Units enabled +- Tor-OR: Container-Pfad (I-07 fixed) From ffe8ad89dbc028615552704f77a8076ec8832943 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hern=C3=A2ni=20Marques?= Date: Thu, 16 Jul 2026 20:49:21 +0200 Subject: [PATCH 25/57] bank: raise DEFAULT_DEBT_LIMIT to libeufin amount ceiling. Allow large GOA exploration withdrawals up to GOA:4503599627370496. --- configs/taler-hacktivism-bank/README.md | 8 ++++++-- configs/taler-hacktivism-bank/bank-overrides.conf | 3 ++- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/configs/taler-hacktivism-bank/README.md b/configs/taler-hacktivism-bank/README.md index 1f51875..a9ace4f 100644 --- a/configs/taler-hacktivism-bank/README.md +++ b/configs/taler-hacktivism-bank/README.md @@ -57,7 +57,11 @@ Regional **GOA Exploration Bank** for GNU Taler: |-------|---------| | `admin` | bank admin (password on host `/root/bank-admin-password.txt`) | | `exchange` | Taler exchange wire account (`is_taler_exchange`) | -| `explorer` | demo user for phone withdraw (`debit_threshold` for regional GOA) | +| `explorer` | demo user for phone withdraw (`debit_threshold` = amount ceiling) | +| `DEFAULT_DEBT_LIMIT` | max overdraft for **new** accounts — set to libeufin ceiling `GOA:4503599627370496` (2026-07-16) | + +Existing accounts need a one-shot admin `PATCH /accounts/$user` with +`{"debit_threshold":"GOA:4503599627370496"}` after raising the default (done live for all ~196 accounts). ## Mobile withdraw (GOA) @@ -91,7 +95,7 @@ For the demo QR (`explorer`), automate step 2: # once /root/auto-confirm-withdrawals.sh # loop (e.g. every 4s) -nohup /root/auto-confirm-withdrawals.sh --loop 4 >>/var/log/auto-confirm-withdrawals.log 2>&1 & +nohup /root/auto-confirm-withdrawals.sh --loop 2 >>/var/log/auto-confirm-withdrawals.log 2>&1 & ``` Script: `scripts/taler-bank/auto-confirm-withdrawals.sh` diff --git a/configs/taler-hacktivism-bank/bank-overrides.conf b/configs/taler-hacktivism-bank/bank-overrides.conf index 83fb887..37023b5 100644 --- a/configs/taler-hacktivism-bank/bank-overrides.conf +++ b/configs/taler-hacktivism-bank/bank-overrides.conf @@ -10,7 +10,8 @@ NAME = "GOA Exploration Bank" SERVE = tcp PORT = 9012 BIND_TO = 0.0.0.0 -DEFAULT_DEBT_LIMIT = GOA:100000 +# Exploration: allow very large withdrawals (libeufin amount ceiling ≈ 2^52) +DEFAULT_DEBT_LIMIT = GOA:4503599627370496 ALLOW_REGISTRATION = yes ALLOW_CONVERSION = no REGISTRATION_BONUS = GOA:0 From 97b2fcce460d688c1516980bdec993467b94284c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hern=C3=A2ni=20Marques?= Date: Thu, 16 Jul 2026 20:49:53 +0200 Subject: [PATCH 26/57] bank: default auto-confirm poll interval to 2 seconds. Faster explorer pool confirmation for demo withdrawals. --- scripts/taler-bank/README.md | 11 +- .../taler-bank/auto-confirm-withdrawals.sh | 218 +++++++++++++----- .../taler-bank/install-demo-withdraw-api.sh | 23 +- 3 files changed, 186 insertions(+), 66 deletions(-) diff --git a/scripts/taler-bank/README.md b/scripts/taler-bank/README.md index df29e4b..bf18099 100644 --- a/scripts/taler-bank/README.md +++ b/scripts/taler-bank/README.md @@ -11,6 +11,7 @@ Container: **`taler-hacktivism-bank`** (libeufin-bank, GOA, **no IBAN**). | `landing-stats-install.sh` | host only | root/podman — copies + runs + optional cron | | `demo-withdraw-api.py` | `/usr/local/bin/` | root — loopback **:19096** | | `install-demo-withdraw-api.sh` | host only | installs API + nginx + auto-confirm | +| `maintenance/raise-debit-limits.sh` | host or bank ctr | raise all accounts’ `debit_threshold` (dry-run; `--no-dry`) | | `auto-confirm-withdrawals.sh` | `/usr/local/bin/` | root — **explorer-only** confirm loop | | `refresh-demo-withdraw.sh` | `/usr/local/bin/` | refresh static `withdraw.uri` | | `credit-account.sh` | host/ops | admin → user credit | @@ -60,12 +61,20 @@ Requires **python3** in the bank container. Env: `BANK_URL`, `BANK_USER`/`BANK_P ```bash # loop inside container — refuses non-explorer unless ALLOW_NON_EXPLORER=1 -auto-confirm-withdrawals.sh --loop 4 +auto-confirm-withdrawals.sh --loop 2 ``` Only confirms withdrawals owned by **`explorer`** when status is `selected` (community demo path). Does not confirm arbitrary customer withdraws. +**Stable ops (via `koopa-external` if LAN `koopa` is down):** + +- One process only (`flock` on `/var/run/auto-confirm-withdrawals.lock`) +- `QUIET=1` + summary `tick checked=… selected=…` each loop +- Watch list capped (`WATCH_MAX=80`, prune of bloated `withdraw-watch.ids`) +- Status via `taler-integration/withdrawal-operation/{id}` +- Reinstall: `./install-demo-withdraw-api.sh` on host with podman access to `taler-hacktivism-bank` + ## Config See `configs/taler-hacktivism-bank/` and `configs/bank-landing/`. diff --git a/scripts/taler-bank/auto-confirm-withdrawals.sh b/scripts/taler-bank/auto-confirm-withdrawals.sh index 3b9dc85..a46c523 100755 --- a/scripts/taler-bank/auto-confirm-withdrawals.sh +++ b/scripts/taler-bank/auto-confirm-withdrawals.sh @@ -1,33 +1,39 @@ #!/bin/bash -# Auto-confirm bank withdrawals for the community demo pool ONLY. -# -# Only account: explorer (override only if you really mean another pool user -# via BANK_USER, but still confirms with that user's token only — never -# confirms other customers' withdrawals). +# Auto-confirm bank withdrawals for the community demo pool ONLY (explorer). # # 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) — must be the pool account +# BANK_USER (default explorer) # BANK_PASS or /root/bank-explorer-password.txt # LANDING_DIR (default /var/www/bank-landing) -# ALLOW_NON_EXPLORER=1 — allow BANK_USER other than explorer (off by default) +# 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}" +LOCK_FILE="${LOCK_FILE:-/var/run/auto-confirm-withdrawals.lock}" LOOP=0 -SLEEP=5 +SLEEP=2 if [ "${1:-}" = "--loop" ]; then LOOP=1 - SLEEP="${2:-5}" + 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 -# Safety: only the shared community account unless explicitly overridden 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 @@ -41,14 +47,20 @@ if [ -z "$PASS" ]; then fi [ -n "$PASS" ] || { echo "no password for $USER" >&2; exit 1; } -# JSON field extract without python (bank container may lack python3) -json_str() { - # json_str FIELD < json-text-or-file - local field="$1" - local data - if [ -f "${2:-}" ]; then data=$(cat "$2"); else data=$(cat); fi - printf '%s' "$data" | sed -n "s/.*\"${field}\"[[:space:]]*:[[:space:]]*\"\\([^\"]*\\)\".*/\\1/p" | head -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" + exit 0 + fi + fi +fi + +log() { printf '%s\n' "$*"; } +qlog() { [ "${QUIET}" = "1" ] || log "$@"; } token() { curl -sS -m 12 -u "${USER}:${PASS}" \ @@ -57,83 +69,169 @@ token() { "${BANK}/accounts/${USER}/token" } -# IDs created for the community pool (landing + watch list only) +# 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 "${LANDING_DIR}/withdraw.uri" ]; then - basename "$(tr -d '\n' <"${LANDING_DIR}/withdraw.uri")" + if [ -f "$URI_FILE" ]; then + basename "$(tr -d '\n' <"$URI_FILE")" fi - if [ -f "${LANDING_DIR}/withdraw-watch.ids" ]; then - # strip empty / comments - grep -E '^[0-9a-fA-F-]{36}$' "${LANDING_DIR}/withdraw-watch.ids" || true + if [ -f "$WATCH_FILE" ]; then + grep -E '^[0-9a-fA-F-]{36}$' "$WATCH_FILE" || true fi } +# 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 - # Public status — must belong to explorer (pool), not another customer - info=$(curl -sS -m 10 "${BANK}/withdrawals/${wid}" 2>/dev/null || true) + info=$(status_json "$wid") [ -n "$info" ] || return 0 - st=$(printf '%s' "$info" | sed -n 's/.*"status"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -1) - uname=$(printf '%s' "$info" | sed -n 's/.*"username"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -1) + st=$(field status "$info") + uname=$(field username "$info") - # Only confirm withdrawals owned by the pool account if [ -n "$uname" ] && [ "$uname" != "$USER" ]; then - echo "skip $wid owner=$uname (only confirm $USER)" - return 0 - fi - # If API omits username, still only confirm via explorer token (cannot confirm others) - if [ -z "$uname" ]; then - # require sender_wire / payto to mention explorer when present - case "$info" in - *explorer*) ;; - *) - # still try only if status selected — confirm endpoint is under explorer account - ;; - esac - fi - - if [ "$st" != "selected" ]; then - echo "skip $wid status=${st:-?} owner=${uname:-?}" + qlog "skip $wid owner=$uname (only confirm $USER)" return 0 fi - echo "confirming $wid as $USER (community pool) ..." - 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") - echo " HTTP $conf $(head -c 200 /tmp/acw-conf.out 2>/dev/null || true)" - curl -sS -m 8 "${BANK}/withdrawals/${wid}" 2>/dev/null \ - | sed -n 's/.*"status"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/ now status=\1/p' | head -1 || true + case "$st" in + selected) + 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 + 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 - ids=$(known_ids | sort -u) + + # Cap work per loop: last WATCH_MAX ids only (newest at end of file) + 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 + } | awk 'NF && !seen[$0]++' ) + if [ -z "$ids" ]; then - echo "no withdrawal ids to watch (landing withdraw.uri / withdraw-watch.ids)" + qlog "no withdrawal ids to watch" return 0 fi + + : >"${LANDING_DIR}/.auto-confirm-selected.tmp" while read -r wid; do [ -n "$wid" ] || continue - confirm_one "$wid" "$tok" + 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 - echo "auto-confirm loop every ${SLEEP}s for pool user=$USER only" + 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" diff --git a/scripts/taler-bank/install-demo-withdraw-api.sh b/scripts/taler-bank/install-demo-withdraw-api.sh index 5366486..c4cd2e8 100755 --- a/scripts/taler-bank/install-demo-withdraw-api.sh +++ b/scripts/taler-bank/install-demo-withdraw-api.sh @@ -61,18 +61,31 @@ else fi ' -# start/restart API +# start/restart API + single auto-confirm loop (flock inside script) podman exec -u root "$CTR" bash -lc ' -pkill -f "demo-withdraw-api.py" 2>/dev/null || true +# stop demo-withdraw by pid (avoid pkill -f self-match) +ps -eo pid=,args= | awk "/demo-withdraw-api\\.py/ && !/awk/ {print \$1}" | while read p; do kill \$p 2>/dev/null || true; done +sleep 0.3 nohup python3 /usr/local/bin/demo-withdraw-api.py \ >>/var/log/demo-withdraw-api.log 2>&1 /dev/null || true -nohup /usr/local/bin/auto-confirm-withdrawals.sh --loop 4 \ +# stop auto-confirm by pid +ps -eo pid=,args= | awk "/auto-confirm-withdrawals\\.sh --loop/ && !/awk/ {print \$1}" | while read p; do kill \$p 2>/dev/null || true; done +sleep 0.5 +if [ -f /var/log/auto-confirm-withdrawals.log ]; then + sz=$(wc -c /tmp/ac-keep.log + : > /var/log/auto-confirm-withdrawals.log + cat /tmp/ac-keep.log >> /var/log/auto-confirm-withdrawals.log + fi +fi +nohup env QUIET=1 WATCH_MAX=80 \ + /usr/local/bin/auto-confirm-withdrawals.sh --loop 2 \ >>/var/log/auto-confirm-withdrawals.log 2>&1 Date: Thu, 16 Jul 2026 21:17:43 +0200 Subject: [PATCH 27/57] Add bank maintenance script to raise all account debit limits. mytops-style dry-run tool; PATCH debit_threshold for every libeufin account. --- scripts/README.md | 10 + scripts/taler-bank/maintenance/README.md | 47 +++ .../maintenance/raise-debit-limits.sh | 280 ++++++++++++++++++ 3 files changed, 337 insertions(+) create mode 100644 scripts/taler-bank/maintenance/README.md create mode 100755 scripts/taler-bank/maintenance/raise-debit-limits.sh diff --git a/scripts/README.md b/scripts/README.md index 5570301..70922e5 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -5,6 +5,8 @@ | `taler-merchant/` | podman `taler-hacktivism`: `/root`, `/usr/local/bin` | | `taler-exchange/` | podman `taler-hacktivism-exchange-ansible`: `/root`, `/usr/local/bin` | | `taler-hacktivism-bank/` | podman `taler-hacktivism-bank`: `/root`, `/usr/local/bin` | +| `taler-bank/` | bank host helpers + **`maintenance/`** (debit limits, dry-run) | +| `taler-shared/` | host **ensure-taler-apps** (post-container app start + auto-confirm) | | `taler-sanity/` | host root checks (stack, settlement, helpers) | | `taler-monitoring/` | **outside-in** public URL walk (`/config` → keys/terms/integration/webui) | | `monitoring/` | host `/home/hernani/scripts` (tor relay stats) | @@ -14,6 +16,14 @@ **Secrets:** never in this tree — sibling **`../koopa-admin-secrets`** (`koopa/host-root//` ↔ `/root/` on host; `containers/…/secrets/` for in-container). +## Boot automation (host user systemd) + +Merchant/bank containers stay **`sleep infinity`**. After the container unit +starts, **`taler-merchant-apps.service`** / **`taler-bank-apps.service`** run +`~/.local/bin/ensure-taler-apps.sh` (install via +`taler-shared/install-ensure-taler-apps.sh`). That runs the in-container +`start_base` + `start_*.sh` (and bank auto-confirm **2 s**). + ## Manual start model (all three) 1. **root** runs `/root/start_base_services_for_taler_*.sh` diff --git a/scripts/taler-bank/maintenance/README.md b/scripts/taler-bank/maintenance/README.md new file mode 100644 index 0000000..b129260 --- /dev/null +++ b/scripts/taler-bank/maintenance/README.md @@ -0,0 +1,47 @@ +# taler-bank maintenance + +One-shot ops tools for **libeufin-bank** on koopa (`taler-hacktivism-bank`). +Pattern matches **mytops-admin-log** `scripts/taler-merchant/maintenance/`: **dry-run by default**, `--no-dry` to apply. + +## `raise-debit-limits.sh` + +Raise **`debit_threshold`** on every bank account (overdraft / withdraw headroom). +Optionally set **`DEFAULT_DEBT_LIMIT`** for new accounts. + +| | | +|--|--| +| **Default amount** | `GOA:4503599627370496` (libeufin amount ceiling ≈ 2⁵²) | +| **Auth** | admin password: `BANK_ADMIN_PASS` or `bank-admin-password.txt` | +| **Bank** | `http://127.0.0.1:9012` on koopa, or `podman exec` into `taler-hacktivism-bank` | + +### Usage + +```bash +# on koopa (hernani), after bank is up: +cd ~/path/to/koopa-admin-log # or copy script + +# preview +./scripts/taler-bank/maintenance/raise-debit-limits.sh + +# apply to all accounts +./scripts/taler-bank/maintenance/raise-debit-limits.sh --no-dry + +# apply + DEFAULT_DEBT_LIMIT in container overrides + bank restart +./scripts/taler-bank/maintenance/raise-debit-limits.sh --no-dry --set-default + +# custom amount / single user +./scripts/taler-bank/maintenance/raise-debit-limits.sh --no-dry --amount GOA:1000000 --only explorer +``` + +Via container if host cannot reach :9012: + +```bash +podman exec -u root -i taler-hacktivism-bank bash -s -- --no-dry \ + < scripts/taler-bank/maintenance/raise-debit-limits.sh +``` + +### Notes + +- Does **not** invent balance; only raises how far accounts may go into **debit**. +- Exchange coin denominations (largest `GOA:1000`) are separate — large withdraws still work with many coins. +- Secrets never live in this repo — see `SECRETS.md` / `koopa-admin-secrets`. diff --git a/scripts/taler-bank/maintenance/raise-debit-limits.sh b/scripts/taler-bank/maintenance/raise-debit-limits.sh new file mode 100755 index 0000000..648924c --- /dev/null +++ b/scripts/taler-bank/maintenance/raise-debit-limits.sh @@ -0,0 +1,280 @@ +#!/usr/bin/env bash +# Raise libeufin-bank debit thresholds for all accounts (and optionally the default). +# +# Style: like mytops-admin-log scripts/taler-merchant/maintenance/*.sh +# dry-run by default; pass --no-dry to apply. +# +# Where to run: +# - on koopa host (preferred): talks to BANK_URL (default http://127.0.0.1:9012) +# - or: podman exec -u root -i taler-hacktivism-bank bash -s < this-script -- --no-dry +# +# Auth (first match wins): +# BANK_ADMIN_PASS +# /root/bank-admin-password.txt (inside bank container or host root secrets mirror) +# $KOOPA_SECRETS/.../bank-admin-password.txt +# ../koopa-admin-secrets/koopa/host-root/taler-bank/bank-admin-password.txt (relative to repo) +# +# Env / flags: +# --no-dry apply changes +# --amount GOA:N threshold (default: libeufin ceiling GOA:4503599627370496) +# --set-default also set DEFAULT_DEBT_LIMIT in bank-overrides.conf (+ bank restart) +# --only USER[,USER] only these usernames +# BANK_URL default http://127.0.0.1:9012 +# BANK_CTR if set and BANK not reachable on host, use podman exec into container +# (default: taler-hacktivism-bank when host curl fails) +set -euo pipefail + +echo "[INFO] raise-debit-limits — libeufin-bank account debit_threshold" +echo "[INFO] Dry-run unless --no-dry. Tested against bank.hacktivism.ch (GOA)." + +DRY_RUN=true +SET_DEFAULT=false +AMOUNT="${AMOUNT:-GOA:4503599627370496}" +ONLY_USERS="" +BANK_URL="${BANK_URL:-http://127.0.0.1:9012}" +BANK_URL="${BANK_URL%/}" +BANK_CTR="${BANK_CTR:-taler-hacktivism-bank}" +OVERRIDE_CONF="${OVERRIDE_CONF:-/etc/libeufin/bank-overrides.conf}" + +usage() { + sed -n '2,30p' "$0" | sed 's/^# \{0,1\}//' + exit 0 +} + +while [[ $# -gt 0 ]]; do + case "$1" in + --no-dry) DRY_RUN=false; echo "[WARN] Dry-run disabled — will PATCH accounts."; shift ;; + --set-default) SET_DEFAULT=true; shift ;; + --amount) AMOUNT="${2:?}"; shift 2 ;; + --only) ONLY_USERS="${2:?}"; shift 2 ;; + -h|--help) usage ;; + *) + echo "[ERROR] unknown arg: $1" >&2 + exit 2 + ;; + esac +done + +if $DRY_RUN; then + echo "[INFO] Dry-run active (no HTTP PATCH, no conf write)." +else + echo "[WARN] APPLY mode amount=${AMOUNT}" +fi + +# --- admin password --- +find_admin_pass() { + if [[ -n "${BANK_ADMIN_PASS:-}" ]]; then + printf '%s' "$BANK_ADMIN_PASS" + return 0 + fi + local f + for f in \ + /root/bank-admin-password.txt \ + "${KOOPA_SECRETS:-}/koopa/host-root/taler-bank/bank-admin-password.txt" \ + "$(cd "$(dirname "$0")/../../../.." 2>/dev/null && pwd)/koopa-admin-secrets/koopa/host-root/taler-bank/bank-admin-password.txt" \ + "$HOME/src/koopa/koopa-admin-secrets/koopa/host-root/taler-bank/bank-admin-password.txt" + do + [[ -n "$f" && -f "$f" && -r "$f" ]] || continue + tr -d '\n' <"$f" + return 0 + done + # hernani@koopa: secret lives in the bank container + if command -v podman >/dev/null 2>&1 \ + && podman inspect -f '{{.State.Running}}' "${BANK_CTR:-taler-hacktivism-bank}" 2>/dev/null | grep -qx true; then + podman exec "${BANK_CTR:-taler-hacktivism-bank}" cat /root/bank-admin-password.txt 2>/dev/null | tr -d '\n' + return 0 + fi + return 1 +} + +ADMIN_PASS="$(find_admin_pass)" || { + echo "[ERROR] no admin password (BANK_ADMIN_PASS or bank-admin-password.txt)" >&2 + exit 1 +} + +# Prefer host URL; fall back to podman exec curl inside bank container +USE_PODMAN=false +if ! curl -sf -m 3 "${BANK_URL}/config" >/dev/null 2>&1; then + if command -v podman >/dev/null 2>&1 && podman inspect -f '{{.State.Running}}' "$BANK_CTR" 2>/dev/null | grep -qx true; then + echo "[INFO] ${BANK_URL} not reachable — using podman exec ${BANK_CTR}" + USE_PODMAN=true + BANK_URL="http://127.0.0.1:9012" + else + echo "[ERROR] bank not reachable at ${BANK_URL} and container ${BANK_CTR} not running" >&2 + exit 1 + fi +fi + +bank_curl() { + # bank_curl [curl-args...] URL_PATH_or_absolute + # Last arg is URL path starting with / or full URL + local args=("$@") + local n=$((${#args[@]} - 1)) + local url="${args[$n]}" + unset "args[$n]" + if [[ "$url" != http* ]]; then + url="${BANK_URL}${url}" + fi + if $USE_PODMAN; then + podman exec -u root -i "$BANK_CTR" curl -sS -m 30 "${args[@]}" "$url" + else + curl -sS -m 30 "${args[@]}" "$url" + fi +} + +bank_curl_code() { + # like bank_curl but print HTTP code on stdout after body to fd3... simpler: write body to file + local out="$1"; shift + local args=("$@") + local n=$((${#args[@]} - 1)) + local url="${args[$n]}" + unset "args[$n]" + if [[ "$url" != http* ]]; then + url="${BANK_URL}${url}" + fi + if $USE_PODMAN; then + podman exec -u root -i "$BANK_CTR" curl -sS -m 30 -o /tmp/raise-debt-body -w '%{http_code}' "${args[@]}" "$url" + else + curl -sS -m 30 -o "$out" -w '%{http_code}' "${args[@]}" "$url" + fi +} + +echo "[INFO] bank=${BANK_URL} podman=${USE_PODMAN} amount=${AMOUNT}" + +TOK_JSON=$(bank_curl -u "admin:${ADMIN_PASS}" -H 'Content-Type: application/json' \ + -d '{"scope":"readwrite"}' /accounts/admin/token) +TOKEN=$(printf '%s' "$TOK_JSON" | python3 -c 'import sys,json; print(json.load(sys.stdin)["access_token"])') +[[ -n "$TOKEN" ]] || { echo "[ERROR] admin token failed: $TOK_JSON" >&2; exit 1; } +echo "[INFO] admin token OK" + +ACCS_JSON=$(bank_curl -H "Authorization: Bearer ${TOKEN}" "/accounts?limit=500") +export ACCS_JSON AMOUNT ONLY_USERS DRY_RUN TOKEN +export BANK_URL USE_PODMAN BANK_CTR + +python3 <<'PY' +import json, os, sys, subprocess, urllib.request + +amount = os.environ["AMOUNT"] +only = {u.strip() for u in os.environ.get("ONLY_USERS", "").split(",") if u.strip()} +dry = os.environ.get("DRY_RUN", "true").lower() in ("1", "true", "yes") +token = os.environ["TOKEN"] +bank = os.environ["BANK_URL"].rstrip("/") +use_podman = os.environ.get("USE_PODMAN", "false").lower() in ("1", "true", "yes") +ctr = os.environ.get("BANK_CTR", "taler-hacktivism-bank") + +accs = json.loads(os.environ["ACCS_JSON"]).get("accounts") or [] +if only: + accs = [a for a in accs if a.get("username") in only] +print(f"[INFO] accounts to process: {len(accs)}") + +def http_patch(user: str, body: dict) -> int: + data = json.dumps(body).encode() + url = f"{bank}/accounts/{user}" + if use_podman: + # avoid putting token in process list longer than needed — still visible + cmd = [ + "podman", "exec", "-u", "root", "-i", ctr, + "curl", "-sS", "-m", "30", "-o", "/dev/null", "-w", "%{http_code}", + "-X", "PATCH", + "-H", f"Authorization: Bearer {token}", + "-H", "Content-Type: application/json", + "-d", json.dumps(body), + url.replace(bank, "http://127.0.0.1:9012") if bank.startswith("http") else f"http://127.0.0.1:9012/accounts/{user}", + ] + # always use in-container localhost for podman path + cmd[-1] = f"http://127.0.0.1:9012/accounts/{user}" + out = subprocess.check_output(cmd, text=True).strip() + return int(out) + req = urllib.request.Request( + url, + data=data, + method="PATCH", + headers={ + "Authorization": f"Bearer {token}", + "Content-Type": "application/json", + }, + ) + try: + with urllib.request.urlopen(req, timeout=30) as r: + return r.status + except Exception as e: + code = getattr(e, "code", None) + if code is not None: + return int(code) + print(f"[ERROR] {user}: {e}", file=sys.stderr) + return 0 + +ok = fail = skip = 0 +for a in accs: + u = a["username"] + old = a.get("debit_threshold") or "?" + bal = a.get("balance") or {} + if old == amount: + print(f"[SKIP] {u}: already {amount}") + skip += 1 + continue + if dry: + print(f"[DRY] {u}: {old} -> {amount} (balance={bal})") + ok += 1 + continue + code = http_patch(u, {"debit_threshold": amount}) + if code in (200, 204): + print(f"[EXECUTE] {u}: {old} -> {amount} HTTP {code}") + ok += 1 + else: + print(f"[FAIL] {u}: {old} -> {amount} HTTP {code}") + fail += 1 + +print(f"[INFO] done ok={ok} skip={skip} fail={fail} dry={dry}") +sys.exit(1 if fail else 0) +PY + +# Optional: DEFAULT_DEBT_LIMIT in overrides (new accounts) +if $SET_DEFAULT; then + echo "[INFO] --set-default: DEFAULT_DEBT_LIMIT in ${OVERRIDE_CONF}" + if $USE_PODMAN; then + conf_path="$OVERRIDE_CONF" + if $DRY_RUN; then + echo "[DRY] sed DEFAULT_DEBT_LIMIT = ${AMOUNT} in container:${conf_path}" + echo "[DRY] restart libeufin-bank serve" + else + podman exec -u root "$BANK_CTR" bash -lc " + set -e + conf='$OVERRIDE_CONF' + cp -a \"\$conf\" \"\${conf}.bak-\$(date +%Y%m%d%H%M%S)\" + if grep -q '^DEFAULT_DEBT_LIMIT' \"\$conf\"; then + sed -i 's/^DEFAULT_DEBT_LIMIT = .*/DEFAULT_DEBT_LIMIT = ${AMOUNT}/' \"\$conf\" + else + echo 'DEFAULT_DEBT_LIMIT = ${AMOUNT}' >>\"\$conf\" + fi + grep '^DEFAULT_DEBT_LIMIT' \"\$conf\" + # restart bank process (manual stack) + pids=\$(ps -eo pid=,args= | awk '/libeufin-bank serve/ && !/awk/ {print \$1}') + for p in \$pids; do kill \$p 2>/dev/null || true; done + sleep 1 + runuser -u libeufin-bank -- nohup /usr/bin/libeufin-bank serve -c /etc/libeufin/libeufin-bank.conf \ + >>/var/log/libeufin-bank/serve.log 2>&1 & + for i in \$(seq 1 30); do + curl -sf -m 2 http://127.0.0.1:9012/config >/dev/null && break + sleep 0.5 + done + curl -sS http://127.0.0.1:9012/config | python3 -c 'import sys,json; d=json.load(sys.stdin); print(\"[INFO] default_debit_threshold\", d.get(\"default_debit_threshold\"))' + " + echo "[EXECUTE] DEFAULT_DEBT_LIMIT + bank restart" + fi + else + if [[ ! -f "$OVERRIDE_CONF" ]]; then + echo "[WARN] ${OVERRIDE_CONF} not on host — skip --set-default (use from inside container or with podman path)" + elif $DRY_RUN; then + echo "[DRY] sed DEFAULT_DEBT_LIMIT = ${AMOUNT} in ${OVERRIDE_CONF}" + else + cp -a "$OVERRIDE_CONF" "${OVERRIDE_CONF}.bak-$(date +%Y%m%d%H%M%S)" + sed -i "s/^DEFAULT_DEBT_LIMIT = .*/DEFAULT_DEBT_LIMIT = ${AMOUNT}/" "$OVERRIDE_CONF" + echo "[EXECUTE] wrote ${OVERRIDE_CONF}" + grep '^DEFAULT_DEBT_LIMIT' "$OVERRIDE_CONF" || true + echo "[WARN] restart libeufin-bank yourself if conf is mounted from host" + fi + fi +fi + +echo "[INFO] Done." From 4dbede51b45abb6c72c750df30e39c0430e3d197 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hern=C3=A2ni=20Marques?= Date: Thu, 16 Jul 2026 23:10:02 +0200 Subject: [PATCH 28/57] bank: standalone GOA withdraw ladder helper script. Fixed zero and max endpoints with random intermediate amounts for local tests. --- scripts/taler-bank/goa-withdraw-ladder.sh | 305 ++++++++++++++++++++++ 1 file changed, 305 insertions(+) create mode 100755 scripts/taler-bank/goa-withdraw-ladder.sh diff --git a/scripts/taler-bank/goa-withdraw-ladder.sh b/scripts/taler-bank/goa-withdraw-ladder.sh new file mode 100755 index 0000000..f66925c --- /dev/null +++ b/scripts/taler-bank/goa-withdraw-ladder.sh @@ -0,0 +1,305 @@ +#!/usr/bin/env bash +# Systematic GOA withdraw ladder on bank.hacktivism.ch +# +# Per landing / intro docs: +# 1) GET /intro/auto-account.json → personal goa-account-* (+ first pool URI) +# 2) Shared pool withdrawals (explorer) with server auto-confirm +# 3) wallet-cli: ToS + accept-uri + run-until-done each rung +# 4) amounts from atomic-GOA up until bank/wallet fails +# +# Prefer the monitoring phase (random increasing ranges + timings + report): +# ../taler-monitoring/taler-monitoring.sh ladder +# LADDER_MAX_RUNGS=10 ../taler-monitoring/check_goa_ladder.sh +# +# Usage (standalone, fixed ladder): +# ./goa-withdraw-ladder.sh +# MAX_RUNGS=12 ./goa-withdraw-ladder.sh +# AMOUNTS='GOA:0.000001 GOA:0.01 GOA:1 GOA:10 GOA:100' ./goa-withdraw-ladder.sh +# +# Env: +# BANK default https://bank.hacktivism.ch +# EXCHANGE default https://exchange.hacktivism.ch/ +# EXP_USER default explorer +# EXP_PW_FILE explorer password file (for mint + confirm) +# WALLET_CLI path to taler-wallet-cli.mjs or binary +# WDB wallet sqlite path +# SHOTDIR result directory +set -euo pipefail +export PATH="/tmp/py313bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin:${PATH}" + +BANK="${BANK:-https://bank.hacktivism.ch}" +BANK="${BANK%/}" +EXCHANGE="${EXCHANGE:-https://exchange.hacktivism.ch/}" +EXP_USER="${EXP_USER:-explorer}" +EXP_PW_FILE="${EXP_PW_FILE:-/Users/newkamek/src/koopa/koopa-admin-secrets/koopa/host-root/taler-bank/bank-explorer-password.txt}" +SHOTDIR="${SHOTDIR:-/tmp/goa-ladder-$(date +%Y%m%d-%H%M%S)}" +WDB="${WDB:-$SHOTDIR/wallet.db}" +MAX_RUNGS="${MAX_RUNGS:-24}" +CLI_JS="${WALLET_CLI_JS:-/Users/newkamek/src/taler-typescript-core/packages/taler-wallet-cli/bin/taler-wallet-cli.mjs}" +mkdir -p "$SHOTDIR" +LOG="$SHOTDIR/ladder.log" +RESULTS="$SHOTDIR/results.tsv" +echo -e "rung\tamount\tstatus\twid\tnote" >"$RESULTS" + +log() { printf '%s\n' "$*" | tee -a "$LOG"; } +die() { log "ERROR: $*"; exit 1; } + +wcli() { + if [ -f "$CLI_JS" ]; then + node "$CLI_JS" --wallet-db="$WDB" --no-throttle "$@" + else + taler-wallet-cli --wallet-db="$WDB" --no-throttle "$@" + fi +} + +# Default ladder via Python: fixed 0 + random low picks + random high ranges + fixed max. +# Prefer taler-monitoring check_goa_ladder.sh for full control (LADDER_* env). +default_amounts() { + python3 - <<'PY' +import math, random +from decimal import Decimal, ROUND_HALF_UP + +MAX = Decimal("4503599627370496") +HIGH_FROM = Decimal("1000000") +HIGH_RUNGS = 12 +CUR = "GOA" + +def fmt(v: Decimal) -> str: + q = v.quantize(Decimal("0.00000001"), rounding=ROUND_HALF_UP) + if q == q.to_integral(): + return "%s:%s" % (CUR, format(int(q), "d")) + return "%s:%s" % (CUR, format(q, "f").rstrip("0").rstrip(".")) + +def logu(lo, hi): + lo = max(lo, 1e-12) + if hi <= lo: + return hi + return math.exp(random.uniform(math.log(lo), math.log(hi))) + +out = [fmt(Decimal(0))] +prev = Decimal(0) +# low bands (same idea as monitoring LADDER_RANGES without 0:0) +bands = [ + (1e-6, 9e-6), (1e-5, 9e-5), (1e-4, 9e-4), (1e-3, 9e-3), (0.01, 0.09), + (0.1, 0.9), (1, 9), (10, 49), (50, 99), (100, 499), (500, 999), + (1000, 4999), (5000, 9999), (1e4, 5e4), (5e4, 1e5), (1e5, 5e5), (5e5, 2e6), +] +for lo, hi in bands: + lo2 = max(lo, float(prev) * 1.0000001 if prev > 0 else lo) + if lo2 > hi: + continue + v = Decimal(str(logu(lo2, hi))).quantize(Decimal("0.00000001")) + if v <= prev: + continue + if v >= MAX: + continue + out.append(fmt(v)) + prev = v + +band_lo = max(float(HIGH_FROM), float(prev) * 1.0000001) +band_hi = float(MAX - 1) +if band_lo < band_hi and HIGH_RUNGS > 0: + cuts = [band_lo, band_hi] + for _ in range(HIGH_RUNGS - 1): + cuts.append(logu(band_lo, band_hi)) + cuts = sorted(set(cuts)) + while len(cuts) < HIGH_RUNGS + 1: + cuts.append(logu(band_lo, band_hi)) + cuts = sorted(set(cuts)) + segs = [(cuts[i], cuts[i + 1]) for i in range(len(cuts) - 1) if cuts[i + 1] > cuts[i] * 1.001] + segs.sort(key=lambda t: t[0]) + for r_lo, r_hi in segs[:HIGH_RUNGS]: + floor = max(r_lo, float(prev) * 1.0000001) + if floor >= r_hi: + continue + v = Decimal(str(logu(floor, r_hi))).quantize(Decimal(1)) + if v <= prev or v >= MAX: + continue + out.append(fmt(v)) + prev = v + +out.append(fmt(MAX)) +print("\n".join(out)) +PY +} + +if [ -n "${AMOUNTS:-}" ]; then + # shellcheck disable=SC2206 + LADDER=($AMOUNTS) +else + LADDER=() + while IFS= read -r line; do + [ -n "$line" ] || continue + LADDER+=("$line") + [ "${#LADDER[@]}" -ge "$MAX_RUNGS" ] && break + done < <(default_amounts) +fi + +[ -f "$EXP_PW_FILE" ] || die "missing explorer password: $EXP_PW_FILE" +EXP_PW="$(tr -d '\n' <"$EXP_PW_FILE")" + +explorer_token() { + curl -sS -m 20 -u "${EXP_USER}:${EXP_PW}" \ + -H 'Content-Type: application/json' \ + -d '{"scope":"readwrite","duration":{"d_us":3600000000}}' \ + "${BANK}/accounts/${EXP_USER}/token" \ + | python3 -c 'import json,sys; print(json.load(sys.stdin)["access_token"])' +} + +confirm_when_selected() { + local wid="$1" tok="$2" + local i st + for i in $(seq 1 30); do + st=$(curl -sS -m 12 "${BANK}/taler-integration/withdrawal-operation/${wid}" \ + | python3 -c 'import json,sys; print(json.load(sys.stdin).get("status",""))' 2>/dev/null || true) + case "$st" in + selected) + code=$(curl -sS -m 20 -o "$SHOTDIR/conf-${wid}.json" -w '%{http_code}' -X POST \ + -H "Authorization: Bearer ${tok}" \ + -H 'Content-Type: application/json' -d '{}' \ + "${BANK}/accounts/${EXP_USER}/withdrawals/${wid}/confirm") + log " auto-confirm HTTP $code (status was selected)" + return 0 + ;; + confirmed) + log " already confirmed" + return 0 + ;; + aborted) + log " aborted" + return 1 + ;; + esac + sleep 1 + done + log " timeout waiting for selected (last=$st)" + return 1 +} + +mint_withdraw() { + local amount="$1" + curl -sS -m 30 -H "Authorization: Bearer ${TOK}" \ + -H 'Content-Type: application/json' \ + -d "{\"amount\":\"${amount}\"}" \ + "${BANK}/accounts/${EXP_USER}/withdrawals" +} + +wallet_avail() { + wcli balance 2>/dev/null | python3 -c ' +import json,sys,re +t=sys.stdin.read() +i=t.find("{") +if i<0: print("0"); raise SystemExit +d=json.loads(t[i:t.rfind("}")+1]) +for b in d.get("balances") or []: + a=b.get("available") or "" + if a.startswith("GOA:"): + print(a.split(":",1)[1]); raise SystemExit +print("0") +' 2>/dev/null || echo "0" +} + +# --- main --- +log "SHOTDIR=$SHOTDIR" +log "BANK=$BANK EXCHANGE=$EXCHANGE" +log "ladder (${#LADDER[@]} rungs): ${LADDER[*]}" + +log "=== 1) auto-account (bank.hacktivism.ch/intro) ===" +curl -sS -m 30 "${BANK}/intro/auto-account.json" | tee "$SHOTDIR/auto-account.json" | python3 -m json.tool | tee -a "$LOG" | head -40 +ACCT_USER=$(python3 -c 'import json; print(json.load(open("'"$SHOTDIR"'/auto-account.json"))["username"])') +ACCT_PASS=$(python3 -c 'import json; print(json.load(open("'"$SHOTDIR"'/auto-account.json"))["password"])') +log "personal account: $ACCT_USER (balance GOA:0 — pool withdraws use explorer + auto-confirm)" +echo "$ACCT_USER" >"$SHOTDIR/account-user.txt" +echo "$ACCT_PASS" >"$SHOTDIR/account-pass.txt" + +log "=== 2) explorer token + wallet init ===" +TOK=$(explorer_token) +log "explorer token ok" + +wcli exchanges add "$EXCHANGE" 2>&1 | tee "$SHOTDIR/ex-add.out" | tail -5 || true +wcli exchanges update "$EXCHANGE" 2>&1 | tee "$SHOTDIR/ex-upd.out" | tail -5 || true +wcli exchanges accept-tos "$EXCHANGE" 2>&1 | tee "$SHOTDIR/ex-tos.out" | tail -5 || true + +n=0 +fail_rung="" +for amt in "${LADDER[@]}"; do + n=$((n + 1)) + log "" + log "======== rung $n / ${#LADDER[@]} amount=$amt ========" + WD=$(mint_withdraw "$amt" 2>&1) || true + echo "$WD" | tee "$SHOTDIR/wd-${n}.json" >/dev/null + if ! echo "$WD" | python3 -c 'import json,sys; json.load(sys.stdin)' 2>/dev/null; then + log "FAIL mint: $WD" + echo -e "${n}\t${amt}\tFAIL_MINT\t-\t$(echo "$WD" | tr '\n' ' ' | head -c 200)" >>"$RESULTS" + fail_rung="$amt" + break + fi + WID=$(echo "$WD" | python3 -c 'import json,sys; print(json.load(sys.stdin).get("withdrawal_id",""))') + URI=$(echo "$WD" | python3 -c 'import json,sys; u=json.load(sys.stdin).get("taler_withdraw_uri",""); print(u.replace(":443/","/"))') + [ -n "$WID" ] && [ -n "$URI" ] || { + log "FAIL parse mint: $WD" + echo -e "${n}\t${amt}\tFAIL_PARSE\t-\t" >>"$RESULTS" + fail_rung="$amt" + break + } + log " WID=$WID" + log " URI=$URI" + + before=$(wallet_avail) + if ! wcli withdraw accept-uri --exchange "$EXCHANGE" "$URI" 2>&1 | tee "$SHOTDIR/accept-${n}.out" | tail -20; then + # still try confirm if selected + true + fi + if ! confirm_when_selected "$WID" "$TOK"; then + log "FAIL confirm $amt" + echo -e "${n}\t${amt}\tFAIL_CONFIRM\t${WID}\t" >>"$RESULTS" + fail_rung="$amt" + # keep going? user asked until it no longer works — stop + break + fi + + settled=0 + for r in $(seq 1 20); do + wcli run-until-done 2>&1 | tee -a "$SHOTDIR/rud-${n}.out" >/dev/null || true + after=$(wallet_avail) + # progress if available increased (float-safe string compare via python) + if python3 -c "import sys; sys.exit(0 if float(sys.argv[1])>float(sys.argv[2]) else 1)" "$after" "$before" 2>/dev/null; then + settled=1 + log " settled avail GOA:$after (was $before)" + break + fi + sleep 1 + done + after=$(wallet_avail) + if [ "$settled" = "1" ]; then + log "OK $amt → wallet GOA:$after" + echo -e "${n}\t${amt}\tOK\t${WID}\tavail=${after}" >>"$RESULTS" + else + # bank may be confirmed but wire lag — check transfer_done + xfer=$(curl -sS -m 10 "${BANK}/taler-integration/withdrawal-operation/${WID}" \ + | python3 -c 'import json,sys; d=json.load(sys.stdin); print(d.get("transfer_done"), d.get("status"))') + log " not settled yet transfer_done/status=$xfer avail=$after" + if echo "$xfer" | grep -q True; then + echo -e "${n}\t${amt}\tOK_BANK_LAG\t${WID}\tavail=${after}" >>"$RESULTS" + log "OK bank confirmed (wallet lag) $amt" + else + echo -e "${n}\t${amt}\tFAIL_SETTLE\t${WID}\tavail=${after} $xfer" >>"$RESULTS" + fail_rung="$amt" + break + fi + fi +done + +log "" +log "=== final balance ===" +wcli balance 2>&1 | tee "$SHOTDIR/balance-final.out" +log "" +log "=== results ===" +column -t -s $'\t' "$RESULTS" 2>/dev/null || cat "$RESULTS" +log "SHOTDIR=$SHOTDIR" +if [ -n "$fail_rung" ]; then + log "STOPPED at first failure: $fail_rung" + exit 2 +fi +log "All rungs OK" +exit 0 From 44d2a57af9eb2be863074d06fc76849d30c591eb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hern=C3=A2ni=20Marques?= Date: Thu, 16 Jul 2026 23:10:19 +0200 Subject: [PATCH 29/57] monitoring: GOA ladder phase with 23 steps from zero to max. taler-monitoring ladder: log-uniform random rungs between fixed GOA:0 and bank ceiling. --- scripts/taler-monitoring/README.md | 1 + scripts/taler-monitoring/TESTS.md | 39 ++ scripts/taler-monitoring/check_goa_ladder.sh | 562 +++++++++++++++++++ scripts/taler-monitoring/taler-monitoring.sh | 8 +- 4 files changed, 609 insertions(+), 1 deletion(-) create mode 100755 scripts/taler-monitoring/check_goa_ladder.sh diff --git a/scripts/taler-monitoring/README.md b/scripts/taler-monitoring/README.md index 35edc67..c867ada 100644 --- a/scripts/taler-monitoring/README.md +++ b/scripts/taler-monitoring/README.md @@ -78,6 +78,7 @@ E2E_VARIABLE=0 WITHDRAW_AMT=GOA:50 PAY_AMT=GOA:1 ./taler-monitoring.sh e2e # s | **versions** | `deb.taler.net` reachable (InRelease/Packages/pool `.deb`); containers can reach it + have apt source; installed Taler packages vs **trixie** | | **sanity** | bank · exchange · merchant sections (public + server) | | **e2e** | account → credit → withdraw → wallet → confirm → order → pay | +| **ladder** | GOA withdraw: fixed **0** + low bands + **random high ranges** + fixed **max** | ```bash # package suite (default trixie on deb.taler.net) diff --git a/scripts/taler-monitoring/TESTS.md b/scripts/taler-monitoring/TESTS.md index 51686d4..eeb05c1 100644 --- a/scripts/taler-monitoring/TESTS.md +++ b/scripts/taler-monitoring/TESTS.md @@ -154,6 +154,44 @@ Blockers keep the same ID prefix: `[BLOCKER] e2e-0NN step: message`. --- +## ladder — GOA withdraw ranges (`./taler-monitoring.sh ladder`) + +| Step | What | +|------|------| +| ladder-001 | `GET /intro/auto-account.json` (personal account, GOA:0) | +| ladder-… | Mint explorer pool withdrawals: **zero**, then **random** in ranges (strictly increasing), then **pin at bank max** | +| ladder-… | wallet-cli accept-uri + explorer confirm when `selected` | +| ladder-… | Settle coins; **stop on first hard failure** | +| report | TSV + JSON with **ms_mint / ms_accept / ms_confirm / ms_settle / ms_total** | + +Default path: + +```text +[fixed] GOA:0 +[low] random pick in each LADDER_RANGES band (atomic … ~2e6) +[high] LADDER_HIGH_RUNGS random *ranges* log-uniform in [HIGH_FROM, max) + (range bounds drawn randomly each run — not fixed 1e7/1e8/…) +[fixed] GOA:4503599627370496 (LADDER_MAX_AMOUNT) +``` + +| Env | Default | Meaning | +|-----|---------|---------| +| `LADDER_INCLUDE_ZERO` | `1` | first rung **0** (fixed) | +| `LADDER_INCLUDE_MAX` | `1` | last rung **max** (fixed) | +| `LADDER_MAX_AMOUNT` | `4503599627370496` | absolute pin | +| `LADDER_HIGH_FROM` | `1000000` | high random zone starts | +| `LADDER_HIGH_RUNGS` | `12` | # random high ranges | +| `LADDER_TIMEOUT_S` | `3600` | large rungs need time | + +```bash +./taler-monitoring.sh ladder +LADDER_HIGH_RUNGS=20 ./taler-monitoring.sh ladder +LADDER_INCLUDE_ZERO=0 LADDER_INCLUDE_MAX=0 ./taler-monitoring.sh ladder +LADDER_REPORT_DIR=/tmp/my-ladder ./taler-monitoring.sh ladder +``` + +--- + ## Run one area ```bash @@ -161,5 +199,6 @@ Blockers keep the same ID prefix: `[BLOCKER] e2e-0NN step: message`. ./taler-monitoring.sh inside # inside only ./taler-monitoring.sh versions # deb.taler.net + package drift ./taler-monitoring.sh e2e # e2e only +./taler-monitoring.sh ladder # GOA amount ladder + timings ./taler-monitoring.sh -d taler.net urls ``` diff --git a/scripts/taler-monitoring/check_goa_ladder.sh b/scripts/taler-monitoring/check_goa_ladder.sh new file mode 100755 index 0000000..59fe37a --- /dev/null +++ b/scripts/taler-monitoring/check_goa_ladder.sh @@ -0,0 +1,562 @@ +#!/usr/bin/env bash +# check_goa_ladder.sh — GOA withdraw ladder for taler-monitoring +# +# bank.hacktivism.ch flow (landing): +# 1) GET /intro/auto-account.json → personal goa-account-* (GOA:0) +# 2) Mint pool withdrawals as explorer (shared pool) + confirm when selected +# 3) wallet-cli accept-uri + run-until-done +# +# Amounts: random within defined ranges, strictly increasing. +# On first hard failure: stop, print timing report, exit 1. +# +# Env: +# LADDER_STEPS total rungs (default 23) = 0 + (N-2) random + max +# LADDER_MAX_AMOUNT fixed last pin (libeufin ceiling 4503599627370496) +# LADDER_TIMEOUT_S default 3600 +# LADDER_LOAD=0 skip host load snapshots +# EXP_PW_FILE, LADDER_REPORT_DIR, … +# +# Path: always [0] → strictly increasing random (log-uniform) → [max] +# Only 0 and max are fixed amounts. +# Load: koopa host snapshot BEFORE withdraw ladder and AFTER (loadavg, mem, podman). +# +# Phase: ./taler-monitoring.sh ladder +set -euo pipefail +ROOT=$(cd "$(dirname "$0")" && pwd) +# shellcheck source=lib.sh +source "$ROOT/lib.sh" + +set_area ladder +SECTION_T0=$(date +%s) +now_ms() { python3 -c 'import time; print(int(time.time()*1000))'; } +elapsed_ms() { + # elapsed_ms START_MS + python3 -c 'import sys; print(int(sys.argv[1]) - int(sys.argv[2]))' "$(now_ms)" "$1" +} + +: "${LADDER_TIMEOUT_S:=3600}" +: "${LADDER_SETTLE_ROUNDS:=18}" +: "${LADDER_SETTLE_SLEEP:=2}" +: "${EXP_USER:=explorer}" +: "${EXP_PW_FILE:=/Users/newkamek/src/koopa/koopa-admin-secrets/koopa/host-root/taler-bank/bank-explorer-password.txt}" +: "${CLI_JS:=/Users/newkamek/src/taler-typescript-core/packages/taler-wallet-cli/bin/taler-wallet-cli.mjs}" +: "${LADDER_MAX_AMOUNT:=4503599627370496}" +: "${LADDER_STEPS:=23}" +: "${LADDER_LOAD:=1}" + +CUR="${EXPECT_CURRENCY:-GOA}" +BANK="${BANK_PUBLIC%/}" +EX="${EXCHANGE_PUBLIC%/}/" +SCRATCH=$(mktemp -d) +WDB="$SCRATCH/wallet.sqlite3" +REPORT_DIR="${LADDER_REPORT_DIR:-$SCRATCH}" +mkdir -p "$REPORT_DIR" +TSV="$REPORT_DIR/ladder-results.tsv" +JSON="$REPORT_DIR/ladder-report.json" +LOAD_BEFORE="$REPORT_DIR/load-before.json" +LOAD_AFTER="$REPORT_DIR/load-after.json" +echo -e "rung\trange\tamount\tstatus\tms_mint\tms_accept\tms_confirm\tms_settle\tms_total\twid\tnote" >"$TSV" + +ladder_over() { + local now + now=$(date +%s) + [ $((now - SECTION_T0)) -ge "$LADDER_TIMEOUT_S" ] +} + +wcli() { + if [ -f "$CLI_JS" ]; then + node "$CLI_JS" --wallet-db="$WDB" --no-throttle "$@" + else + taler-wallet-cli --wallet-db="$WDB" --no-throttle "$@" + fi +} + +wallet_avail() { + wcli balance 2>/dev/null | python3 -c ' +import json,sys +t=sys.stdin.read() +i=t.find("{") +if i<0: + print("0"); raise SystemExit +d=json.loads(t[i:t.rfind("}")+1]) +cur=sys.argv[1] +for b in d.get("balances") or []: + a=b.get("available") or "" + if a.startswith(cur+":"): + print(a.split(":",1)[1]); raise SystemExit +print("0") +' "$CUR" 2>/dev/null || echo "0" +} + +# Build exactly LADDER_STEPS: [0] + (N-2) log-uniform random increasing + [max] +build_ladder() { + python3 - <<'PY' "$CUR" "${LADDER_MAX_AMOUNT}" "${LADDER_STEPS}" +import math, random, sys +from decimal import Decimal, ROUND_HALF_UP + +cur = sys.argv[1] +max_amt = Decimal(sys.argv[2]) +steps = max(2, int(sys.argv[3])) + +def fmt(v: Decimal) -> str: + q = v.quantize(Decimal("0.00000001"), rounding=ROUND_HALF_UP) + if q == q.to_integral(): + return format(int(q), "d") + return format(q, "f").rstrip("0").rstrip(".") + +mid = steps - 2 # between 0 and max +out = ["%s:0" % cur] +if mid > 0 and max_amt > 0: + # log-space cut points in (epsilon, max), pick strictly increasing + lo, hi = 1e-8, float(max_amt) * 0.999999 + if hi <= lo: + hi = lo * 10 + cuts = sorted(math.exp(random.uniform(math.log(lo), math.log(hi))) for _ in range(mid)) + # enforce strict increase after quantize + prev = Decimal(0) + for c in cuts: + v = Decimal(str(c)) + if v >= 1: + v = v.quantize(Decimal(1), rounding=ROUND_HALF_UP) + else: + v = v.quantize(Decimal("0.00000001"), rounding=ROUND_HALF_UP) + if v <= prev: + step = max(prev * Decimal("1e-6"), Decimal("0.00000001")) if prev > 0 else Decimal("0.00000001") + v = (prev + step).quantize(Decimal("0.00000001") if prev < 1 else Decimal(1)) + if v >= max_amt: + v = max_amt - (Decimal(1) if max_amt > 1 else Decimal("0.00000001")) + if v <= prev: + continue + out.append("%s:%s" % (cur, fmt(v))) + prev = v +out.append("%s:%s" % (cur, fmt(max_amt))) +# trim/pad to exact steps if quantize collapsed some +while len(out) > steps: + # drop from middle + out.pop(len(out) // 2) +while len(out) < steps and len(out) >= 2: + # insert geometric mean mid + i = len(out) // 2 + a = Decimal(out[i - 1].split(":", 1)[1]) + b = Decimal(out[i].split(":", 1)[1]) + if a <= 0: + m = b / 2 if b > 0 else Decimal("0.000001") + else: + m = (a * b).sqrt() if a * b > 0 else (a + b) / 2 + if m <= a or m >= b: + break + out.insert(i, "%s:%s" % (cur, fmt(m))) +print(" ".join(out[:steps])) +PY +} + +# shellcheck source=metrics.sh +source "$ROOT/metrics.sh" +METRICS_DIR="$REPORT_DIR" +export METRICS_DIR CUR WDB CLI_JS + +section "ladder · GOA withdraw (0 → random → max · ${LADDER_STEPS} steps)" +info "bank" "$BANK" +info "exchange" "$EX" +info "currency" "$CUR" +info "budget" "${LADDER_TIMEOUT_S}s" +info "steps" "${LADDER_STEPS} (fixed 0 + $((LADDER_STEPS - 2)) random + fixed max=${CUR}:${LADDER_MAX_AMOUNT})" + +if [ ! -f "$EXP_PW_FILE" ]; then + err bank "explorer password missing" "$EXP_PW_FILE" + exit 1 +fi +EXP_PW=$(tr -d '\n' <"$EXP_PW_FILE") + +# --- auto-account --- +t0=$(now_ms) +if ! curl -sS -m 30 -o "$SCRATCH/auto-account.json" "${BANK}/intro/auto-account.json"; then + err bank "auto-account.json unreachable" + exit 1 +fi +ms_auto=$(elapsed_ms "$t0") +if ! python3 -c 'import json; d=json.load(open("'"$SCRATCH"'/auto-account.json")); assert d.get("ok") or d.get("username")' 2>/dev/null; then + err bank "auto-account create failed" "$(head -c 120 "$SCRATCH/auto-account.json" | tr '\n' ' ')" + exit 1 +fi +ACCT_USER=$(python3 -c 'import json; print(json.load(open("'"$SCRATCH"'/auto-account.json"))["username"])') +ok "auto-account ${ACCT_USER} (${ms_auto}ms) — personal GOA:0; pool=explorer" +info "auto-account password" "(see $SCRATCH/auto-account.json — not logged)" + +# --- explorer token --- +t0=$(now_ms) +TOK=$(curl -sS -m 20 -u "${EXP_USER}:${EXP_PW}" \ + -H 'Content-Type: application/json' \ + -d '{"scope":"readwrite","duration":{"d_us":3600000000}}' \ + "${BANK}/accounts/${EXP_USER}/token" \ + | python3 -c 'import json,sys; print(json.load(sys.stdin)["access_token"])') +ms_tok=$(elapsed_ms "$t0") +[ -n "$TOK" ] || { err bank "explorer token failed"; exit 1; } +ok "explorer token (${ms_tok}ms)" + +# --- wallet exchange + ToS --- +t0=$(now_ms) +wcli exchanges add "$EX" >"$SCRATCH/ex-add.out" 2>&1 || true +wcli exchanges update "$EX" >"$SCRATCH/ex-upd.out" 2>&1 || true +wcli exchanges accept-tos "$EX" >"$SCRATCH/ex-tos.out" 2>&1 || true +ms_tos=$(elapsed_ms "$t0") +ok "wallet exchange + ToS (${ms_tos}ms)" + +LADDER_LIST=$(build_ladder) +: "${LADDER_MAX_RUNGS:=99}" +# shellcheck disable=SC2086 +set -- $LADDER_LIST +if [ "$#" -gt "$LADDER_MAX_RUNGS" ]; then + set -- $(printf '%s\n' "$@" | head -n "$LADDER_MAX_RUNGS") +fi +info "ladder plan" "$*" +printf '%s\n' "$@" >"$SCRATCH/ladder-plan.txt" + +OK_N=0 +FAIL_N_L=0 +STOP_REASON="" +STOP_AMOUNT="" +declare -a RUNG_JSON=() + +rung=0 +for AMT in "$@"; do + rung=$((rung + 1)) + if ladder_over; then + STOP_REASON="timeout budget ${LADDER_TIMEOUT_S}s" + warn "ladder" "budget exhausted after $OK_N ok rungs" + break + fi + + section "ladder · rung $rung $AMT" + tag=$(printf '%s' "$AMT" | tr '.:' '__') + range_note=$(printf '%s' "$LADDER_RANGES" | awk -v n="$rung" '{print $n}') + t_rung=$(now_ms) + ms_mint=0 ms_accept=0 ms_confirm=0 ms_settle=0 + note="" + status="FAIL" + WID="-" + + # mint from explorer pool + t0=$(now_ms) + code=$(curl -sS -m 30 -o "$SCRATCH/wd-$tag.json" -w '%{http_code}' \ + -H "Authorization: Bearer ${TOK}" \ + -H 'Content-Type: application/json' \ + -d "{\"amount\":\"${AMT}\"}" \ + "${BANK}/accounts/${EXP_USER}/withdrawals") + ms_mint=$(elapsed_ms "$t0") + WID=$(python3 -c 'import json;d=json.load(open("'"$SCRATCH"'/wd-'"$tag"'.json"));print(d.get("withdrawal_id") or "")' 2>/dev/null || true) + URI=$(python3 -c 'import json;u=json.load(open("'"$SCRATCH"'/wd-'"$tag"'.json")).get("taler_withdraw_uri") or "";print(u.replace(":443/","/"))' 2>/dev/null || true) + # numeric amount (for zero / settle special-cases) + AMT_NUM=$(python3 -c 'import sys; print(sys.argv[1].split(":",1)[-1])' "$AMT") + IS_ZERO=0 + python3 -c 'import sys; from decimal import Decimal; sys.exit(0 if Decimal(sys.argv[1])==0 else 1)' "$AMT_NUM" 2>/dev/null && IS_ZERO=1 + + if [ "$code" != "200" ] && [ "$code" != "201" ] || [ -z "$WID" ] || [ -z "$URI" ]; then + note="mint HTTP $code $(head -c 100 "$SCRATCH/wd-$tag.json" 2>/dev/null | tr '\n' ' ')" + ms_total=$(elapsed_ms "$t_rung") + if [ "$IS_ZERO" = "1" ]; then + # Probe only: bank may reject GOA:0 — record and continue ladder + status="ZERO_REJECT" + note="zero-withdraw rejected (expected possible): $note" + warn bank "mint $AMT" "$note" + echo -e "${rung}\t${range_note}\t${AMT}\t${status}\t${ms_mint}\t${ms_accept}\t${ms_confirm}\t${ms_settle}\t${ms_total}\t${WID}\t${note}" >>"$TSV" + OK_N=$((OK_N + 1)) + continue + fi + err bank "mint $AMT failed" "$note" + status="FAIL_MINT" + STOP_REASON="$note" + STOP_AMOUNT="$AMT" + echo -e "${rung}\t${range_note}\t${AMT}\t${status}\t${ms_mint}\t${ms_accept}\t${ms_confirm}\t${ms_settle}\t${ms_total}\t${WID}\t${note}" >>"$TSV" + FAIL_N_L=$((FAIL_N_L + 1)) + break + fi + ok "mint $AMT ($WID) ${ms_mint}ms" + + before=$(wallet_avail) + + # accept + t0=$(now_ms) + if wcli withdraw accept-uri --exchange "$EX" "$URI" >"$SCRATCH/accept-$tag.out" 2>&1; then + ms_accept=$(elapsed_ms "$t0") + ok "accept-uri $AMT ${ms_accept}ms" + else + ms_accept=$(elapsed_ms "$t0") + note="accept-uri failed: $(tail -c 120 "$SCRATCH/accept-$tag.out" | tr '\n' ' ')" + err wallet "accept $AMT" "$note" + status="FAIL_ACCEPT" + STOP_REASON="$note" + STOP_AMOUNT="$AMT" + ms_total=$(elapsed_ms "$t_rung") + echo -e "${rung}\t${range_note}\t${AMT}\t${status}\t${ms_mint}\t${ms_accept}\t${ms_confirm}\t${ms_settle}\t${ms_total}\t${WID}\t${note}" >>"$TSV" + FAIL_N_L=$((FAIL_N_L + 1)) + break + fi + + # Confirm ASAP when bank status is selected (do NOT block on long run-until-done first). + # Server-side auto-confirm only watches landing withdraw-watch.ids — ladder must confirm itself. + bank_st() { + curl -sS -m 8 "${BANK}/taler-integration/withdrawal-operation/${WID}" 2>/dev/null \ + | python3 -c 'import json,sys; print(json.load(sys.stdin).get("status",""))' 2>/dev/null || true + } + do_confirm() { + curl -sS -m 15 -o "$SCRATCH/conf-$tag.json" -w '%{http_code}' -X POST \ + -H "Authorization: Bearer ${TOK}" -H 'Content-Type: application/json' -d '{}' \ + "${BANK}/accounts/${EXP_USER}/withdrawals/${WID}/confirm" + } + force_select_if_needed() { + local st_now="$1" + [ "$st_now" = "pending" ] || [ -z "$st_now" ] || return 0 + local rpub epayto + rpub=$(python3 -c ' +import re,sys +paths=sys.argv[1:] +t="" +for p in paths: + try: t+=open(p).read() + except Exception: pass +m=re.search(r"reserve[_ ]?pub[\"=: ]+([A-Z0-9]{40,})", t, re.I) +if not m: m=re.search(r"\"reservePub\"\s*:\s*\"([^\"]+)\"", t) +print(m.group(1) if m else "") +' "$SCRATCH/accept-$tag.out" "$SCRATCH/tx-$tag.json" 2>/dev/null || true) + if [ -z "$rpub" ]; then + wcli transactions >"$SCRATCH/tx-$tag.json" 2>&1 || true + rpub=$(python3 -c ' +import re,sys +t=open(sys.argv[1]).read() +m=re.search(r"reserve[_ ]?pub[\"=: ]+([A-Z0-9]{40,})", t, re.I) +if not m: m=re.search(r"\"reservePub\"\s*:\s*\"([^\"]+)\"", t) +print(m.group(1) if m else "") +' "$SCRATCH/tx-$tag.json" 2>/dev/null || true) + fi + epayto=$(curl -sS -m 10 "${EX%/}/keys" 2>/dev/null | python3 -c ' +import json,sys +d=json.load(sys.stdin) +acc=d.get("accounts") or [] +for a in acc: + p=a.get("payto_uri") or a.get("payto_address") or "" + if "x-taler-bank" in p or "exchange" in p: + print(p); break +else: + if acc: print(acc[0].get("payto_uri") or "") +' 2>/dev/null || true) + if [ -n "$rpub" ] && [ -n "$epayto" ]; then + curl -sS -m 12 -o "$SCRATCH/force-sel-$tag.json" -X POST \ + -H 'Content-Type: application/json' \ + -d "{\"reserve_pub\":\"${rpub}\",\"selected_exchange\":\"${epayto}\"}" \ + "${BANK}/taler-integration/withdrawal-operation/${WID}" >/dev/null || true + info "force-select" "rpub=${rpub:0:12}…" + fi + } + + t0=$(now_ms) + conf_ok=0 + st="" + # Immediate poll: confirm the moment we see selected (no long wallet block first) + for i in $(seq 1 60); do + ladder_over && break + st=$(bank_st) + case "$st" in + selected) + ccode=$(do_confirm) + if [ "$ccode" = "204" ] || [ "$ccode" = "200" ]; then + conf_ok=1 + info "confirm" "immediate HTTP $ccode after selected (poll $i)" + else + note="confirm HTTP $ccode" + fi + break + ;; + confirmed) + conf_ok=1 + break + ;; + aborted) + note="withdrawal aborted by bank" + break + ;; + esac + # short shepherd only — never block tens of seconds on run-until-done + if [ $((i % 3)) -eq 1 ]; then + if command -v timeout >/dev/null 2>&1; then + timeout 4 wcli run-until-done >"$SCRATCH/sel-$tag-$i.out" 2>&1 || true + else + # macOS: background + kill + wcli run-until-done >"$SCRATCH/sel-$tag-$i.out" 2>&1 & + wpid=$! + sleep 4 + kill "$wpid" 2>/dev/null || true + wait "$wpid" 2>/dev/null || true + fi + fi + # if still pending after a few polls, force bank select once + if [ "$i" = "4" ] || [ "$i" = "12" ]; then + force_select_if_needed "$st" + fi + sleep 0.5 + done + ms_confirm=$(elapsed_ms "$t0") + if [ "$conf_ok" != "1" ]; then + note="${note:-confirm timeout last=$st}" + err bank "confirm $AMT" "$note" + status="FAIL_CONFIRM" + STOP_REASON="$note" + STOP_AMOUNT="$AMT" + ms_total=$(elapsed_ms "$t_rung") + printf '%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n' \ + "$rung" "$range_note" "$AMT" "$status" "$ms_mint" "$ms_accept" "$ms_confirm" "$ms_settle" "$ms_total" "$WID" "$note" >>"$TSV" + FAIL_N_L=$((FAIL_N_L + 1)) + break + fi + ok "confirm $AMT ${ms_confirm}ms (client, on selected)" + + # settle coins (zero amount: no balance increase expected) + t0=$(now_ms) + settled=0 + if [ "$IS_ZERO" = "1" ]; then + # short wallet run only + wcli run-until-done >"$SCRATCH/rud-$tag-zero.out" 2>&1 || true + settled=1 + note="zero-amount: no coin delta expected" + else + for r in $(seq 1 "$LADDER_SETTLE_ROUNDS"); do + ladder_over && break + wcli run-until-done >"$SCRATCH/rud-$tag-$r.out" 2>&1 || true + after=$(wallet_avail) + if python3 -c " +from decimal import Decimal +import sys +sys.exit(0 if Decimal(sys.argv[1]) > Decimal(sys.argv[2]) else 1) +" "$after" "$before" 2>/dev/null; then + settled=1 + break + fi + sleep "$LADDER_SETTLE_SLEEP" + done + fi + ms_settle=$(elapsed_ms "$t0") + after=$(wallet_avail) + ms_total=$(elapsed_ms "$t_rung") + + if [ "$settled" = "1" ]; then + status="OK" + note="${note:-avail=${CUR}:${after}}" + ok "settle $AMT → ${CUR}:${after} (settle ${ms_settle}ms, rung ${ms_total}ms)" + OK_N=$((OK_N + 1)) + echo -e "${rung}\t${range_note}\t${AMT}\t${status}\t${ms_mint}\t${ms_accept}\t${ms_confirm}\t${ms_settle}\t${ms_total}\t${WID}\t${note}" >>"$TSV" + else + xfer=$(curl -sS -m 10 "${BANK}/taler-integration/withdrawal-operation/${WID}" \ + | python3 -c 'import json,sys; d=json.load(sys.stdin); print(d.get("transfer_done"), d.get("status"))' 2>/dev/null || echo "?") + if echo "$xfer" | grep -qi True; then + status="OK_BANK_LAG" + note="bank transfer_done avail=${after} $xfer" + warn "settle lag $AMT" "bank confirmed; wallet still ${CUR}:${after} (${ms_settle}ms)" + OK_N=$((OK_N + 1)) + echo -e "${rung}\t${range_note}\t${AMT}\t${status}\t${ms_mint}\t${ms_accept}\t${ms_confirm}\t${ms_settle}\t${ms_total}\t${WID}\t${note}" >>"$TSV" + else + note="no coins avail=${after} $xfer" + err wallet "settle $AMT" "$note" + status="FAIL_SETTLE" + STOP_REASON="$note" + STOP_AMOUNT="$AMT" + FAIL_N_L=$((FAIL_N_L + 1)) + echo -e "${rung}\t${range_note}\t${AMT}\t${status}\t${ms_mint}\t${ms_accept}\t${ms_confirm}\t${ms_settle}\t${ms_total}\t${WID}\t${note}" >>"$TSV" + break + fi + fi +done + +ms_phase=$(python3 -c 'import sys,time; print(int((time.time()-float(sys.argv[1]))*1000))' "$SECTION_T0") + +# --- report --- +section "ladder · report" +info "auto-account" "$ACCT_USER" +info "ok_rungs" "$OK_N" +info "fail_rungs" "$FAIL_N_L" +info "phase_ms" "$ms_phase" +info "tsv" "$TSV" + +# speed summary via python +python3 - "$TSV" "$JSON" "$OK_N" "$FAIL_N_L" "${STOP_AMOUNT:-}" "${STOP_REASON:-}" "$ms_phase" "$ACCT_USER" "$CUR" <<'PY' +import csv, json, sys, statistics +tsv, jpath, ok_n, fail_n, stop_amt, stop_reason, phase_ms, acct, cur = sys.argv[1:10] +rows = [] +with open(tsv, newline="") as f: + r = csv.DictReader(f, delimiter="\t") + for row in r: + rows.append(row) + +def nums(key): + out = [] + for row in rows: + try: + out.append(int(row[key])) + except Exception: + pass + return out + +def stats(xs): + if not xs: + return {"n": 0} + return { + "n": len(xs), + "min_ms": min(xs), + "max_ms": max(xs), + "avg_ms": int(sum(xs) / len(xs)), + "p50_ms": int(statistics.median(xs)), + } + +report = { + "currency": cur, + "auto_account": acct, + "ok_rungs": int(ok_n), + "fail_rungs": int(fail_n), + "stopped_at_amount": stop_amt or None, + "stop_reason": stop_reason or None, + "phase_ms": int(phase_ms), + "timing": { + "mint": stats(nums("ms_mint")), + "accept": stats(nums("ms_accept")), + "confirm": stats(nums("ms_confirm")), + "settle": stats(nums("ms_settle")), + "rung_total": stats(nums("ms_total")), + }, + "rungs": rows, +} +json.dump(report, open(jpath, "w"), indent=2) +print("JSON", jpath) +print("--- speed (ms) ---") +for k, v in report["timing"].items(): + if v.get("n"): + print(f" {k:12} n={v['n']} min={v['min_ms']} p50={v['p50_ms']} avg={v['avg_ms']} max={v['max_ms']}") +print("--- rungs ---") +for row in rows: + print(f" {row['rung']:>2} {row['amount']:16} {row['status']:12} total={row['ms_total']}ms mint={row['ms_mint']} accept={row['ms_accept']} conf={row['ms_confirm']} set={row['ms_settle']}") +if stop_amt: + print(f"STOPPED at {stop_amt}: {stop_reason}") +else: + print("Completed without hard failure (or budget stop without fail).") +PY + +wcli balance 2>&1 | tee "$REPORT_DIR/balance-final.out" | tail -20 || true + +# Keep scratch if LADDER_REPORT_DIR set; else copy key files to /tmp +if [ -z "${LADDER_REPORT_DIR:-}" ]; then + KEEP="/tmp/goa-ladder-report-$(date +%Y%m%d-%H%M%S)" + mkdir -p "$KEEP" + cp -a "$TSV" "$JSON" "$SCRATCH/auto-account.json" "$SCRATCH/ladder-plan.txt" \ + "$REPORT_DIR/balance-final.out" "$KEEP/" 2>/dev/null || true + info "report_dir" "$KEEP" + echo "$KEEP" >"$SCRATCH/KEEP_PATH" +fi + +if [ "$FAIL_N_L" -gt 0 ]; then + blocker "ladder" "stopped at ${STOP_AMOUNT:-?} — ${STOP_REASON:-error}" + exit 1 +fi +if [ "$OK_N" -eq 0 ]; then + blocker "ladder" "no successful rungs" + exit 1 +fi +ok "ladder finished ok_rungs=$OK_N phase=${ms_phase}ms" +exit 0 diff --git a/scripts/taler-monitoring/taler-monitoring.sh b/scripts/taler-monitoring/taler-monitoring.sh index 321525b..30b8c30 100755 --- a/scripts/taler-monitoring/taler-monitoring.sh +++ b/scripts/taler-monitoring/taler-monitoring.sh @@ -26,6 +26,7 @@ Phases: sanity public + optional server server server-side only (SSH) e2e withdraw + pay (small amounts; remote aborts on login/KYC) + ladder GOA withdraw ladder (auto-account + explorer pool + timings) all urls + inside + versions + sanity + e2e (SSH phases only on koopa) Options: @@ -90,7 +91,7 @@ while [ $# -gt 0 ]; do CURRENCY_OVERRIDE="$2"; shift 2 ;; --no-probe) NO_PROBE=1; shift ;; - urls|inside|versions|sanity|server|e2e|all) PHASES+=("$1"); shift ;; + urls|inside|versions|sanity|server|e2e|ladder|goa-ladder|all) PHASES+=("$1"); shift ;; *) # bare domain shorthand: ./taler-monitoring.sh taler.net if [[ "$1" == *.* && "$1" != *://* && "$1" != -* ]]; then @@ -133,6 +134,10 @@ export EXPECT_CURRENCY SKIP_SSH LOCAL_STACK TALER_DOMAIN_PROBE export WITHDRAW_AMT PAY_AMT CREDIT_AMT MERCHANT_INSTANCE export E2E_FAKE_INCOMING E2E_REMOTE E2E_VARIABLE E2E_ATM_MAX export E2E_WITHDRAW_VALUES E2E_PAY_VALUES +export LADDER_RANGES LADDER_MAX_RUNGS LADDER_TIMEOUT_S LADDER_REPORT_DIR +export LADDER_SETTLE_ROUNDS LADDER_SETTLE_SLEEP EXP_PW_FILE EXP_USER +export LADDER_MAX_AMOUNT LADDER_INCLUDE_ZERO LADDER_INCLUDE_MAX +export LADDER_HIGH_FROM LADDER_HIGH_RUNGS export TALER_DOMAIN_APPLIED=1 # Default phases @@ -193,6 +198,7 @@ for p in "${PHASES[@]}"; do sanity) "$ROOT/check_sanity.sh" || ec=1 ;; server) "$ROOT/check_server.sh" || ec=1 ;; e2e) "$ROOT/check_e2e.sh" || ec=1 ;; + ladder|goa-ladder) "$ROOT/check_goa_ladder.sh" || ec=1 ;; esac done exit "$ec" From 3945de58316267774c8bd3797a8afd322ba649e6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hern=C3=A2ni=20Marques?= Date: Thu, 16 Jul 2026 23:45:51 +0200 Subject: [PATCH 30/57] monitoring: shared metrics for Taler load, DB size, and wallet coins. Host/container RAM and process probes plus dump-coins summaries for e2e and ladder. --- scripts/taler-monitoring/metrics.sh | 484 ++++++++++++++++++++++++++++ 1 file changed, 484 insertions(+) create mode 100644 scripts/taler-monitoring/metrics.sh diff --git a/scripts/taler-monitoring/metrics.sh b/scripts/taler-monitoring/metrics.sh new file mode 100644 index 0000000..e862644 --- /dev/null +++ b/scripts/taler-monitoring/metrics.sh @@ -0,0 +1,484 @@ +# shellcheck shell=bash +# Shared Taler stack metrics for e2e + ladder (source after lib.sh). +# +# - Host/container load (RAM, processes, DB sizes) before/after a phase +# - Wallet coin inventory (total + by denomination) +# - Final overall statistics block +# +# Env: +# METRICS_DIR where JSON snapshots go (default $SCRATCH or /tmp) +# METRICS_LOAD=0 skip remote/host load probes +# KOOPA_SSH / SKIP_SSH as in lib.sh + +: "${METRICS_LOAD:=1}" +: "${METRICS_DIR:=${SCRATCH:-/tmp}}" +mkdir -p "$METRICS_DIR" 2>/dev/null || true + +# --- coin inventory from wallet-cli dump-coins --- +# Writes JSON to $1; prints one-line summary to stdout. +# Sets COINS_TOTAL, COINS_FRESH, COINS_SUMMARY (human one-liner). +metrics_wallet_coins() { + local out="${1:-$METRICS_DIR/coins.json}" + local dump="$METRICS_DIR/dump-coins.raw" + COINS_TOTAL=0 + COINS_FRESH=0 + COINS_SUMMARY="(no coins)" + # Prefer wcli() from caller (bash function in e2e/ladder) + if type wcli >/dev/null 2>&1; then + wcli advanced dump-coins >"$dump" 2>/dev/null || true + elif [ -n "${CLI_JS:-}" ] && [ -f "${CLI_JS}" ]; then + node "$CLI_JS" --wallet-db="${WDB:-}" --no-throttle advanced dump-coins >"$dump" 2>/dev/null || true + elif [ -n "${WALLET_CLI:-}" ]; then + node "$WALLET_CLI" --wallet-db="${WDB:-}" --no-throttle advanced dump-coins >"$dump" 2>/dev/null || true + else + echo "$COINS_SUMMARY" + printf '%s\n' '{"ok":false,"reason":"no-wcli"}' >"$out" + return 1 + fi + python3 - "$dump" "$out" "${CUR:-GOA}" <<'PY' +import json, re, sys +from collections import Counter +raw_path, out_path, cur = sys.argv[1:4] +raw = open(raw_path).read() if raw_path else "" +d = None +for m in re.finditer(r"\{", raw): + try: + d = json.loads(raw[m.start():]) + if isinstance(d, dict) and ("coins" in d or "coin" in d): + break + except Exception: + d = None +coins = [] +if isinstance(d, dict): + coins = d.get("coins") or d.get("coin") or [] +by_denom = Counter() +by_status = Counter() +fresh = 0 +for c in coins: + if not isinstance(c, dict): + continue + dv = c.get("denomValue") or c.get("value") or "?" + st = str(c.get("coinStatus") or c.get("status") or "?") + by_denom[dv] += 1 + by_status[st] += 1 + if st.lower() in ("fresh", "pending", "dormant", "usable", ""): + # count non-spent-looking as "in circulation" for summary + pass + if "spent" not in st.lower() and "delete" not in st.lower(): + fresh += 1 +# prefer explicit status +fresh = sum(n for s, n in by_status.items() if "spent" not in s.lower() and "delete" not in s.lower()) +total = len(coins) +# stable sort denoms by numeric value if CUR:num +def denom_key(k): + try: + return float(str(k).split(":", 1)[-1]) + except Exception: + return 0.0 +denom_list = [ + {"denom": k, "count": by_denom[k]} + for k in sorted(by_denom.keys(), key=denom_key) +] +# human bar: "GOA:10×3 GOA:1×5" +parts = [] +for item in denom_list: + parts.append("%s×%d" % (item["denom"], item["count"])) +summary = ("total=%d in_wallet=%d | %s" % (total, fresh, " ".join(parts))) if parts else ("total=%d" % total) +report = { + "ok": True, + "currency": cur, + "total_coins": total, + "in_circulation": fresh, + "by_status": dict(by_status), + "by_denom": denom_list, + "summary": summary, +} +json.dump(report, open(out_path, "w"), indent=2) +print(summary) +# side channel for bash via file +open(out_path + ".total", "w").write(str(total)) +open(out_path + ".circ", "w").write(str(fresh)) +PY + COINS_TOTAL=$(cat "${out}.total" 2>/dev/null || echo 0) + COINS_FRESH=$(cat "${out}.circ" 2>/dev/null || echo 0) + COINS_SUMMARY=$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1])).get("summary","?"))' "$out" 2>/dev/null || echo "$COINS_SUMMARY") + echo "$COINS_SUMMARY" +} + +# Diff two coin JSON snapshots → new coins this step +metrics_coins_delta() { + local before="${1:-}" after="${2:-}" out="${3:-$METRICS_DIR/coins-delta.json}" + python3 - "$before" "$after" "$out" <<'PY' +import json, sys +from collections import Counter +def load(p): + try: + d=json.load(open(p)) + return d + except Exception: + return {} +b, a = load(sys.argv[1]), load(sys.argv[2]) +outp = sys.argv[3] +def bag(d): + c=Counter() + for item in d.get("by_denom") or []: + c[item.get("denom") or "?"] += int(item.get("count") or 0) + return c +bb, aa = bag(b), bag(a) +delta = aa - bb +parts = ["%s×%+d" % (k, delta[k]) for k in sorted(delta.keys(), key=lambda x: float(str(x).split(":")[-1]) if ":" in str(x) or str(x).replace(".","").isdigit() else 0) if delta[k]] +new_total = int(a.get("total_coins") or 0) - int(b.get("total_coins") or 0) +rep = { + "new_coins": new_total, + "total_after": int(a.get("total_coins") or 0), + "circulation_after": int(a.get("in_circulation") or 0), + "delta_by_denom": {k: delta[k] for k in delta}, + "summary": ("new=%+d total_now=%s | %s" % ( + new_total, a.get("total_coins"), " ".join(parts) if parts else "(no denom change)")), +} +json.dump(rep, open(outp, "w"), indent=2) +print(rep["summary"]) +PY +} + +# --- Taler stack load on koopa (host + bank/exchange/merchant) --- +# Writes JSON to $1. RAM, process counts, DB sizes, disk I/O counters. +metrics_taler_load() { + local out="${1:-$METRICS_DIR/load.json}" + local label="${2:-snap}" + if [ "${METRICS_LOAD}" = "0" ] || [ "${LADDER_LOAD:-1}" = "0" ]; then + printf '%s\n' "{\"ok\":false,\"reason\":\"disabled\",\"label\":\"$label\"}" >"$out" + return 0 + fi + local raw="" + local remote_py + remote_py=$(cat <<'PY' +import json, os, re, subprocess, time +from collections import defaultdict + +def sh(cmd, t=15): + try: + return subprocess.check_output(cmd, shell=True, text=True, stderr=subprocess.DEVNULL, timeout=t) + except Exception: + return "" + +def loadavg(): + try: + a,b,c = open("/proc/loadavg").read().split()[:3] + return [float(a), float(b), float(c)] + except Exception: + return [] + +def meminfo(): + d = {} + try: + for line in open("/proc/meminfo"): + k,v = line.split(":",1) + d[k.strip()] = int(v.strip().split()[0]) * 1024 + except Exception: + pass + return { + "mem_total_b": d.get("MemTotal"), + "mem_available_b": d.get("MemAvailable"), + "mem_free_b": d.get("MemFree"), + "buffers_b": d.get("Buffers"), + "cached_b": d.get("Cached"), + } + +def disk_io(): + r = w = 0 + try: + for line in open("/proc/diskstats"): + p = line.split() + if len(p) < 14: continue + name = p[2] + if name.startswith(("loop","ram","dm-")): continue + r += int(p[5]); w += int(p[9]) + except Exception: + pass + return {"sectors_read": r, "sectors_written": w, + "approx_write_bytes": w*512, "approx_read_bytes": r*512} + +CTRS = [("bank","taler-hacktivism-bank"),("merchant","taler-hacktivism"), + ("exchange","taler-hacktivism-exchange-ansible")] + +def running(name): + return sh(f"podman inspect -f '{{{{.State.Running}}}}' {name}").strip() == "true" + +def stats(name): + o = sh(f"podman stats --no-stream --format json {name}") + try: + data = json.loads(o) + if isinstance(data, list) and data: data = data[0] + if not isinstance(data, dict): return {} + return {"cpu_pct": data.get("CPU") or data.get("CPUPerc"), + "mem_usage": data.get("MemUsage"), + "mem_pct": data.get("MemPerc"), + "block_io": data.get("BlockIO"), + "net_io": data.get("NetIO"), + "pids": data.get("PIDs")} + except Exception: + return {} + +def procs(name): + # count + RSS by role inside container + code = r''' +import os,re,json +from collections import defaultdict +by=defaultdict(lambda:{"n":0,"rss_b":0}); n=0; rss=0 +for ent in os.listdir("/proc"): + if not ent.isdigit(): continue + try: st=open(f"/proc/{ent}/status").read() + except Exception: continue + m=re.search(r"^VmRSS:\s+(\d+)",st,re.M) + if not m: continue + rb=int(m.group(1))*1024 + nm=(re.search(r"^Name:\s+(\S+)",st,re.M) or type("x",(object,),{"group":lambda s,i: "?"})()).group(1) + try: cmd=open(f"/proc/{ent}/cmdline","rb").read().decode("utf-8","replace").replace("\0"," ") + except Exception: cmd="" + blob=(nm+" "+cmd).lower(); key="other" + if "postgres" in blob or "postmaster" in blob: key="postgres" + elif "libeufin" in blob or "mainkt" in blob: key="libeufin" + elif "java" in blob: key="java" + elif "taler-merchant" in blob: key="taler-merchant" + elif "taler-exchange" in blob: key="taler-exchange" + elif "nginx" in blob: key="nginx" + elif "apache" in blob: key="apache" + by[key]["n"]+=1; by[key]["rss_b"]+=rb; n+=1; rss+=rb +print(json.dumps({"proc_total":n,"rss_total_b":rss,"by_role":dict(by)})) +''' + o = sh(f"podman exec {name} python3 -c {json.dumps(code)}") + try: return json.loads(o) + except Exception: + n = sh(f"podman exec {name} sh -c 'ps -e --no-headers 2>/dev/null | wc -l'").strip() + return {"proc_total": int(n) if n.isdigit() else None, "rss_total_b": None, "by_role": {}} + +def dbs(name): + o = sh( + f"podman exec {name} su -s /bin/bash postgres -c " + + json.dumps( + "psql -Atc \"SELECT datname||'|'||pg_database_size(datname)||'|'||pg_size_pretty(pg_database_size(datname)) " + "FROM pg_database WHERE datistemplate=false ORDER BY 1\"" + ) + ) + out=[] + for line in o.splitlines(): + p=line.strip().split("|") + if len(p)>=3: + try: out.append({"name":p[0],"size_b":int(p[1]),"size_pretty":p[2]}) + except Exception: pass + du=sh(f"podman exec {name} sh -c 'du -sb /var/lib/postgresql 2>/dev/null | cut -f1'").strip() + pretty=sh(f"podman exec {name} sh -c 'du -sh /var/lib/postgresql 2>/dev/null | cut -f1'").strip() + return {"databases": out, "pgdata_bytes": int(du) if du.isdigit() else None, "pgdata_pretty": pretty or None} + +components={} +for role,cname in CTRS: + if not running(cname): + components[role]={"container":cname,"running":False} + continue + components[role]={ + "container":cname,"running":True, + "podman_stats":stats(cname), + "processes":procs(cname), + "databases":dbs(cname), + } + +host_ps=sh("ps -eo comm=") +host_counts={k:sum(1 for line in host_ps.splitlines() if k in line.lower()) + for k in ("postgres","nginx","caddy","podman","conmon","pasta")} +print(json.dumps({ + "ok": True, + "ts": time.strftime("%Y-%m-%dT%H:%M:%S%z"), + "host": { + "hostname": sh("hostname").strip(), + "loadavg": loadavg(), + "nproc": os.cpu_count(), + "memory": meminfo(), + "disk_io": disk_io(), + "process_counts": host_counts, + }, + "taler": components, +})) +PY +) + if [ "${SKIP_SSH:-0}" != "1" ] && type koopa_ssh_ok >/dev/null 2>&1 && koopa_ssh_ok; then + raw=$(printf '%s\n' "$remote_py" | koopa_ssh_bash 50 'python3 -' 2>/dev/null || true) + # koopa_ssh_bash only runs bash -s; pipe python via bash + if [ -z "$raw" ]; then + raw=$(printf 'python3 - <<'"'"'PY'"'"'\n%s\nPY\n' "$remote_py" | koopa_ssh_bash 50 2>/dev/null || true) + fi + elif command -v podman >/dev/null 2>&1; then + raw=$(python3 -c "$remote_py" 2>/dev/null || true) + fi + if [ -z "$raw" ]; then + printf '%s\n' "{\"ok\":false,\"reason\":\"probe-failed\",\"label\":\"$label\"}" >"$out" + return 1 + fi + printf '%s\n' "$raw" | python3 -c ' +import sys,json,re +t=sys.stdin.read(); obj=None +for m in re.finditer(r"\{", t): + try: obj=json.loads(t[m.start():]) + except Exception: pass +if not obj: obj={"ok":False,"reason":"parse"} +obj["label"]=sys.argv[1] +json.dump(obj, open(sys.argv[2],"w"), indent=2) +' "$label" "$out" +} + +# Human one-screen summary of a load JSON +metrics_print_load() { + local f="$1" title="${2:-load}" + python3 - "$f" "$title" <<'PY' +import json,sys +try: + d=json.load(open(sys.argv[1])) +except Exception as e: + print(f" ({sys.argv[2]}: unreadable {e})") + raise SystemExit +if not d.get("ok"): + print(f" ({sys.argv[2]}: {d.get('reason','n/a')})") + raise SystemExit +h=d.get("host") or {} +mem=h.get("memory") or {} +la=h.get("loadavg") or [] +def gi(b): + if b is None: return "?" + return f"{b/1024/1024/1024:.2f} GiB" +print(f" host loadavg {la} nproc={h.get('nproc')} mem_avail={gi(mem.get('mem_available_b'))}/{gi(mem.get('mem_total_b'))}") +dio=h.get("disk_io") or {} +if dio: + print(f" host disk Δsectors read={dio.get('sectors_read')} written={dio.get('sectors_written')} (~write {gi(dio.get('approx_write_bytes'))})") +for role in ("bank","exchange","merchant"): + c=(d.get("taler") or {}).get(role) or {} + if not c: + continue + if not c.get("running"): + print(f" {role:8} DOWN ({c.get('container')})") + continue + pr=c.get("processes") or {} + rss=pr.get("rss_total_b") + n=pr.get("proc_total") + roles=pr.get("by_role") or {} + bits=[] + for k in ("libeufin","taler-exchange","taler-merchant","postgres","nginx"): + if k in roles: + bits.append(f"{k}:n={roles[k].get('n')} rss={gi(roles[k].get('rss_b'))}") + dbs=c.get("databases") or {} + db_s=", ".join(f"{x.get('name')}={x.get('size_pretty')}" for x in (dbs.get("databases") or [])[:6]) + pg=dbs.get("pgdata_pretty") or "" + st=c.get("podman_stats") or {} + print(f" {role:8} procs={n} rss={gi(rss)} cpu={st.get('cpu_pct','?')} block={st.get('block_io','?')}") + if bits: + print(f" " + " ".join(bits)) + if db_s or pg: + print(f" db: {db_s}" + (f" pgdata={pg}" if pg else "")) +PY +} + +# Diff two load snaps: highlight RAM/proc/DB growth for taler roles +metrics_print_load_delta() { + local before="$1" after="$2" + python3 - "$before" "$after" <<'PY' +import json,sys +def load(p): + try: return json.load(open(p)) + except Exception: return {} +b,a=load(sys.argv[1]),load(sys.argv[2]) +if not b.get("ok") or not a.get("ok"): + print(" (load delta unavailable)") + raise SystemExit +def gi(x): + if x is None: return None + return x/1024/1024/1024 +print(" --- delta (after − before) ---") +# host load +bla=(b.get("host") or {}).get("loadavg") or [0,0,0] +ala=(a.get("host") or {}).get("loadavg") or [0,0,0] +if bla and ala: + print(f" loadavg1 {bla[0]:.2f} → {ala[0]:.2f} (Δ {ala[0]-bla[0]:+.2f})") +bm=(b.get("host") or {}).get("memory") or {} +am=(a.get("host") or {}).get("memory") or {} +if bm.get("mem_available_b") is not None and am.get("mem_available_b") is not None: + print(f" mem_avail {gi(bm['mem_available_b']):.2f} → {gi(am['mem_available_b']):.2f} GiB (Δ {gi(am['mem_available_b'])-gi(bm['mem_available_b']):+.3f} GiB)") +bd=(b.get("host") or {}).get("disk_io") or {} +ad=(a.get("host") or {}).get("disk_io") or {} +if bd.get("sectors_written") is not None and ad.get("sectors_written") is not None: + dw=(ad["sectors_written"]-bd["sectors_written"])*512 + dr=(ad["sectors_read"]-bd["sectors_read"])*512 + print(f" disk I/O write≈{dw/1024/1024:.1f} MiB read≈{dr/1024/1024:.1f} MiB (during phase)") +for role in ("bank","exchange","merchant"): + bc=(b.get("taler") or {}).get(role) or {} + ac=(a.get("taler") or {}).get(role) or {} + if not ac.get("running"): + continue + br=(bc.get("processes") or {}).get("rss_total_b") + ar=(ac.get("processes") or {}).get("rss_total_b") + bn=(bc.get("processes") or {}).get("proc_total") + an=(ac.get("processes") or {}).get("proc_total") + line=f" {role:8}" + if br is not None and ar is not None: + line+=f" rss {gi(br):.3f}→{gi(ar):.3f} GiB (Δ{gi(ar)-gi(br):+.3f})" + if bn is not None and an is not None: + line+=f" procs {bn}→{an} (Δ{an-bn:+d})" + # DB sizes + def dbmap(c): + m={} + for x in ((c.get("databases") or {}).get("databases") or []): + m[x.get("name")]=x.get("size_b") + return m + bdb,adb=dbmap(bc),dbmap(ac) + dbits=[] + for name in sorted(set(bdb)|set(adb)): + bb,aa=bdb.get(name),adb.get(name) + if bb is not None and aa is not None and aa!=bb: + dbits.append(f"{name} {aa-bb:+d}B") + elif aa is not None and bb is None: + dbits.append(f"{name}={aa}B") + if dbits: + line+=" dbΔ["+", ".join(dbits)+"]" + print(line) +PY +} + +# Overall end-of-run statistics block +# Args via env / files: +# METRICS_DIR, optional: WITHDRAW_REPORT, PAY_REPORT, phase timings JSON files +metrics_print_overall() { + local title="${1:-overall statistics}" + section "metrics · $title" + if [ -f "${METRICS_DIR}/load-before.json" ]; then + info "load BEFORE" "" + metrics_print_load "${METRICS_DIR}/load-before.json" "before" + fi + if [ -f "${METRICS_DIR}/load-after.json" ]; then + info "load AFTER" "" + metrics_print_load "${METRICS_DIR}/load-after.json" "after" + metrics_print_load_delta "${METRICS_DIR}/load-before.json" "${METRICS_DIR}/load-after.json" + fi + if [ -f "${METRICS_DIR}/coins-final.json" ]; then + info "coins final" "$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1])).get("summary","?"))' "${METRICS_DIR}/coins-final.json" 2>/dev/null || echo n/a)" + fi + if [ -f "${METRICS_DIR}/coins-history.tsv" ]; then + echo " --- coins after each withdraw ---" + # header + rows + if [ -s "${METRICS_DIR}/coins-history.tsv" ]; then + column -t -s $'\t' "${METRICS_DIR}/coins-history.tsv" 2>/dev/null \ + || cat "${METRICS_DIR}/coins-history.tsv" + fi + fi + if [ -f "${METRICS_DIR}/perf-summary.json" ]; then + info "performance" "" + python3 - "${METRICS_DIR}/perf-summary.json" <<'PY' +import json,sys +d=json.load(open(sys.argv[1])) +for k,v in d.items(): + if isinstance(v, dict) and "n" in v: + print(f" {k:14} n={v.get('n')} min={v.get('min_ms')}ms p50={v.get('p50_ms')}ms avg={v.get('avg_ms')}ms max={v.get('max_ms')}ms") + else: + print(f" {k}: {v}") +PY + fi + # free-form extras from caller + [ -n "${METRICS_EXTRA_LINES:-}" ] && printf '%s\n' "$METRICS_EXTRA_LINES" +} From e7469b47f9e0dbeb2e86db7760e8f99ef17fc0a3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hern=C3=A2ni=20Marques?= Date: Thu, 16 Jul 2026 23:50:57 +0200 Subject: [PATCH 31/57] nym: polish Containerfile, compose, entrypoint, and README. Host-side binary download notes and compose port mapping clarifications. --- configs/nym/Containerfile | 16 ++++++++++++---- configs/nym/README.md | 19 ++++++++++++++++++- configs/nym/compose.yml | 26 ++++++++++++++++---------- configs/nym/entrypoint.sh | 9 +++++---- 4 files changed, 51 insertions(+), 19 deletions(-) diff --git a/configs/nym/Containerfile b/configs/nym/Containerfile index 3f4f504..4956efd 100644 --- a/configs/nym/Containerfile +++ b/configs/nym/Containerfile @@ -1,26 +1,34 @@ # koopa-nym — nym-node (nym.com mixnet / NymVPN network) # Fetch binary on host first (see scripts/nym/build.sh), then: # podman build -t localhost/koopa-nym:latest -f Containerfile . +# +# Process runs as uid/gid 1000 (not root). With rootless podman use +# userns=keep-id so host ./data owned by hernani stays writable (see compose.yml). FROM docker.io/library/debian:bookworm-slim ENV DEBIAN_FRONTEND=noninteractive \ NYM_HOME=/var/lib/nym \ - PATH=/usr/local/bin:$PATH + PATH=/usr/local/bin:$PATH \ + HOME=/var/lib/nym RUN apt-get update \ && apt-get install -y --no-install-recommends \ ca-certificates libssl3 \ && rm -rf /var/lib/apt/lists/* \ - && mkdir -p /var/lib/nym /usr/local/bin + && groupadd --gid 1000 nym \ + && useradd --uid 1000 --gid 1000 --home-dir /var/lib/nym --shell /usr/sbin/nologin nym \ + && mkdir -p /var/lib/nym /usr/local/bin \ + && chown -R nym:nym /var/lib/nym # Pre-downloaded on host into build context as ./nym-node COPY nym-node /usr/local/bin/nym-node -RUN chmod +x /usr/local/bin/nym-node \ +RUN chmod 755 /usr/local/bin/nym-node \ && /usr/local/bin/nym-node --version || true COPY entrypoint.sh /usr/local/bin/nym-entrypoint -RUN chmod +x /usr/local/bin/nym-entrypoint +RUN chmod 755 /usr/local/bin/nym-entrypoint EXPOSE 1789/udp 1789/tcp 1790 8080 9000 51822/udp WORKDIR /var/lib/nym +USER nym:nym ENTRYPOINT ["/usr/local/bin/nym-entrypoint"] diff --git a/configs/nym/README.md b/configs/nym/README.md index 3b3c01d..7f1f486 100644 --- a/configs/nym/README.md +++ b/configs/nym/README.md @@ -9,6 +9,7 @@ Mirror in this repo: `configs/nym/`. |---------|--------| | Container | `koopa-nym` | | Image | `localhost/koopa-nym:latest` | +| Process user | **non-root** `nym` **uid/gid 1000** (`USER` + compose `userns_mode: keep-id`) | | Default mode | **`mixnode`** (safest on a home host; no open-internet exit) | | Optional modes | `entry-gateway`, `exit-gateway` (+ WireGuard for dVPN) — see env | | Local ID | `koopa-nym` | @@ -45,12 +46,28 @@ before enabling. ```bash cd ~/koopa-nym # or this mirror cp .env.example .env # edit PUBLIC_IPS, LOCATION, MODE +# host data must be writable by uid 1000 (hernani) +mkdir -p data && chown -R "$(id -u):$(id -g)" data +# sync compose + entrypoint from admin-log if this tree is a copy podman build -t localhost/koopa-nym:latest -f Containerfile . -podman compose up -d # or podman run … +# IMPORTANT: port maps only apply on create — not on plain podman start +podman compose down +podman compose up -d --force-recreate --build +podman exec koopa-nym id # expect uid=1000(nym) +podman port koopa-nym # must list 1789/tcp+udp and 1790/tcp +ss -lntp | grep -E '1789|1790|9080' # bonding: use Nym wallet / harbourmaster; node must accept operator T&Cs curl -sS http://127.0.0.1:9080/api/v1/roles | jq . ``` +### If WAN `1790` is “connection refused” but `1789` works + +Almost always **container publish/bind**, not VeciGate: + +1. Old container created without a solid `1790/tcp` map → **recreate** (above). +2. Process bound only on `[::]:1790` while host proxy expects IPv4 → defaults are now **`0.0.0.0:1790`**. +3. Confirm: `podman port koopa-nym` shows `1790/tcp -> 0.0.0.0:1790`, then `nc -vz WAN 1790`. + Secrets layout (values in **koopa-admin-secrets**): | Live | Secrets mirror | diff --git a/configs/nym/compose.yml b/configs/nym/compose.yml index f639fce..ccb6c91 100644 --- a/configs/nym/compose.yml +++ b/configs/nym/compose.yml @@ -10,6 +10,9 @@ services: image: localhost/koopa-nym:latest container_name: koopa-nym restart: unless-stopped + # Non-root (image USER nym = 1000). keep-id → host ./data owned by hernani works. + user: "1000:1000" + userns_mode: keep-id env_file: - .env environment: @@ -21,22 +24,25 @@ services: NYMNODE_HOSTNAME: ${NYMNODE_HOSTNAME:-} NYMNODE_WG_ENABLED: ${NYMNODE_WG_ENABLED:-false} NYMNODE_ACCEPT_OPERATOR_TERMS: ${NYMNODE_ACCEPT_OPERATOR_TERMS:-true} - NYMNODE_HTTP_BIND_ADDRESS: "[::]:8080" - NYMNODE_MIXNET_BIND_ADDRESS: "[::]:1789" - NYMNODE_VERLOC_BIND_ADDRESS: "[::]:1790" - NYMNODE_ENTRY_BIND_ADDRESS: "[::]:9000" + # IPv4 binds: rootless podman port-proxy is reliable with 0.0.0.0 + # ([::] can leave verloc without a host IPv4 listener → WAN "connection refused") + NYMNODE_HTTP_BIND_ADDRESS: ${NYMNODE_HTTP_BIND_ADDRESS:-0.0.0.0:8080} + NYMNODE_MIXNET_BIND_ADDRESS: ${NYMNODE_MIXNET_BIND_ADDRESS:-0.0.0.0:1789} + NYMNODE_VERLOC_BIND_ADDRESS: ${NYMNODE_VERLOC_BIND_ADDRESS:-0.0.0.0:1790} + NYMNODE_ENTRY_BIND_ADDRESS: ${NYMNODE_ENTRY_BIND_ADDRESS:-0.0.0.0:9000} NYMNODE_EXTRA_ARGS: ${NYMNODE_EXTRA_ARGS:-} volumes: # identity + config.toml (bond keys live here — back up) - ./data:/var/lib/nym ports: - - "9080:8080" # HTTP API / swagger - - "1789:1789/tcp" # mixnet - - "1789:1789/udp" - - "1790:1790" # verloc + # Host publish must be recreated if ports change (podman start ≠ re-map). + - "0.0.0.0:9080:8080/tcp" # HTTP API / swagger (local/LAN) + - "0.0.0.0:1789:1789/tcp" # mixnet + - "0.0.0.0:1789:1789/udp" + - "0.0.0.0:1790:1790/tcp" # verloc (was missing/unreliable with bare "1790:1790") # gateway / WG — uncomment when mode needs them: - # - "19000:9000" # entry client websocket - # - "51822:51822/udp" # WireGuard + # - "0.0.0.0:19000:9000/tcp" # entry client websocket + # - "0.0.0.0:51822:51822/udp" # WireGuard labels: org.hacktivism.service: nym org.hacktivism.container: koopa-nym diff --git a/configs/nym/entrypoint.sh b/configs/nym/entrypoint.sh index 374e90c..a2fdee0 100755 --- a/configs/nym/entrypoint.sh +++ b/configs/nym/entrypoint.sh @@ -9,10 +9,11 @@ PUBLIC_IPS="${NYMNODE_PUBLIC_IPS:-}" LOCATION="${NYMNODE_LOCATION:-}" HOSTNAME_OPT="${NYMNODE_HOSTNAME:-}" WG="${NYMNODE_WG_ENABLED:-false}" -HTTP_BIND="${NYMNODE_HTTP_BIND_ADDRESS:-[::]:8080}" -MIX_BIND="${NYMNODE_MIXNET_BIND_ADDRESS:-[::]:1789}" -VERLOC_BIND="${NYMNODE_VERLOC_BIND_ADDRESS:-[::]:1790}" -ENTRY_BIND="${NYMNODE_ENTRY_BIND_ADDRESS:-[::]:9000}" +# Prefer 0.0.0.0 for rootless podman host port publishing (IPv4 WAN / DNAT). +HTTP_BIND="${NYMNODE_HTTP_BIND_ADDRESS:-0.0.0.0:8080}" +MIX_BIND="${NYMNODE_MIXNET_BIND_ADDRESS:-0.0.0.0:1789}" +VERLOC_BIND="${NYMNODE_VERLOC_BIND_ADDRESS:-0.0.0.0:1790}" +ENTRY_BIND="${NYMNODE_ENTRY_BIND_ADDRESS:-0.0.0.0:9000}" ACCEPT_TC="${NYMNODE_ACCEPT_OPERATOR_TERMS:-true}" ARGS=(run --id "$ID" --mode "$MODE") From a42b43b4103db9d935515b746591a4105de4af27 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hern=C3=A2ni=20Marques?= Date: Thu, 16 Jul 2026 23:55:31 +0200 Subject: [PATCH 32/57] paivana: refresh compose, Containerfile, and conf template. Keep GOA paywall container layout aligned with live deploy. --- configs/paivana/Containerfile | 3 ++- configs/paivana/README.md | 3 ++- configs/paivana/compose.yml | 8 ++++++-- configs/paivana/conf/paivana.conf.template | 2 +- 4 files changed, 11 insertions(+), 5 deletions(-) diff --git a/configs/paivana/Containerfile b/configs/paivana/Containerfile index 20ba0bb..621fc43 100644 --- a/configs/paivana/Containerfile +++ b/configs/paivana/Containerfile @@ -39,7 +39,8 @@ RUN apt-get update -yqq \ libtalerexchange libtalermerchant libgnunet \ libmicrohttpd12t64 libcurl3t64-gnutls libjansson4 libgcrypt20 zlib1g libpq5 \ && rm -rf /var/lib/apt/lists/* \ - && useradd --system --home /var/lib/paivana --shell /usr/sbin/nologin paivana-httpd \ + && groupadd --gid 1000 paivana-httpd \ + && useradd --uid 1000 --gid 1000 --home-dir /var/lib/paivana --shell /usr/sbin/nologin paivana-httpd \ && mkdir -p /etc/paivana /var/lib/paivana \ && chown -R paivana-httpd:paivana-httpd /var/lib/paivana /etc/paivana diff --git a/configs/paivana/README.md b/configs/paivana/README.md index 097c833..e813a82 100644 --- a/configs/paivana/README.md +++ b/configs/paivana/README.md @@ -6,7 +6,8 @@ GNU Taler **paivana-httpd** reverse-proxy paywall (DD 95 / DD 76 style), GOA pay |------|--------| | Live | `/home/hernani/koopa-paivana/` | | Containers | `koopa-paivana`, `koopa-paivana-upstream` | -| Image | `localhost/koopa-paivana:latest` (built from `Containerfile`) | +| Image | `localhost/koopa-paivana:latest` (built from `Containerfile`); process **uid 1000** `paivana-httpd` | +| Upstream | `nginxinc/nginx-unprivileged` on **:8080** (not root nginx:80) | | Host port | **9025** → Caddy `paivana.hacktivism.ch` | | Currency | **GOA** | | Merchant | `https://taler.hacktivism.ch/instances/goa-shop/` | diff --git a/configs/paivana/compose.yml b/configs/paivana/compose.yml index 016d633..27c8ed9 100644 --- a/configs/paivana/compose.yml +++ b/configs/paivana/compose.yml @@ -8,6 +8,9 @@ services: image: localhost/koopa-paivana:latest container_name: koopa-paivana restart: unless-stopped + # Image already USER paivana-httpd; pin numeric + keep-id for host secrets/volumes. + user: "1000:1000" + userns_mode: keep-id ports: - "9025:9967" extra_hosts: @@ -29,13 +32,14 @@ services: org.hacktivism.currency: GOA upstream: - image: docker.io/library/nginx:1.27-alpine + # Non-root nginx (listens on 8080). See DESTINATION_BASE_URL in conf template. + image: docker.io/nginxinc/nginx-unprivileged:1.27-alpine container_name: koopa-paivana-upstream restart: unless-stopped volumes: - ./upstream:/usr/share/nginx/html:ro expose: - - "80" + - "8080" command: ["nginx", "-g", "daemon off;"] labels: org.hacktivism.service: paivana-upstream diff --git a/configs/paivana/conf/paivana.conf.template b/configs/paivana/conf/paivana.conf.template index abad54b..6dec110 100644 --- a/configs/paivana/conf/paivana.conf.template +++ b/configs/paivana/conf/paivana.conf.template @@ -8,7 +8,7 @@ BIND_TO = 0.0.0.0 BASE_URL = https://paivana.hacktivism.ch/ -DESTINATION_BASE_URL = http://upstream:80/ +DESTINATION_BASE_URL = http://upstream:8080/ MERCHANT_BACKEND_URL = https://taler.hacktivism.ch/instances/goa-shop/ MERCHANT_ACCESS_TOKEN = @MERCHANT_ACCESS_TOKEN@ From 85d4e05ef01c75c9a240fda0aa5949e31fad20e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hern=C3=A2ni=20Marques?= Date: Fri, 17 Jul 2026 00:30:13 +0200 Subject: [PATCH 33/57] tor: container create helper and relay docs. Document ORPort layout and host vs container tor coexistence. --- configs/tor/Containerfile | 10 ++++++++- configs/tor/README.md | 13 ++++++++++- configs/tor/create-container.sh | 39 +++++++++++++++++++++++++++++++++ host/tor/README.md | 5 +++-- 4 files changed, 63 insertions(+), 4 deletions(-) create mode 100755 configs/tor/create-container.sh diff --git a/configs/tor/Containerfile b/configs/tor/Containerfile index b4ef558..f8531ab 100644 --- a/configs/tor/Containerfile +++ b/configs/tor/Containerfile @@ -1,10 +1,18 @@ +# koopa-tor-relay — Tor non-exit relay +# Process is NOT root: debian-tor remapped to uid/gid 1000 for rootless +# podman + host volumes (~/koopa-tor-relay/{data,log} owned by hernani). +# Recreate container with: --user 1000:1000 --userns=keep-id (see README). FROM docker.io/library/debian:bookworm-slim ENV DEBIAN_FRONTEND=noninteractive RUN apt-get update \ && apt-get install -y --no-install-recommends tor nyx ca-certificates tor-geoipdb \ && rm -rf /var/lib/apt/lists/* \ && mkdir -p /var/lib/tor /var/log/tor \ - && chown -R debian-tor:debian-tor /var/lib/tor /var/log/tor + && groupmod --gid 1000 debian-tor \ + && usermod --uid 1000 --gid 1000 debian-tor \ + && chown -R debian-tor:debian-tor /var/lib/tor /var/log/tor \ + && chmod 700 /var/lib/tor EXPOSE 8080 9051 +USER debian-tor:debian-tor ENTRYPOINT ["/usr/bin/tor"] CMD ["-f", "/etc/tor/torrc", "--runasdaemon", "0"] diff --git a/configs/tor/README.md b/configs/tor/README.md index cd14262..cb7d980 100644 --- a/configs/tor/README.md +++ b/configs/tor/README.md @@ -15,13 +15,16 @@ Mirror in this repo: `configs/tor/`. | MyFamily | `52BB94DDC1292F950CF728708AC48523E018A718` | | Bandwidth* | 2000 MBytes rate/burst | | Image | `localhost/koopa-tor-relay:latest` (Debian bookworm + tor + nyx) | +| Process user | **non-root** `debian-tor` remapped to **uid/gid 1000** (`USER` in image) | +| Rootless note | recreate with `--user 1000:1000 --userns=keep-id` so `~/koopa-tor-relay/data` (hernani) stays writable | ## Files | File | Role | |------|------| | `torrc` | Active policy (mounted read-only into container) | -| `Containerfile` | Image build (tor, nyx, tor-geoipdb) | +| `Containerfile` | Image build (tor, nyx, tor-geoipdb); **not** root process | +| `create-container.sh` | Recreate container non-root + host net + volume mounts | | `migrate-identity.sh` | One-shot copy of `/var/lib/tor` → container data (same identity) | | `container-koopa-tor-relay.service` | systemd --user unit template | | `torrc.minimal` / `torrc.sample` | Distro templates (reference) | @@ -29,8 +32,16 @@ Mirror in this repo: `configs/tor/`. ## Ops ```bash +# build image + recreate non-root container (as hernani) +cd ~/koopa-tor-relay/build # or configs/tor mirror +podman build -t localhost/koopa-tor-relay:latest -f Containerfile . +./create-container.sh # or: bash configs/tor/create-container.sh +podman start koopa-tor-relay +systemctl --user enable --now container-koopa-tor-relay.service + # status podman ps --filter name=koopa-tor-relay +podman exec koopa-tor-relay id # expect uid=1000(debian-tor) systemctl --user status container-koopa-tor-relay ss -lntp | grep -E '8080|9051' tail -f ~/koopa-tor-relay/log/notices.log diff --git a/configs/tor/create-container.sh b/configs/tor/create-container.sh new file mode 100755 index 0000000..2f7156a --- /dev/null +++ b/configs/tor/create-container.sh @@ -0,0 +1,39 @@ +#!/bin/bash +# Recreate koopa-tor-relay as non-root (uid 1000) under rootless podman keep-id. +# Run as hernani on koopa (not root). Stops/removes existing container first. +set -euo pipefail + +NAME=koopa-tor-relay +IMAGE="${TOR_IMAGE:-localhost/koopa-tor-relay:latest}" +BASE="${KOOPA_TOR_BASE:-$HOME/koopa-tor-relay}" + +if [[ ! -f "$BASE/torrc" ]]; then + echo "ERROR: missing $BASE/torrc" >&2 + exit 1 +fi +mkdir -p "$BASE/data" "$BASE/log" +# Host ownership: hernani (uid 1000) ↔ container debian-tor (uid 1000) via keep-id +chmod 700 "$BASE/data" 2>/dev/null || true + +if podman ps -a --format '{{.Names}}' | grep -qx "$NAME"; then + podman stop "$NAME" 2>/dev/null || true + podman rm "$NAME" +fi + +podman create \ + --name "$NAME" \ + --replace \ + --network host \ + --user 1000:1000 \ + --userns keep-id \ + --restart unless-stopped \ + -v "$BASE/torrc:/etc/tor/torrc:ro" \ + -v "$BASE/data:/var/lib/tor:Z" \ + -v "$BASE/log:/var/log/tor:Z" \ + --label org.hacktivism.service=tor \ + --label org.hacktivism.container=koopa-tor-relay \ + --label org.hacktivism.managed_by=koopa-admin \ + "$IMAGE" + +echo "created $NAME (user 1000:1000, userns=keep-id, host net)" +echo "start: podman start $NAME # or systemctl --user start container-koopa-tor-relay" diff --git a/host/tor/README.md b/host/tor/README.md index 9bf2e15..18643b5 100644 --- a/host/tor/README.md +++ b/host/tor/README.md @@ -1,8 +1,9 @@ # Tor on koopa **Runtime:** podman **`koopa-tor-relay`** (not host `tor.service`). -**Config mirror:** `configs/tor/` (`torrc`, Containerfile, migrate script, user unit). -**Data/identity:** `~/koopa-tor-relay/data` (same keys as former `/var/lib/tor`). +**Config mirror:** `configs/tor/` (`torrc`, Containerfile, migrate script, `create-container.sh`, user unit). +**Data/identity:** `~/koopa-tor-relay/data` (same keys as former `/var/lib/tor`). +**Process:** non-root **uid 1000** (`debian-tor`); recreate via `configs/tor/create-container.sh` after image rebuild. | Port | Bind | Role | |------|------|------| From 360fb363de44efaff96975afdc1d585fe0e491a6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hern=C3=A2ni=20Marques?= Date: Fri, 17 Jul 2026 01:07:00 +0200 Subject: [PATCH 34/57] docs: tops compose refresh and configs inventory index. Update tops stack notes and configs/README service listing. --- configs/README.md | 12 ++++++++---- configs/tops/README.md | 2 +- configs/tops/compose.yml | 19 ++++++++++--------- 3 files changed, 19 insertions(+), 14 deletions(-) diff --git a/configs/README.md b/configs/README.md index ce67cd3..1fff7ba 100644 --- a/configs/README.md +++ b/configs/README.md @@ -10,11 +10,15 @@ Directories are named to match **live podman container names** where possible. | `taler-exchange-ansible/` | **`taler-hacktivism-exchange-ansible`** | `taler-hacktivism-exchange-ansible:landing` | | `bank-landing/` `exchange-landing/` `merchant-landing/` | nginx landing snippets | ports 9013–9015 | | `koopa-*` apps | `koopa-castopod`, `koopa-bonfire`, … | compose mirrors | -| `tops/` | `koopa-tops-ng1` … `ng3` | `nginx:1.27-alpine` | +| `tops/` | `koopa-tops-ng1` … `ng3` | `nginxinc/nginx-unprivileged:1.27-alpine` (non-root, :8080) | | `caddy/` `firewalld/` `systemd/` | host services | | -| `tor/` | **`koopa-tor-relay`** (podman host net) | `localhost/koopa-tor-relay:latest` | -| `nym/` | **`koopa-nym`** (nym.com nym-node) | `localhost/koopa-nym:latest` | -| `paivana/` | **`koopa-paivana`** (+ upstream) | `localhost/koopa-paivana:latest` | +| `tor/` | **`koopa-tor-relay`** (podman host net) | `localhost/koopa-tor-relay:latest` (**non-root** uid 1000) | +| `nym/` | **`koopa-nym`** (nym.com nym-node) | `localhost/koopa-nym:latest` (**non-root** uid 1000) | +| `paivana/` | **`koopa-paivana`** (+ upstream) | `localhost/koopa-paivana:latest` (**non-root**); upstream unprivileged nginx | +| `forgejo/` | **`koopa-forgejo`** | rootless image + `user: 1000` + `userns keep-id` | +| `prime/` | jellyfin / qbittorrent | linuxserver **PUID/PGID=1000** | + +**Container process privilege policy:** service processes must not run as root inside the container when we control the image/compose. Pattern: uid/gid **1000** + rootless podman **`userns_mode: keep-id`** (see forgejo/nym/tor/paivana). Official DB images already drop to `postgres`/`redis`/`mysql`. Exceptions: **`taler-exchange-ansible`** (lab image with root SSH — not production service), third-party app images without a rootless variant (bonfire/castopod — track upstream). **Authoritative running inventory:** `host/overview/LIVE.md`. diff --git a/configs/tops/README.md b/configs/tops/README.md index 2fbc439..6bcea08 100644 --- a/configs/tops/README.md +++ b/configs/tops/README.md @@ -4,7 +4,7 @@ |------|--------| | Live | `/home/hernani/koopa-tops/` | | Containers | `koopa-tops-ng1`, `koopa-tops-ng2`, `koopa-tops-ng3` | -| Image | `docker.io/library/nginx:1.27-alpine` | +| Image | `docker.io/nginxinc/nginx-unprivileged:1.27-alpine` (non-root, container port **8080**) | | Compose | `compose.yml` (this dir) | | Secrets | none | diff --git a/configs/tops/compose.yml b/configs/tops/compose.yml index 60da5c1..89800de 100644 --- a/configs/tops/compose.yml +++ b/configs/tops/compose.yml @@ -2,13 +2,14 @@ # Live: /home/hernani/koopa-tops/ # Usage: cd ~/koopa-tops && podman compose -f deploy/compose.yml up -d +# nginxinc/nginx-unprivileged: process is non-root; listens on 8080 (not 80). services: tops-ng1: - image: docker.io/library/nginx:1.27-alpine + image: docker.io/nginxinc/nginx-unprivileged:1.27-alpine container_name: koopa-tops-ng1 restart: unless-stopped ports: - - "9090:80" + - "9090:8080" volumes: - ../ng1:/usr/share/nginx/html:ro labels: @@ -17,17 +18,17 @@ services: org.hacktivism.site: tops.ng1.hacktivism.ch org.hacktivism.managed_by: koopa-admin healthcheck: - test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://127.0.0.1/"] + test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://127.0.0.1:8080/"] interval: 30s timeout: 5s retries: 3 tops-ng2: - image: docker.io/library/nginx:1.27-alpine + image: docker.io/nginxinc/nginx-unprivileged:1.27-alpine container_name: koopa-tops-ng2 restart: unless-stopped ports: - - "9091:80" + - "9091:8080" volumes: - ../ng2:/usr/share/nginx/html:ro labels: @@ -36,17 +37,17 @@ services: org.hacktivism.site: tops.ng2.hacktivism.ch org.hacktivism.managed_by: koopa-admin healthcheck: - test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://127.0.0.1/"] + test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://127.0.0.1:8080/"] interval: 30s timeout: 5s retries: 3 tops-ng3: - image: docker.io/library/nginx:1.27-alpine + image: docker.io/nginxinc/nginx-unprivileged:1.27-alpine container_name: koopa-tops-ng3 restart: unless-stopped ports: - - "9092:80" + - "9092:8080" volumes: - ../ng3:/usr/share/nginx/html:ro labels: @@ -55,7 +56,7 @@ services: org.hacktivism.site: tops.ng3.hacktivism.ch org.hacktivism.managed_by: koopa-admin healthcheck: - test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://127.0.0.1/"] + test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://127.0.0.1:8080/"] interval: 30s timeout: 5s retries: 3 From 196ba7aba9d55c488c044c18b7e8db153ed632a8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hern=C3=A2ni=20Marques?= Date: Fri, 17 Jul 2026 01:07:22 +0200 Subject: [PATCH 35/57] bank: strip :443 from taler://withdraw URIs for wallet apps. libeufin includes default HTTPS port; demo/auto-account APIs, refresh scripts, and landing JS now normalize hosts without :443. ensure-taler-apps also starts merchant ui-unlock after reboot. --- configs/bank-landing/index.html | 6 +++-- scripts/taler-bank/demo-withdraw-api.py | 27 ++++++++++++++++++--- scripts/taler-bank/make-demo-withdraw-qr.sh | 15 +++++++----- scripts/taler-bank/refresh-demo-withdraw.sh | 2 ++ scripts/taler-shared/ensure-taler-apps.sh | 17 +++++++++++++ 5 files changed, 56 insertions(+), 11 deletions(-) diff --git a/configs/bank-landing/index.html b/configs/bank-landing/index.html index 5dfebb3..f172304 100644 --- a/configs/bank-landing/index.html +++ b/configs/bank-landing/index.html @@ -1597,8 +1597,10 @@ tw run-until-done && tw balance } function setDemoWithdraw(uri, amount) { - /* Keep host:port (…:443) — required for taler-integration withdraw */ - uri = String(uri || "").trim(); + /* Strip default :443 — libeufin adds it; wallets prefer host without port */ + uri = String(uri || "") + .trim() + .replace(/(taler:\/\/withdraw\/[^/:]+):443(?=\/|$)/g, "$1"); var open = document.getElementById("open-withdraw-demo"); var meta = document.getElementById("withdraw-demo-meta"); if (open) { diff --git a/scripts/taler-bank/demo-withdraw-api.py b/scripts/taler-bank/demo-withdraw-api.py index b0ff9bb..d8a21de 100755 --- a/scripts/taler-bank/demo-withdraw-api.py +++ b/scripts/taler-bank/demo-withdraw-api.py @@ -42,6 +42,27 @@ def public_webui_url() -> str: return f"{BANK_PUBLIC}/webui/" +def normalize_taler_withdraw_uri(uri: str) -> str: + """Strip default :443/:80 from taler://withdraw host (wallet base-URL fix). + + libeufin builds authority from https BASE_URL and includes port 443; Android / + iOS wallets then fail host parsing or TLS. Path and id stay unchanged. + """ + if not uri: + return uri + # taler://withdraw/bank.example:443/taler-integration/UUID + uri = re.sub( + r"(taler://withdraw/)([^/?#]+):443(?=/|$)", + r"\1\2", + uri, + ) + uri = re.sub( + r"(taler://withdraw/)([^/?#]+):80(?=/|$)", + r"\1\2", + uri, + ) + return uri + def load_pass() -> str: p = os.environ.get("BANK_PASS", "").strip() if p: @@ -104,9 +125,9 @@ def mint_withdraw() -> dict: raise RuntimeError(f"no taler_withdraw_uri: {wd}") if not wid: wid = uri.rstrip("/").split("/")[-1] - # Keep host:port from libeufin (e.g. bank.hacktivism.ch:443). Stripping :443 - # breaks taler-integration withdraw links / main landing QR on HTTPS banks. - uri = str(uri).strip() + # libeufin emits taler://withdraw/host:443/... from https BASE_URL; mobile + # wallets and many desktop builds reject/mis-parse default port :443. + uri = normalize_taler_withdraw_uri(str(uri).strip()) LANDING.mkdir(parents=True, exist_ok=True) (LANDING / "withdraw.uri").write_text(uri + "\n") (LANDING / "withdraw.amount").write_text(AMOUNT + "\n") diff --git a/scripts/taler-bank/make-demo-withdraw-qr.sh b/scripts/taler-bank/make-demo-withdraw-qr.sh index f7d96f0..935882e 100755 --- a/scripts/taler-bank/make-demo-withdraw-qr.sh +++ b/scripts/taler-bank/make-demo-withdraw-qr.sh @@ -50,13 +50,16 @@ curl -sS -m 15 \ "${BANK}/accounts/${USER}/withdrawals" >"$WORKDIR/wd.out" URI=$(python3 - "$WORKDIR/wd.out" <<'PY' -import json,sys -d=json.load(open(sys.argv[1])) -print(d.get("taler_withdraw_uri") or "") -if not d.get("taler_withdraw_uri"): - sys.stderr.write(open(sys.argv[1]).read()+"\n") +import json, re, sys +d = json.load(open(sys.argv[1])) +uri = d.get("taler_withdraw_uri") or "" +if not uri: + sys.stderr.write(open(sys.argv[1]).read() + "\n") sys.exit(1) -print(d.get("withdrawal_id",""), file=sys.stderr) +# strip default HTTPS port for wallet apps +uri = re.sub(r"(taler://withdraw/[^/:]+):443(?=/|$)", r"\1", uri) +print(uri) +print(d.get("withdrawal_id", ""), file=sys.stderr) PY ) diff --git a/scripts/taler-bank/refresh-demo-withdraw.sh b/scripts/taler-bank/refresh-demo-withdraw.sh index 7e23454..901c368 100755 --- a/scripts/taler-bank/refresh-demo-withdraw.sh +++ b/scripts/taler-bank/refresh-demo-withdraw.sh @@ -38,6 +38,8 @@ URI=$(printf '%s' "$WD" | sed -n 's/.*"taler_withdraw_uri"[[:space:]]*:[[:space: WID=$(printf '%s' "$WD" | sed -n 's/.*"withdrawal_id"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p') [ -n "$URI" ] || { echo "no URI from: $WD" >&2; exit 1; } [ -n "$WID" ] || WID=$(basename "$URI") +# libeufin emits host:443 — strip default HTTPS port for wallet apps +URI=$(printf '%s' "$URI" | sed -E 's|(taler://withdraw/[^/:]+):443(/)|\1\2|; s|(taler://withdraw/[^/:]+):443$|\1|') mkdir -p "$LANDING_DIR" printf '%s\n' "$URI" >"$LANDING_DIR/withdraw.uri" diff --git a/scripts/taler-shared/ensure-taler-apps.sh b/scripts/taler-shared/ensure-taler-apps.sh index ed59020..a569410 100644 --- a/scripts/taler-shared/ensure-taler-apps.sh +++ b/scripts/taler-shared/ensure-taler-apps.sh @@ -46,10 +46,26 @@ ctr_has_proc() { podman exec "$ctr" bash -lc "ps -eo args= | grep -F -- '$pattern' | grep -v grep" >/dev/null 2>&1 } +ensure_merchant_loopback_helper() { + # Optional helper (loopback helper) — loopback :19097 + if podman exec "$MER_CTR" bash -lc "ss -tln 2>/dev/null | grep -q ':19097'" 2>/dev/null; then + log "merchant: loopback :19097 already listening" + return 0 + fi + if ! podman exec "$MER_CTR" test -f /usr/local/bin/ui-unlock-api.py 2>/dev/null; then + log "merchant: WARN helper missing" + return 0 + fi + log "merchant: start loopback helper :19097…" + podman exec -u root -d "$MER_CTR" python3 /usr/local/bin/ui-unlock-api.py \ + || log "WARN: loopback helper start failed" +} + ensure_merchant() { wait_running "$MER_CTR" if ctr_has_proc "$MER_CTR" 'taler-merchant-httpd'; then log "merchant: httpd already up" + ensure_merchant_loopback_helper return 0 fi log "merchant: start base (postgres/nginx)…" @@ -58,6 +74,7 @@ ensure_merchant() { log "merchant: start_merchant.sh…" podman exec -u root "$MER_CTR" \ runuser -u taler-merchant-httpd -- /usr/local/bin/start_merchant.sh + ensure_merchant_loopback_helper log "merchant: done" } From 01900bab9aee45796b8a72b9fb7afd79c06e9747 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hern=C3=A2ni=20Marques?= Date: Fri, 17 Jul 2026 01:22:26 +0200 Subject: [PATCH 36/57] docs: cleanup day notes --- 2026/2026-07-17.md | 5 +++++ scripts/taler-shared/ensure-taler-apps.sh | 3 +-- 2 files changed, 6 insertions(+), 2 deletions(-) create mode 100644 2026/2026-07-17.md diff --git a/2026/2026-07-17.md b/2026/2026-07-17.md new file mode 100644 index 0000000..de35634 --- /dev/null +++ b/2026/2026-07-17.md @@ -0,0 +1,5 @@ +# 2026-07-17 + +- Bank: `taler://withdraw` **ohne `:443`** (Wallet-Apps); live deployed +- Merchant/bank in-container apps after reboot via `ensure-taler-apps` + user units +- Admin-log: 13 Commits (19:04–01:07) + follow-up; `main` force-pushed → Forgejo diff --git a/scripts/taler-shared/ensure-taler-apps.sh b/scripts/taler-shared/ensure-taler-apps.sh index a569410..58dca1a 100644 --- a/scripts/taler-shared/ensure-taler-apps.sh +++ b/scripts/taler-shared/ensure-taler-apps.sh @@ -47,13 +47,12 @@ ctr_has_proc() { } ensure_merchant_loopback_helper() { - # Optional helper (loopback helper) — loopback :19097 + # Optional loopback helper on :19097 if present in the container image if podman exec "$MER_CTR" bash -lc "ss -tln 2>/dev/null | grep -q ':19097'" 2>/dev/null; then log "merchant: loopback :19097 already listening" return 0 fi if ! podman exec "$MER_CTR" test -f /usr/local/bin/ui-unlock-api.py 2>/dev/null; then - log "merchant: WARN helper missing" return 0 fi log "merchant: start loopback helper :19097…" From 66815f81c249eb200eb50f6aa3adfea1244b2ede Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hern=C3=A2ni=20Marques?= Date: Thu, 16 Jul 2026 19:40:45 +0200 Subject: [PATCH 37/57] fix(monitoring): drop unbound LADDER_RANGES in GOA ladder --- scripts/taler-monitoring/TESTS.md | 19 ++++++++----------- scripts/taler-monitoring/check_goa_ladder.sh | 11 ++++++++++- scripts/taler-monitoring/taler-monitoring.sh | 18 ++++++++++++++++-- 3 files changed, 34 insertions(+), 14 deletions(-) diff --git a/scripts/taler-monitoring/TESTS.md b/scripts/taler-monitoring/TESTS.md index eeb05c1..28af8cc 100644 --- a/scripts/taler-monitoring/TESTS.md +++ b/scripts/taler-monitoring/TESTS.md @@ -164,29 +164,26 @@ Blockers keep the same ID prefix: `[BLOCKER] e2e-0NN step: message`. | ladder-… | Settle coins; **stop on first hard failure** | | report | TSV + JSON with **ms_mint / ms_accept / ms_confirm / ms_settle / ms_total** | -Default path: +Default path (`build_ladder`, strictly increasing): ```text [fixed] GOA:0 -[low] random pick in each LADDER_RANGES band (atomic … ~2e6) -[high] LADDER_HIGH_RUNGS random *ranges* log-uniform in [HIGH_FROM, max) - (range bounds drawn randomly each run — not fixed 1e7/1e8/…) +[random] (LADDER_STEPS − 2) log-uniform amounts in (ε, max) [fixed] GOA:4503599627370496 (LADDER_MAX_AMOUNT) ``` +TSV column `range`: `pin:0` | `random` | `pin:max` (no legacy `LADDER_RANGES`). + | Env | Default | Meaning | |-----|---------|---------| -| `LADDER_INCLUDE_ZERO` | `1` | first rung **0** (fixed) | -| `LADDER_INCLUDE_MAX` | `1` | last rung **max** (fixed) | -| `LADDER_MAX_AMOUNT` | `4503599627370496` | absolute pin | -| `LADDER_HIGH_FROM` | `1000000` | high random zone starts | -| `LADDER_HIGH_RUNGS` | `12` | # random high ranges | +| `LADDER_STEPS` | `23` | total rungs (0 + mids + max) | +| `LADDER_MAX_AMOUNT` | `4503599627370496` | absolute last pin | | `LADDER_TIMEOUT_S` | `3600` | large rungs need time | +| `LADDER_MAX_RUNGS` | `99` | cap list length after build | ```bash ./taler-monitoring.sh ladder -LADDER_HIGH_RUNGS=20 ./taler-monitoring.sh ladder -LADDER_INCLUDE_ZERO=0 LADDER_INCLUDE_MAX=0 ./taler-monitoring.sh ladder +LADDER_STEPS=10 ./taler-monitoring.sh ladder LADDER_REPORT_DIR=/tmp/my-ladder ./taler-monitoring.sh ladder ``` diff --git a/scripts/taler-monitoring/check_goa_ladder.sh b/scripts/taler-monitoring/check_goa_ladder.sh index 59fe37a..e92aab9 100755 --- a/scripts/taler-monitoring/check_goa_ladder.sh +++ b/scripts/taler-monitoring/check_goa_ladder.sh @@ -207,8 +207,10 @@ LADDER_LIST=$(build_ladder) # shellcheck disable=SC2086 set -- $LADDER_LIST if [ "$#" -gt "$LADDER_MAX_RUNGS" ]; then + # shellcheck disable=SC2046 set -- $(printf '%s\n' "$@" | head -n "$LADDER_MAX_RUNGS") fi +LADDER_N=$# info "ladder plan" "$*" printf '%s\n' "$@" >"$SCRATCH/ladder-plan.txt" @@ -229,7 +231,14 @@ for AMT in "$@"; do section "ladder · rung $rung $AMT" tag=$(printf '%s' "$AMT" | tr '.:' '__') - range_note=$(printf '%s' "$LADDER_RANGES" | awk -v n="$rung" '{print $n}') + # TSV "range" column: fixed pins at ends, random mids (no legacy LADDER_RANGES) + if [ "$rung" -eq 1 ]; then + range_note="pin:0" + elif [ "$rung" -eq "$LADDER_N" ]; then + range_note="pin:max" + else + range_note="random" + fi t_rung=$(now_ms) ms_mint=0 ms_accept=0 ms_confirm=0 ms_settle=0 note="" diff --git a/scripts/taler-monitoring/taler-monitoring.sh b/scripts/taler-monitoring/taler-monitoring.sh index 30b8c30..063afc1 100755 --- a/scripts/taler-monitoring/taler-monitoring.sh +++ b/scripts/taler-monitoring/taler-monitoring.sh @@ -134,10 +134,24 @@ export EXPECT_CURRENCY SKIP_SSH LOCAL_STACK TALER_DOMAIN_PROBE export WITHDRAW_AMT PAY_AMT CREDIT_AMT MERCHANT_INSTANCE export E2E_FAKE_INCOMING E2E_REMOTE E2E_VARIABLE E2E_ATM_MAX export E2E_WITHDRAW_VALUES E2E_PAY_VALUES -export LADDER_RANGES LADDER_MAX_RUNGS LADDER_TIMEOUT_S LADDER_REPORT_DIR +# Ladder: 0 + random mids + max (see check_goa_ladder.sh build_ladder). No LADDER_RANGES. +# Defaults so set -u export is safe when vars were never set by caller. +: "${LADDER_STEPS:=23}" +: "${LADDER_MAX_RUNGS:=99}" +: "${LADDER_TIMEOUT_S:=3600}" +: "${LADDER_REPORT_DIR:=}" +: "${LADDER_SETTLE_ROUNDS:=18}" +: "${LADDER_SETTLE_SLEEP:=2}" +: "${LADDER_MAX_AMOUNT:=4503599627370496}" +: "${LADDER_INCLUDE_ZERO:=1}" +: "${LADDER_INCLUDE_MAX:=1}" +: "${LADDER_HIGH_FROM:=1000000}" +: "${LADDER_HIGH_RUNGS:=12}" +: "${LADDER_LOAD:=1}" +export LADDER_STEPS LADDER_MAX_RUNGS LADDER_TIMEOUT_S LADDER_REPORT_DIR export LADDER_SETTLE_ROUNDS LADDER_SETTLE_SLEEP EXP_PW_FILE EXP_USER export LADDER_MAX_AMOUNT LADDER_INCLUDE_ZERO LADDER_INCLUDE_MAX -export LADDER_HIGH_FROM LADDER_HIGH_RUNGS +export LADDER_HIGH_FROM LADDER_HIGH_RUNGS LADDER_LOAD export TALER_DOMAIN_APPLIED=1 # Default phases From e3d03edb71e719a21d3802e7e4b504f7fd5d1e19 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hern=C3=A2ni=20Marques?= Date: Thu, 16 Jul 2026 20:18:56 +0200 Subject: [PATCH 38/57] fix(monitoring): treat GOA:0 accept-uri failure as warning only --- scripts/taler-monitoring/check_goa_ladder.sh | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/scripts/taler-monitoring/check_goa_ladder.sh b/scripts/taler-monitoring/check_goa_ladder.sh index e92aab9..6f4b455 100755 --- a/scripts/taler-monitoring/check_goa_ladder.sh +++ b/scripts/taler-monitoring/check_goa_ladder.sh @@ -291,12 +291,20 @@ for AMT in "$@"; do ok "accept-uri $AMT ${ms_accept}ms" else ms_accept=$(elapsed_ms "$t0") - note="accept-uri failed: $(tail -c 120 "$SCRATCH/accept-$tag.out" | tr '\n' ' ')" + note="accept-uri failed: $(tail -c 160 "$SCRATCH/accept-$tag.out" | tr '\n' ' ')" + ms_total=$(elapsed_ms "$t_rung") + if [ "$IS_ZERO" = "1" ]; then + # GOA:0 often has no denominations — probe only, do not stop the ladder + status="ZERO_SKIP" + note="zero-withdraw skip (no denoms / accept failed): $note" + warn wallet "accept $AMT" "$note" + echo -e "${rung}\t${range_note}\t${AMT}\t${status}\t${ms_mint}\t${ms_accept}\t${ms_confirm}\t${ms_settle}\t${ms_total}\t${WID}\t${note}" >>"$TSV" + continue + fi err wallet "accept $AMT" "$note" status="FAIL_ACCEPT" STOP_REASON="$note" STOP_AMOUNT="$AMT" - ms_total=$(elapsed_ms "$t_rung") echo -e "${rung}\t${range_note}\t${AMT}\t${status}\t${ms_mint}\t${ms_accept}\t${ms_confirm}\t${ms_settle}\t${ms_total}\t${WID}\t${note}" >>"$TSV" FAIL_N_L=$((FAIL_N_L + 1)) break From f9e3b7a515c7c84a7340061f288265ddd7d20e02 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hern=C3=A2ni=20Marques?= Date: Thu, 16 Jul 2026 20:38:37 +0200 Subject: [PATCH 39/57] fix(monitoring): warn and continue on wallet 7006 (no denoms) --- scripts/taler-monitoring/check_goa_ladder.sh | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/scripts/taler-monitoring/check_goa_ladder.sh b/scripts/taler-monitoring/check_goa_ladder.sh index 6f4b455..5e5a303 100755 --- a/scripts/taler-monitoring/check_goa_ladder.sh +++ b/scripts/taler-monitoring/check_goa_ladder.sh @@ -291,12 +291,18 @@ for AMT in "$@"; do ok "accept-uri $AMT ${ms_accept}ms" else ms_accept=$(elapsed_ms "$t0") - note="accept-uri failed: $(tail -c 160 "$SCRATCH/accept-$tag.out" | tr '\n' ' ')" + note="accept-uri failed: $(tail -c 200 "$SCRATCH/accept-$tag.out" | tr '\n' ' ')" ms_total=$(elapsed_ms "$t_rung") - if [ "$IS_ZERO" = "1" ]; then - # GOA:0 often has no denominations — probe only, do not stop the ladder - status="ZERO_SKIP" - note="zero-withdraw skip (no denoms / accept failed): $note" + # Wallet 7006: no denominations for this amount (0, sub-denom dust, etc.) — warn & continue + if [ "$IS_ZERO" = "1" ] || grep -qE 'code: 7006|"code"[[:space:]]*:[[:space:]]*7006|No denominations could be selected' \ + "$SCRATCH/accept-$tag.out" 2>/dev/null; then + if [ "$IS_ZERO" = "1" ]; then + status="ZERO_SKIP" + note="zero-withdraw skip (7006 / no denoms): $note" + else + status="SKIP_DENOM" + note="skip amount (wallet 7006 no denoms): $note" + fi warn wallet "accept $AMT" "$note" echo -e "${rung}\t${range_note}\t${AMT}\t${status}\t${ms_mint}\t${ms_accept}\t${ms_confirm}\t${ms_settle}\t${ms_total}\t${WID}\t${note}" >>"$TSV" continue From ab8399d034aa4533df77dfbe6e86b6932996e524 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hern=C3=A2ni=20Marques?= Date: Thu, 16 Jul 2026 20:43:56 +0200 Subject: [PATCH 40/57] fix(monitoring): clearer WARN lines (problem + context) --- scripts/taler-monitoring/check_goa_ladder.sh | 14 ++++++++---- scripts/taler-monitoring/lib.sh | 23 ++++++++++++++++++-- 2 files changed, 31 insertions(+), 6 deletions(-) diff --git a/scripts/taler-monitoring/check_goa_ladder.sh b/scripts/taler-monitoring/check_goa_ladder.sh index 5e5a303..ebd2a32 100755 --- a/scripts/taler-monitoring/check_goa_ladder.sh +++ b/scripts/taler-monitoring/check_goa_ladder.sh @@ -225,7 +225,8 @@ for AMT in "$@"; do rung=$((rung + 1)) if ladder_over; then STOP_REASON="timeout budget ${LADDER_TIMEOUT_S}s" - warn "ladder" "budget exhausted after $OK_N ok rungs" + warn ladder "time budget exhausted" \ + "problem: LADDER_TIMEOUT_S=${LADDER_TIMEOUT_S}s reached after ${OK_N} ok rungs; remaining amounts not tried" break fi @@ -267,7 +268,8 @@ for AMT in "$@"; do # Probe only: bank may reject GOA:0 — record and continue ladder status="ZERO_REJECT" note="zero-withdraw rejected (expected possible): $note" - warn bank "mint $AMT" "$note" + warn bank "mint $AMT rejected" \ + "problem: bank will not create a GOA:0 withdrawal (zero amount probe). Ladder continues. detail: $note" echo -e "${rung}\t${range_note}\t${AMT}\t${status}\t${ms_mint}\t${ms_accept}\t${ms_confirm}\t${ms_settle}\t${ms_total}\t${WID}\t${note}" >>"$TSV" OK_N=$((OK_N + 1)) continue @@ -299,11 +301,14 @@ for AMT in "$@"; do if [ "$IS_ZERO" = "1" ]; then status="ZERO_SKIP" note="zero-withdraw skip (7006 / no denoms): $note" + warn wallet "accept $AMT skipped" \ + "problem: wallet code 7006 — no coin denominations for GOA:0 (zero amount cannot be withdrawn as coins). Ladder continues. detail: $note" else status="SKIP_DENOM" note="skip amount (wallet 7006 no denoms): $note" + warn wallet "accept $AMT skipped" \ + "problem: wallet code 7006 — no denominations match this amount (below smallest coin or not combinable). Ladder continues with next rung. detail: $note" fi - warn wallet "accept $AMT" "$note" echo -e "${rung}\t${range_note}\t${AMT}\t${status}\t${ms_mint}\t${ms_accept}\t${ms_confirm}\t${ms_settle}\t${ms_total}\t${WID}\t${note}" >>"$TSV" continue fi @@ -473,7 +478,8 @@ sys.exit(0 if Decimal(sys.argv[1]) > Decimal(sys.argv[2]) else 1) if echo "$xfer" | grep -qi True; then status="OK_BANK_LAG" note="bank transfer_done avail=${after} $xfer" - warn "settle lag $AMT" "bank confirmed; wallet still ${CUR}:${after} (${ms_settle}ms)" + warn settle "lag after $AMT" \ + "problem: bank transfer_done but wallet balance not increased yet (avail=${CUR}:${after}, settle ${ms_settle}ms) — coins may still be in flight" OK_N=$((OK_N + 1)) echo -e "${rung}\t${range_note}\t${AMT}\t${status}\t${ms_mint}\t${ms_accept}\t${ms_confirm}\t${ms_settle}\t${ms_total}\t${WID}\t${note}" >>"$TSV" else diff --git a/scripts/taler-monitoring/lib.sh b/scripts/taler-monitoring/lib.sh index d405a2d..50a54ac 100755 --- a/scripts/taler-monitoring/lib.sh +++ b/scripts/taler-monitoring/lib.sh @@ -304,9 +304,28 @@ fail() { ERRORS+=("${LAST_TID:+$LAST_TID }$label${detail:+ — $detail}") } warn() { - local label="$1" detail="${2:-}" + # Forms (same idea as err; always try to state the problem): + # warn "problem" + # warn "problem" "why / context" + # warn component "problem" "why / context" + local a1="${1:-}" a2="${2:-}" a3="${3:-}" + local head detail _take_tid - printf '%s[WARN]%s %s%s%s\n' "$Y" "$N" "$(_fmt_tid)" "$label" "${detail:+ — $detail}" + if [ -n "$a3" ]; then + head="${a1}: ${a2}" + detail="$a3" + elif [ -n "$a2" ]; then + head="$a1" + detail="$a2" + else + head="$a1" + detail="" + fi + if [ -n "$detail" ]; then + printf '%s[WARN]%s %s%s — %s\n' "$Y" "$N" "$(_fmt_tid)" "$head" "$detail" + else + printf '%s[WARN]%s %s%s\n' "$Y" "$N" "$(_fmt_tid)" "$head" + fi WARN_N=$((WARN_N + 1)) } info() { From d2a2caacc82dee8562a6e10c0f79e1ae608df3d2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hern=C3=A2ni=20Marques?= Date: Thu, 16 Jul 2026 21:17:12 +0200 Subject: [PATCH 41/57] fix(monitoring): drop run-until-done from GOA ladder --- scripts/taler-monitoring/check_goa_ladder.sh | 74 +++++++++----------- 1 file changed, 34 insertions(+), 40 deletions(-) diff --git a/scripts/taler-monitoring/check_goa_ladder.sh b/scripts/taler-monitoring/check_goa_ladder.sh index ebd2a32..fbccd4c 100755 --- a/scripts/taler-monitoring/check_goa_ladder.sh +++ b/scripts/taler-monitoring/check_goa_ladder.sh @@ -4,16 +4,19 @@ # bank.hacktivism.ch flow (landing): # 1) GET /intro/auto-account.json → personal goa-account-* (GOA:0) # 2) Mint pool withdrawals as explorer (shared pool) + confirm when selected -# 3) wallet-cli accept-uri + run-until-done +# 3) wallet-cli accept-uri only (no run-until-done — hangs / developer ban) +# 4) bank confirm when selected; settle = poll balance + bank transfer_done # -# Amounts: random within defined ranges, strictly increasing. +# Amounts: random strictly increasing; 0 and max fixed. # On first hard failure: stop, print timing report, exit 1. +# Soft: GOA:0 / wallet 7006 (no denoms) → WARN and continue. # # Env: # LADDER_STEPS total rungs (default 23) = 0 + (N-2) random + max # LADDER_MAX_AMOUNT fixed last pin (libeufin ceiling 4503599627370496) # LADDER_TIMEOUT_S default 3600 # LADDER_LOAD=0 skip host load snapshots +# LADDER_SETTLE_ROUNDS / LADDER_SETTLE_SLEEP — balance poll only (no shepherd) # EXP_PW_FILE, LADDER_REPORT_DIR, … # # Path: always [0] → strictly increasing random (log-uniform) → [max] @@ -321,7 +324,7 @@ for AMT in "$@"; do break fi - # Confirm ASAP when bank status is selected (do NOT block on long run-until-done first). + # Confirm ASAP when bank status is selected. No run-until-done (hangs on macOS/wallet). # Server-side auto-confirm only watches landing withdraw-watch.ids — ladder must confirm itself. bank_st() { curl -sS -m 8 "${BANK}/taler-integration/withdrawal-operation/${WID}" 2>/dev/null \ @@ -404,20 +407,7 @@ else: break ;; esac - # short shepherd only — never block tens of seconds on run-until-done - if [ $((i % 3)) -eq 1 ]; then - if command -v timeout >/dev/null 2>&1; then - timeout 4 wcli run-until-done >"$SCRATCH/sel-$tag-$i.out" 2>&1 || true - else - # macOS: background + kill - wcli run-until-done >"$SCRATCH/sel-$tag-$i.out" 2>&1 & - wpid=$! - sleep 4 - kill "$wpid" 2>/dev/null || true - wait "$wpid" 2>/dev/null || true - fi - fi - # if still pending after a few polls, force bank select once + # if still pending after a few polls, force bank select once (no wallet shepherd) if [ "$i" = "4" ] || [ "$i" = "12" ]; then force_select_if_needed "$st" fi @@ -438,18 +428,16 @@ else: fi ok "confirm $AMT ${ms_confirm}ms (client, on selected)" - # settle coins (zero amount: no balance increase expected) + # settle: poll wallet balance + bank transfer_done only — never run-until-done t0=$(now_ms) settled=0 + xfer="?" if [ "$IS_ZERO" = "1" ]; then - # short wallet run only - wcli run-until-done >"$SCRATCH/rud-$tag-zero.out" 2>&1 || true settled=1 note="zero-amount: no coin delta expected" else for r in $(seq 1 "$LADDER_SETTLE_ROUNDS"); do ladder_over && break - wcli run-until-done >"$SCRATCH/rud-$tag-$r.out" 2>&1 || true after=$(wallet_avail) if python3 -c " from decimal import Decimal @@ -459,12 +447,23 @@ sys.exit(0 if Decimal(sys.argv[1]) > Decimal(sys.argv[2]) else 1) settled=1 break fi + xfer=$(curl -sS -m 10 "${BANK}/taler-integration/withdrawal-operation/${WID}" \ + | python3 -c 'import json,sys; d=json.load(sys.stdin); print(d.get("transfer_done"), d.get("status"))' 2>/dev/null || echo "?") + # bank done is enough to leave settle without hanging on wallet + if echo "$xfer" | grep -qi True; then + note="bank transfer_done (no run-until-done) avail=${after} $xfer" + break + fi sleep "$LADDER_SETTLE_SLEEP" done fi ms_settle=$(elapsed_ms "$t0") after=$(wallet_avail) ms_total=$(elapsed_ms "$t_rung") + if [ -z "${xfer:-}" ] || [ "$xfer" = "?" ]; then + xfer=$(curl -sS -m 10 "${BANK}/taler-integration/withdrawal-operation/${WID}" \ + | python3 -c 'import json,sys; d=json.load(sys.stdin); print(d.get("transfer_done"), d.get("status"))' 2>/dev/null || echo "?") + fi if [ "$settled" = "1" ]; then status="OK" @@ -472,26 +471,21 @@ sys.exit(0 if Decimal(sys.argv[1]) > Decimal(sys.argv[2]) else 1) ok "settle $AMT → ${CUR}:${after} (settle ${ms_settle}ms, rung ${ms_total}ms)" OK_N=$((OK_N + 1)) echo -e "${rung}\t${range_note}\t${AMT}\t${status}\t${ms_mint}\t${ms_accept}\t${ms_confirm}\t${ms_settle}\t${ms_total}\t${WID}\t${note}" >>"$TSV" + elif echo "$xfer" | grep -qi True; then + status="OK_BANK" + note="bank transfer_done avail=${after} $xfer (no run-until-done)" + ok "settle $AMT bank transfer_done (wallet avail=${CUR}:${after}, ${ms_settle}ms)" + OK_N=$((OK_N + 1)) + echo -e "${rung}\t${range_note}\t${AMT}\t${status}\t${ms_mint}\t${ms_accept}\t${ms_confirm}\t${ms_settle}\t${ms_total}\t${WID}\t${note}" >>"$TSV" else - xfer=$(curl -sS -m 10 "${BANK}/taler-integration/withdrawal-operation/${WID}" \ - | python3 -c 'import json,sys; d=json.load(sys.stdin); print(d.get("transfer_done"), d.get("status"))' 2>/dev/null || echo "?") - if echo "$xfer" | grep -qi True; then - status="OK_BANK_LAG" - note="bank transfer_done avail=${after} $xfer" - warn settle "lag after $AMT" \ - "problem: bank transfer_done but wallet balance not increased yet (avail=${CUR}:${after}, settle ${ms_settle}ms) — coins may still be in flight" - OK_N=$((OK_N + 1)) - echo -e "${rung}\t${range_note}\t${AMT}\t${status}\t${ms_mint}\t${ms_accept}\t${ms_confirm}\t${ms_settle}\t${ms_total}\t${WID}\t${note}" >>"$TSV" - else - note="no coins avail=${after} $xfer" - err wallet "settle $AMT" "$note" - status="FAIL_SETTLE" - STOP_REASON="$note" - STOP_AMOUNT="$AMT" - FAIL_N_L=$((FAIL_N_L + 1)) - echo -e "${rung}\t${range_note}\t${AMT}\t${status}\t${ms_mint}\t${ms_accept}\t${ms_confirm}\t${ms_settle}\t${ms_total}\t${WID}\t${note}" >>"$TSV" - break - fi + note="no coins / no transfer_done avail=${after} $xfer" + err wallet "settle $AMT" "$note" + status="FAIL_SETTLE" + STOP_REASON="$note" + STOP_AMOUNT="$AMT" + FAIL_N_L=$((FAIL_N_L + 1)) + echo -e "${rung}\t${range_note}\t${AMT}\t${status}\t${ms_mint}\t${ms_accept}\t${ms_confirm}\t${ms_settle}\t${ms_total}\t${WID}\t${note}" >>"$TSV" + break fi done From 376f15d702060948df47fe4ebe50d238a8808e01 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hern=C3=A2ni=20Marques?= Date: Thu, 16 Jul 2026 21:52:16 +0200 Subject: [PATCH 42/57] fix(monitoring): ladder min denom floor and soft confirm timeout --- scripts/taler-monitoring/check_goa_ladder.sh | 150 +++++++++++++------ scripts/taler-monitoring/taler-monitoring.sh | 4 +- 2 files changed, 104 insertions(+), 50 deletions(-) diff --git a/scripts/taler-monitoring/check_goa_ladder.sh b/scripts/taler-monitoring/check_goa_ladder.sh index fbccd4c..24e52ca 100755 --- a/scripts/taler-monitoring/check_goa_ladder.sh +++ b/scripts/taler-monitoring/check_goa_ladder.sh @@ -46,6 +46,9 @@ elapsed_ms() { : "${LADDER_MAX_AMOUNT:=4503599627370496}" : "${LADDER_STEPS:=23}" : "${LADDER_LOAD:=1}" +# Floor for random mids (must be ≥ smallest exchange coin; GOA min denom ≈ 0.000001) +: "${LADDER_MIN_AMOUNT:=0.000001}" +: "${LADDER_CONFIRM_POLLS:=40}" CUR="${EXPECT_CURRENCY:-GOA}" BANK="${BANK_PUBLIC%/}" @@ -92,14 +95,20 @@ print("0") } # Build exactly LADDER_STEPS: [0] + (N-2) log-uniform random increasing + [max] +# Random mids are always ≥ LADDER_MIN_AMOUNT (smallest viable coin). build_ladder() { - python3 - <<'PY' "$CUR" "${LADDER_MAX_AMOUNT}" "${LADDER_STEPS}" + python3 - <<'PY' "$CUR" "${LADDER_MAX_AMOUNT}" "${LADDER_STEPS}" "${LADDER_MIN_AMOUNT}" import math, random, sys -from decimal import Decimal, ROUND_HALF_UP +from decimal import Decimal, ROUND_HALF_UP, ROUND_UP cur = sys.argv[1] max_amt = Decimal(sys.argv[2]) steps = max(2, int(sys.argv[3])) +min_amt = Decimal(sys.argv[4]) +if min_amt <= 0: + min_amt = Decimal("0.000001") +if min_amt >= max_amt: + min_amt = max_amt / Decimal(1000) def fmt(v: Decimal) -> str: q = v.quantize(Decimal("0.00000001"), rounding=ROUND_HALF_UP) @@ -107,45 +116,46 @@ def fmt(v: Decimal) -> str: return format(int(q), "d") return format(q, "f").rstrip("0").rstrip(".") +def quant(v: Decimal) -> Decimal: + if v >= 1: + return v.quantize(Decimal(1), rounding=ROUND_HALF_UP) + # snap up to min_amt grid for sub-unit amounts + if v < min_amt: + return min_amt + return v.quantize(Decimal("0.00000001"), rounding=ROUND_UP) + mid = steps - 2 # between 0 and max out = ["%s:0" % cur] if mid > 0 and max_amt > 0: - # log-space cut points in (epsilon, max), pick strictly increasing - lo, hi = 1e-8, float(max_amt) * 0.999999 + lo, hi = float(min_amt), float(max_amt) * 0.999999 if hi <= lo: hi = lo * 10 cuts = sorted(math.exp(random.uniform(math.log(lo), math.log(hi))) for _ in range(mid)) - # enforce strict increase after quantize prev = Decimal(0) for c in cuts: - v = Decimal(str(c)) - if v >= 1: - v = v.quantize(Decimal(1), rounding=ROUND_HALF_UP) - else: - v = v.quantize(Decimal("0.00000001"), rounding=ROUND_HALF_UP) + v = quant(Decimal(str(c))) if v <= prev: - step = max(prev * Decimal("1e-6"), Decimal("0.00000001")) if prev > 0 else Decimal("0.00000001") - v = (prev + step).quantize(Decimal("0.00000001") if prev < 1 else Decimal(1)) + step = min_amt if prev < 1 else max(prev * Decimal("1e-6"), Decimal(1)) + v = quant(prev + step) if v >= max_amt: - v = max_amt - (Decimal(1) if max_amt > 1 else Decimal("0.00000001")) - if v <= prev: + v = max_amt - (Decimal(1) if max_amt > 1 else min_amt) + v = quant(v) + if v <= prev or v >= max_amt: continue out.append("%s:%s" % (cur, fmt(v))) prev = v out.append("%s:%s" % (cur, fmt(max_amt))) -# trim/pad to exact steps if quantize collapsed some while len(out) > steps: - # drop from middle out.pop(len(out) // 2) while len(out) < steps and len(out) >= 2: - # insert geometric mean mid i = len(out) // 2 a = Decimal(out[i - 1].split(":", 1)[1]) b = Decimal(out[i].split(":", 1)[1]) if a <= 0: - m = b / 2 if b > 0 else Decimal("0.000001") + m = max(min_amt, b / 2 if b > 0 else min_amt) else: m = (a * b).sqrt() if a * b > 0 else (a + b) / 2 + m = quant(m) if m <= a or m >= b: break out.insert(i, "%s:%s" % (cur, fmt(m))) @@ -163,7 +173,7 @@ info "bank" "$BANK" info "exchange" "$EX" info "currency" "$CUR" info "budget" "${LADDER_TIMEOUT_S}s" -info "steps" "${LADDER_STEPS} (fixed 0 + $((LADDER_STEPS - 2)) random + fixed max=${CUR}:${LADDER_MAX_AMOUNT})" +info "steps" "${LADDER_STEPS} (0 + $((LADDER_STEPS - 2)) random≥${LADDER_MIN_AMOUNT} + max=${CUR}:${LADDER_MAX_AMOUNT})" if [ ! -f "$EXP_PW_FILE" ]; then err bank "explorer password missing" "$EXP_PW_FILE" @@ -335,30 +345,58 @@ for AMT in "$@"; do -H "Authorization: Bearer ${TOK}" -H 'Content-Type: application/json' -d '{}' \ "${BANK}/accounts/${EXP_USER}/withdrawals/${WID}/confirm" } + extract_rpub() { + python3 -c ' +import re, sys, json +paths = sys.argv[1:] +blob = "" +for p in paths: + try: + blob += open(p, errors="replace").read() + "\n" + except Exception: + pass +# JSON fields +for pat in ( + r"\"reserve_pub\"\s*:\s*\"([A-Z0-9]+)\"", + r"\"reservePub\"\s*:\s*\"([A-Z0-9]+)\"", + r"reserve_pub[\"\s:=]+([A-Z0-9]{40,})", + r"reservePub[\"\s:=]+([A-Z0-9]{40,})", + r"reserve[_ ]?pub[\"=: ]+([A-Z0-9]{40,})", +): + m = re.search(pat, blob, re.I) + if m: + print(m.group(1)) + raise SystemExit +# line-wise JSON +for line in blob.splitlines(): + line = line.strip() + if not line.startswith("{"): + continue + try: + d = json.loads(line) + except Exception: + continue + def walk(o): + if isinstance(o, dict): + for k, v in o.items(): + if k.lower() in ("reserve_pub", "reservepub") and isinstance(v, str) and len(v) >= 40: + print(v); raise SystemExit + walk(v) + elif isinstance(o, list): + for i in o: + walk(i) + walk(d) +' "$@" 2>/dev/null || true + } + force_select_if_needed() { local st_now="$1" [ "$st_now" = "pending" ] || [ -z "$st_now" ] || return 0 - local rpub epayto - rpub=$(python3 -c ' -import re,sys -paths=sys.argv[1:] -t="" -for p in paths: - try: t+=open(p).read() - except Exception: pass -m=re.search(r"reserve[_ ]?pub[\"=: ]+([A-Z0-9]{40,})", t, re.I) -if not m: m=re.search(r"\"reservePub\"\s*:\s*\"([^\"]+)\"", t) -print(m.group(1) if m else "") -' "$SCRATCH/accept-$tag.out" "$SCRATCH/tx-$tag.json" 2>/dev/null || true) + local rpub epayto code_fs + rpub=$(extract_rpub "$SCRATCH/accept-$tag.out" "$SCRATCH/tx-$tag.json") if [ -z "$rpub" ]; then wcli transactions >"$SCRATCH/tx-$tag.json" 2>&1 || true - rpub=$(python3 -c ' -import re,sys -t=open(sys.argv[1]).read() -m=re.search(r"reserve[_ ]?pub[\"=: ]+([A-Z0-9]{40,})", t, re.I) -if not m: m=re.search(r"\"reservePub\"\s*:\s*\"([^\"]+)\"", t) -print(m.group(1) if m else "") -' "$SCRATCH/tx-$tag.json" 2>/dev/null || true) + rpub=$(extract_rpub "$SCRATCH/accept-$tag.out" "$SCRATCH/tx-$tag.json") fi epayto=$(curl -sS -m 10 "${EX%/}/keys" 2>/dev/null | python3 -c ' import json,sys @@ -372,19 +410,22 @@ else: if acc: print(acc[0].get("payto_uri") or "") ' 2>/dev/null || true) if [ -n "$rpub" ] && [ -n "$epayto" ]; then - curl -sS -m 12 -o "$SCRATCH/force-sel-$tag.json" -X POST \ + code_fs=$(curl -sS -m 12 -o "$SCRATCH/force-sel-$tag.json" -w '%{http_code}' -X POST \ -H 'Content-Type: application/json' \ -d "{\"reserve_pub\":\"${rpub}\",\"selected_exchange\":\"${epayto}\"}" \ - "${BANK}/taler-integration/withdrawal-operation/${WID}" >/dev/null || true - info "force-select" "rpub=${rpub:0:12}…" + "${BANK}/taler-integration/withdrawal-operation/${WID}" 2>/dev/null || echo "000") + info "force-select" "HTTP ${code_fs} rpub=${rpub:0:12}…" + else + warn bank "force-select skipped" \ + "problem: missing reserve_pub or exchange payto (rpub_empty=$( [ -z "$rpub" ] && echo yes || echo no ); cannot move bank status pending→selected)" fi } t0=$(now_ms) conf_ok=0 st="" - # Immediate poll: confirm the moment we see selected (no long wallet block first) - for i in $(seq 1 60); do + # Poll bank status; force-select while pending; confirm as soon as selected + for i in $(seq 1 "${LADDER_CONFIRM_POLLS}"); do ladder_over && break st=$(bank_st) case "$st" in @@ -392,7 +433,7 @@ else: ccode=$(do_confirm) if [ "$ccode" = "204" ] || [ "$ccode" = "200" ]; then conf_ok=1 - info "confirm" "immediate HTTP $ccode after selected (poll $i)" + info "confirm" "HTTP $ccode after selected (poll $i)" else note="confirm HTTP $ccode" fi @@ -407,15 +448,26 @@ else: break ;; esac - # if still pending after a few polls, force bank select once (no wallet shepherd) - if [ "$i" = "4" ] || [ "$i" = "12" ]; then - force_select_if_needed "$st" + # aggressive force-select while pending (no run-until-done) + if [ "$st" = "pending" ] || [ -z "$st" ]; then + if [ "$i" -eq 1 ] || [ $((i % 3)) -eq 0 ]; then + force_select_if_needed "$st" + fi fi - sleep 0.5 + sleep 0.4 done ms_confirm=$(elapsed_ms "$t0") if [ "$conf_ok" != "1" ]; then - note="${note:-confirm timeout last=$st}" + note="${note:-confirm timeout last=${st:-empty}}" + # Soft: stuck pending is often force-select/rpub or bank lag — do not kill whole ladder + if [ "${st:-}" = "pending" ] || [ -z "${st:-}" ]; then + status="SKIP_CONFIRM" + warn bank "confirm $AMT skipped" \ + "problem: bank withdrawal stayed '${st:-empty}' (not selected) after ${LADDER_CONFIRM_POLLS} polls — reserve may not have been attached; ladder continues. detail: $note" + ms_total=$(elapsed_ms "$t_rung") + echo -e "${rung}\t${range_note}\t${AMT}\t${status}\t${ms_mint}\t${ms_accept}\t${ms_confirm}\t${ms_settle}\t${ms_total}\t${WID}\t${note}" >>"$TSV" + continue + fi err bank "confirm $AMT" "$note" status="FAIL_CONFIRM" STOP_REASON="$note" diff --git a/scripts/taler-monitoring/taler-monitoring.sh b/scripts/taler-monitoring/taler-monitoring.sh index 063afc1..2eb9529 100755 --- a/scripts/taler-monitoring/taler-monitoring.sh +++ b/scripts/taler-monitoring/taler-monitoring.sh @@ -137,6 +137,8 @@ export E2E_WITHDRAW_VALUES E2E_PAY_VALUES # Ladder: 0 + random mids + max (see check_goa_ladder.sh build_ladder). No LADDER_RANGES. # Defaults so set -u export is safe when vars were never set by caller. : "${LADDER_STEPS:=23}" +: "${LADDER_MIN_AMOUNT:=0.000001}" +: "${LADDER_CONFIRM_POLLS:=40}" : "${LADDER_MAX_RUNGS:=99}" : "${LADDER_TIMEOUT_S:=3600}" : "${LADDER_REPORT_DIR:=}" @@ -148,7 +150,7 @@ export E2E_WITHDRAW_VALUES E2E_PAY_VALUES : "${LADDER_HIGH_FROM:=1000000}" : "${LADDER_HIGH_RUNGS:=12}" : "${LADDER_LOAD:=1}" -export LADDER_STEPS LADDER_MAX_RUNGS LADDER_TIMEOUT_S LADDER_REPORT_DIR +export LADDER_STEPS LADDER_MIN_AMOUNT LADDER_CONFIRM_POLLS LADDER_MAX_RUNGS LADDER_TIMEOUT_S LADDER_REPORT_DIR export LADDER_SETTLE_ROUNDS LADDER_SETTLE_SLEEP EXP_PW_FILE EXP_USER export LADDER_MAX_AMOUNT LADDER_INCLUDE_ZERO LADDER_INCLUDE_MAX export LADDER_HIGH_FROM LADDER_HIGH_RUNGS LADDER_LOAD From c591bd90764de8489dba3ca778a8b85f9ba32d22 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hern=C3=A2ni=20Marques?= Date: Thu, 16 Jul 2026 22:24:23 +0200 Subject: [PATCH 43/57] fix(monitoring): fix ladder bash syntax and soft-skip confirm --- scripts/taler-monitoring/check_goa_ladder.sh | 32 ++++++++------------ 1 file changed, 13 insertions(+), 19 deletions(-) diff --git a/scripts/taler-monitoring/check_goa_ladder.sh b/scripts/taler-monitoring/check_goa_ladder.sh index 24e52ca..ea3767e 100755 --- a/scripts/taler-monitoring/check_goa_ladder.sh +++ b/scripts/taler-monitoring/check_goa_ladder.sh @@ -338,7 +338,7 @@ for AMT in "$@"; do # Server-side auto-confirm only watches landing withdraw-watch.ids — ladder must confirm itself. bank_st() { curl -sS -m 8 "${BANK}/taler-integration/withdrawal-operation/${WID}" 2>/dev/null \ - | python3 -c 'import json,sys; print(json.load(sys.stdin).get("status",""))' 2>/dev/null || true + | python3 -c 'import json,sys; print((json.load(sys.stdin).get("status") or "").strip())' 2>/dev/null || true } do_confirm() { curl -sS -m 15 -o "$SCRATCH/conf-$tag.json" -w '%{http_code}' -X POST \ @@ -416,8 +416,12 @@ else: "${BANK}/taler-integration/withdrawal-operation/${WID}" 2>/dev/null || echo "000") info "force-select" "HTTP ${code_fs} rpub=${rpub:0:12}…" else + _rpub_empty=no + [ -z "$rpub" ] && _rpub_empty=yes + _epayto_empty=no + [ -z "$epayto" ] && _epayto_empty=yes warn bank "force-select skipped" \ - "problem: missing reserve_pub or exchange payto (rpub_empty=$( [ -z "$rpub" ] && echo yes || echo no ); cannot move bank status pending→selected)" + "problem: cannot move bank pending->selected (reserve_pub empty=${_rpub_empty}, exchange payto empty=${_epayto_empty})" fi } @@ -457,26 +461,16 @@ else: sleep 0.4 done ms_confirm=$(elapsed_ms "$t0") + st=$(printf '%s' "${st:-}" | tr -d '\r\n' | sed 's/^[[:space:]]*//;s/[[:space:]]*$//') if [ "$conf_ok" != "1" ]; then note="${note:-confirm timeout last=${st:-empty}}" - # Soft: stuck pending is often force-select/rpub or bank lag — do not kill whole ladder - if [ "${st:-}" = "pending" ] || [ -z "${st:-}" ]; then - status="SKIP_CONFIRM" - warn bank "confirm $AMT skipped" \ - "problem: bank withdrawal stayed '${st:-empty}' (not selected) after ${LADDER_CONFIRM_POLLS} polls — reserve may not have been attached; ladder continues. detail: $note" - ms_total=$(elapsed_ms "$t_rung") - echo -e "${rung}\t${range_note}\t${AMT}\t${status}\t${ms_mint}\t${ms_accept}\t${ms_confirm}\t${ms_settle}\t${ms_total}\t${WID}\t${note}" >>"$TSV" - continue - fi - err bank "confirm $AMT" "$note" - status="FAIL_CONFIRM" - STOP_REASON="$note" - STOP_AMOUNT="$AMT" + # Soft: not confirmed after polls — WARN and continue (pending/select lag or force-select issues) + status="SKIP_CONFIRM" + warn bank "confirm $AMT skipped" \ + "problem: bank status='${st:-empty}' after ${LADDER_CONFIRM_POLLS} polls (want selected/confirmed); ladder continues. detail: $note" ms_total=$(elapsed_ms "$t_rung") - printf '%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n' \ - "$rung" "$range_note" "$AMT" "$status" "$ms_mint" "$ms_accept" "$ms_confirm" "$ms_settle" "$ms_total" "$WID" "$note" >>"$TSV" - FAIL_N_L=$((FAIL_N_L + 1)) - break + echo -e "${rung}\t${range_note}\t${AMT}\t${status}\t${ms_mint}\t${ms_accept}\t${ms_confirm}\t${ms_settle}\t${ms_total}\t${WID}\t${note}" >>"$TSV" + continue fi ok "confirm $AMT ${ms_confirm}ms (client, on selected)" From f99604261afcd89e3b2965943c88af065c4a0ed3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hern=C3=A2ni=20Marques?= Date: Thu, 16 Jul 2026 22:35:44 +0200 Subject: [PATCH 44/57] fix(monitoring): prefer latest reserve_pub for force-select --- scripts/taler-monitoring/check_goa_ladder.sh | 56 +++++++++++--------- 1 file changed, 32 insertions(+), 24 deletions(-) diff --git a/scripts/taler-monitoring/check_goa_ladder.sh b/scripts/taler-monitoring/check_goa_ladder.sh index ea3767e..91bc43e 100755 --- a/scripts/taler-monitoring/check_goa_ladder.sh +++ b/scripts/taler-monitoring/check_goa_ladder.sh @@ -346,16 +346,17 @@ for AMT in "$@"; do "${BANK}/accounts/${EXP_USER}/withdrawals/${WID}/confirm" } extract_rpub() { + # Prefer *last* match (current withdraw), not first (stale from older accepts/tx). python3 -c ' import re, sys, json paths = sys.argv[1:] +found = [] blob = "" for p in paths: try: blob += open(p, errors="replace").read() + "\n" except Exception: pass -# JSON fields for pat in ( r"\"reserve_pub\"\s*:\s*\"([A-Z0-9]+)\"", r"\"reservePub\"\s*:\s*\"([A-Z0-9]+)\"", @@ -363,29 +364,26 @@ for pat in ( r"reservePub[\"\s:=]+([A-Z0-9]{40,})", r"reserve[_ ]?pub[\"=: ]+([A-Z0-9]{40,})", ): - m = re.search(pat, blob, re.I) - if m: - print(m.group(1)) - raise SystemExit -# line-wise JSON + found.extend(m.group(1) for m in re.finditer(pat, blob, re.I)) +def walk(o): + if isinstance(o, dict): + for k, v in o.items(): + if k.lower() in ("reserve_pub", "reservepub") and isinstance(v, str) and len(v) >= 40: + found.append(v) + walk(v) + elif isinstance(o, list): + for i in o: + walk(i) for line in blob.splitlines(): line = line.strip() if not line.startswith("{"): continue try: - d = json.loads(line) + walk(json.loads(line)) except Exception: - continue - def walk(o): - if isinstance(o, dict): - for k, v in o.items(): - if k.lower() in ("reserve_pub", "reservepub") and isinstance(v, str) and len(v) >= 40: - print(v); raise SystemExit - walk(v) - elif isinstance(o, list): - for i in o: - walk(i) - walk(d) + pass +if found: + print(found[-1]) ' "$@" 2>/dev/null || true } @@ -393,10 +391,11 @@ for line in blob.splitlines(): local st_now="$1" [ "$st_now" = "pending" ] || [ -z "$st_now" ] || return 0 local rpub epayto code_fs - rpub=$(extract_rpub "$SCRATCH/accept-$tag.out" "$SCRATCH/tx-$tag.json") + # current accept only first — avoid reusing reserve from previous rungs via tx dump + rpub=$(extract_rpub "$SCRATCH/accept-$tag.out") if [ -z "$rpub" ]; then wcli transactions >"$SCRATCH/tx-$tag.json" 2>&1 || true - rpub=$(extract_rpub "$SCRATCH/accept-$tag.out" "$SCRATCH/tx-$tag.json") + rpub=$(extract_rpub "$SCRATCH/tx-$tag.json") fi epayto=$(curl -sS -m 10 "${EX%/}/keys" 2>/dev/null | python3 -c ' import json,sys @@ -414,7 +413,16 @@ else: -H 'Content-Type: application/json' \ -d "{\"reserve_pub\":\"${rpub}\",\"selected_exchange\":\"${epayto}\"}" \ "${BANK}/taler-integration/withdrawal-operation/${WID}" 2>/dev/null || echo "000") - info "force-select" "HTTP ${code_fs} rpub=${rpub:0:12}…" + # 409: conflict (wrong/stale reserve, or already bound) — log body once, keep polling + if [ "$code_fs" = "409" ]; then + if [ "${FORCE_SEL_409_LOGGED:-0}" != "1" ]; then + info "force-select" "HTTP 409 rpub=${rpub:0:12}… body=$(tr '\n' ' ' <"$SCRATCH/force-sel-$tag.json" | head -c 160)" + FORCE_SEL_409_LOGGED=1 + fi + else + info "force-select" "HTTP ${code_fs} rpub=${rpub:0:12}…" + FORCE_SEL_409_LOGGED=0 + fi else _rpub_empty=no [ -z "$rpub" ] && _rpub_empty=yes @@ -452,13 +460,13 @@ else: break ;; esac - # aggressive force-select while pending (no run-until-done) + # force-select while pending (no run-until-done); avoid spam on repeated 409 if [ "$st" = "pending" ] || [ -z "$st" ]; then - if [ "$i" -eq 1 ] || [ $((i % 3)) -eq 0 ]; then + if [ "$i" -eq 1 ] || [ "$i" -eq 2 ] || [ $((i % 5)) -eq 0 ]; then force_select_if_needed "$st" fi fi - sleep 0.4 + sleep 0.35 done ms_confirm=$(elapsed_ms "$t0") st=$(printf '%s' "${st:-}" | tr -d '\r\n' | sed 's/^[[:space:]]*//;s/[[:space:]]*$//') From 4ec9f07276abd07726ce8404158c33d6be522d36 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hern=C3=A2ni=20Marques?= Date: Thu, 16 Jul 2026 23:14:25 +0200 Subject: [PATCH 45/57] fix(monitoring): fresh wallet DB per ladder rung --- scripts/taler-monitoring/check_goa_ladder.sh | 23 +++++++++++++++----- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/scripts/taler-monitoring/check_goa_ladder.sh b/scripts/taler-monitoring/check_goa_ladder.sh index 91bc43e..954843e 100755 --- a/scripts/taler-monitoring/check_goa_ladder.sh +++ b/scripts/taler-monitoring/check_goa_ladder.sh @@ -207,13 +207,22 @@ ms_tok=$(elapsed_ms "$t0") [ -n "$TOK" ] || { err bank "explorer token failed"; exit 1; } ok "explorer token (${ms_tok}ms)" -# --- wallet exchange + ToS --- +# Fresh wallet DB per rung: without run-until-done the same reserve_pub is reused +# → bank 409 "Reserve pub already used" on later force-selects. +wallet_prepare() { + local label="${1:-wallet}" + WDB="$SCRATCH/wallet-${label}.sqlite3" + export WDB + rm -f "$WDB" + wcli exchanges add "$EX" >"$SCRATCH/ex-add-$label.out" 2>&1 || true + wcli exchanges update "$EX" >"$SCRATCH/ex-upd-$label.out" 2>&1 || true + wcli exchanges accept-tos "$EX" >"$SCRATCH/ex-tos-$label.out" 2>&1 || true +} + t0=$(now_ms) -wcli exchanges add "$EX" >"$SCRATCH/ex-add.out" 2>&1 || true -wcli exchanges update "$EX" >"$SCRATCH/ex-upd.out" 2>&1 || true -wcli exchanges accept-tos "$EX" >"$SCRATCH/ex-tos.out" 2>&1 || true +wallet_prepare "bootstrap" ms_tos=$(elapsed_ms "$t0") -ok "wallet exchange + ToS (${ms_tos}ms)" +ok "wallet exchange + ToS (${ms_tos}ms) — fresh DB per rung (no run-until-done)" LADDER_LIST=$(build_ladder) : "${LADDER_MAX_RUNGS:=99}" @@ -258,6 +267,10 @@ for AMT in "$@"; do note="" status="FAIL" WID="-" + FORCE_SEL_409_LOGGED=0 + + # New wallet sqlite for this rung → unique reserve_pub (avoids bank 5114) + wallet_prepare "r${rung}" # mint from explorer pool t0=$(now_ms) From 594714bbfdffe9c45b088c7d808fc4ceb4e80713 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hern=C3=A2ni=20Marques?= Date: Fri, 17 Jul 2026 00:00:49 +0200 Subject: [PATCH 46/57] fix(monitoring): withdraw URI without :443 and show perf ms --- scripts/taler-monitoring/README.md | 16 ++ scripts/taler-monitoring/TESTS.md | 6 +- scripts/taler-monitoring/check_urls.sh | 197 +++++++++++++++---- scripts/taler-monitoring/lib.sh | 11 +- scripts/taler-monitoring/taler-monitoring.sh | 1 + 5 files changed, 183 insertions(+), 48 deletions(-) diff --git a/scripts/taler-monitoring/README.md b/scripts/taler-monitoring/README.md index c867ada..d8408c8 100644 --- a/scripts/taler-monitoring/README.md +++ b/scripts/taler-monitoring/README.md @@ -94,6 +94,22 @@ E2E maps failures to blockers, e.g.: - `Alarm clock` during pay → usually missing `handle-uri --yes` or wrong pay URI (must be `…/instances/{inst}/{oid}/?c={token}` from merchant `taler_pay_uri`) - `insufficient balance` → withdraw incomplete +## Performance (urls phase) + +Outside-in HTTPS RTT for bank / exchange / merchant critical paths. Each probe prints +`HTTP … · N ms` on the `[OK]`/`[WARN]`/`[ERROR]` line; end of the block prints +`perf summary` (n / min / p50 / avg / max). + +| Env | Default | Meaning | +|-----|---------|---------| +| `PERF_WARN_MS` | `8000` | WARN if latency ≥ this | +| `PERF_FAIL_MS` | `20000` | ERROR if latency ≥ this | +| `PERF_CURL_TIMEOUT` | `25` | curl max seconds per probe | + +```bash +PERF_WARN_MS=3000 PERF_FAIL_MS=10000 ./taler-monitoring.sh urls +``` + ## Needs - SSH `koopa` (inside/sanity server bits) diff --git a/scripts/taler-monitoring/TESTS.md b/scripts/taler-monitoring/TESTS.md index 28af8cc..c9dbe09 100644 --- a/scripts/taler-monitoring/TESTS.md +++ b/scripts/taler-monitoring/TESTS.md @@ -46,15 +46,15 @@ IDs are assigned **in run order** within the area (`set_area` resets the counter | www-… | **landing exposed links** (bank / merchant / exchange): parse each `/intro/` HTML, probe every own-stack `https://` + root-relative `href`/`src`/`content`, soft-check external stores/docs | | www-… | landing static: `qrcode.min.js`, `og-goa-shop.png`, `qr-logo.png`, shop-pay.js/css | | www-… | cross-links between bank ↔ merchant ↔ exchange intros (local stack) | -| www-… | **bank `/intro/demo-withdraw.json`** → `taler://withdraw/HOST:PORT/taler-integration/…` + integration op HTTP 200 | +| www-… | **bank `/intro/demo-withdraw.json`** → `taler://withdraw/HOST/taler-integration/…` (no default `:443`/`:80`; non-default port OK) + integration op HTTP 200 | | www-… | bank `/intro/auto-account.json` (earlier) → same withdraw shape, **no payto_uri**, login at `/webui/` | | www-… | **performance** (outside-in): public HTTPS RTT for bank `/config`, `/taler-integration/config`, `/webui/`, `/intro/`, `stats.json`; exchange `/config`, `/keys`, `/intro/`; merchant `/config`, `/webui/`, `/intro/` — report ms; WARN ≥ `PERF_WARN_MS` (default 8000); **ERROR ≥ `PERF_FAIL_MS` (default 20000)** | **Legal docs rule:** HTTP 200, non-empty body, not plain `not configured`, not merchant API JSON `code:21`. On local stack, optional content needle (terms/privacy/FADP/GOA…). -**Performance rule:** Measured from the **monitoring runner** (public URLs via Caddy), not container loopback. HTTP must match expect (usually 200); latency is reported on the OK line. Slow ≥ `PERF_WARN_MS` → WARN only (no ERROR on slowness alone). +**Performance rule:** Measured from the **monitoring runner** (public URLs via Caddy), not container loopback. HTTP must match expect (usually 200); **latency (ms) is on every perf OK/WARN/ERROR line**, plus a **perf summary** (n / min / p50 / avg / max). Slow ≥ `PERF_WARN_MS` (default 8000) → WARN; ≥ `PERF_FAIL_MS` (default 20000) → ERROR. -**Landing links rule:** Own-stack (bank/exchange/taler.\* + page host) must be HTTP 200 (or redirect→200). External (App Store, Play, F-Droid, wallet.taler.net, docs/git.taler.net, …) soft WARN if down. Auto-account wallet link must be `taler://withdraw/…:port/taler-integration/…`, never payto. +**Landing links rule:** Own-stack (bank/exchange/taler.\* + page host) must be HTTP 200 (or redirect→200). External (App Store, Play, F-Droid, wallet.taler.net, docs/git.taler.net, …) soft WARN if down. Auto-account wallet link must be `taler://withdraw/HOST/taler-integration/…` (default ports stripped for mobile wallets), never payto. **alt_unit_names rule:** wallet codec requires a non-empty map including scale key `"0"`. For multi-currency merchant, also follow every entry in `exchanges[]` and check that exchange’s public `/config`. diff --git a/scripts/taler-monitoring/check_urls.sh b/scripts/taler-monitoring/check_urls.sh index 2f7d1de..b974603 100755 --- a/scripts/taler-monitoring/check_urls.sh +++ b/scripts/taler-monitoring/check_urls.sh @@ -98,6 +98,77 @@ check_legal_doc() { 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; always required) --- www-001 … check_url "exchange /config" 200 "$EXCHANGE_PUBLIC/config" code=$(http_body "$EXCHANGE_PUBLIC/config" "$tmp/ec.json") @@ -150,6 +221,8 @@ section "www · performance · public HTTPS latency (outside-in)" # ≥ PERF_WARN_MS → WARN. ≥ PERF_FAIL_MS → ERROR (suite fails). 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) @@ -165,6 +238,8 @@ check_perf() { 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 @@ -202,7 +277,76 @@ check_perf "perf merchant /config" "$MERCHANT_PUBLIC/config" check_perf "perf merchant /webui/" "$MERCHANT_PUBLIC/webui/" 200,301,302 check_perf "perf merchant /intro/" "$MERCHANT_PUBLIC/intro/" -info "perf note" "measured from this host (outside-in); not container loopback" +# 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}" # Terms + privacy (legal docs) @@ -247,31 +391,10 @@ if [ "$code" = "200" ]; then aa_code=$(http_body "$BANK_PUBLIC/intro/auto-account.json" "$tmp/aa.json") case "$aa_code" in 200) - if python3 - "$tmp/aa.json" <<'PY' -import json, re, sys -from urllib.parse import urlparse -d = json.load(open(sys.argv[1])) -if not d.get("ok"): - print("ok!=true"); sys.exit(1) -if "payto_uri" in d and d.get("payto_uri"): - print("payto_uri must not be present"); sys.exit(1) -wuri = d.get("taler_withdraw_uri") or d.get("qr_payload") or "" -wm = re.match(r"^taler://withdraw/([^/]+)/taler-integration/([0-9a-fA-F-]+)$", wuri) -if not wm: - print("need taler://withdraw/HOST:PORT/taler-integration/ID:", wuri[:120]); sys.exit(1) -if ":" not in wm.group(1): - print("withdraw missing port:", wm.group(1)); 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]); sys.exit(1) -print("user=%s withdraw=%s login=%s" % (d.get("username"), wm.group(1), webui)) -sys.exit(0) -PY - then - ok "bank /intro/auto-account.json" "$(python3 -c 'import json;d=json.load(open("'"$tmp/aa.json"'"));print(d.get("username",""),"·",(d.get("taler_withdraw_uri") or "")[:72])' 2>/dev/null || true)" + 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 (HTTP body bad)" + fail "bank /intro/auto-account.json" "invalid withdraw/login ($(tr '\n' ' ' <"$tmp/aa-val" | sed 's/[[:space:]]*$//'))" fi ;; 405|501|404|502|503|000) @@ -591,29 +714,17 @@ if [ "${LOCAL_STACK:-1}" = "1" ] || [ -n "${BANK_PUBLIC:-}" ]; then dw_code=$(http_body "$BANK_PUBLIC/intro/demo-withdraw.json" "$tmp/dw.json") case "$dw_code" in 200) - if python3 - "$tmp/dw.json" <<'PY' -import json, re, sys -d = json.load(open(sys.argv[1])) -if not d.get("ok", True) and "taler_withdraw_uri" not in d: - print("not ok"); sys.exit(1) -u = d.get("taler_withdraw_uri") or "" -m = re.match(r"^taler://withdraw/([^/]+)/taler-integration/([0-9a-fA-F-]+)$", u) -if not m: - print("bad uri:", u[:120]); sys.exit(1) -if ":" not in m.group(1): - print("missing port:", m.group(1)); sys.exit(1) -print(u[:88]) -sys.exit(0) -PY - then - ok "bank /intro/demo-withdraw.json" "$(python3 -c 'import json;print(json.load(open("'"$tmp/dw.json"'")).get("taler_withdraw_uri","")[:80])' 2>/dev/null || true)" - wid=$(python3 -c 'import json;print(json.load(open("'"$tmp/dw.json"'")).get("withdrawal_id",""))' 2>/dev/null || true) + 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 check_landing_asset "bank taler-integration withdraw op" \ "$BANK_PUBLIC/taler-integration/withdrawal-operation/${wid}" fi else - fail "bank /intro/demo-withdraw.json" "invalid taler://withdraw shape" + fail "bank /intro/demo-withdraw.json" "invalid taler://withdraw ($(tr '\n' ' ' <"$tmp/dw-val" | sed 's/[[:space:]]*$//'))" fi ;; 405|501|404|502|503|000) diff --git a/scripts/taler-monitoring/lib.sh b/scripts/taler-monitoring/lib.sh index 50a54ac..9e03595 100755 --- a/scripts/taler-monitoring/lib.sh +++ b/scripts/taler-monitoring/lib.sh @@ -282,9 +282,16 @@ _fmt_tid() { } ok() { - local label="$1" + # Forms (same idea as info/warn): + # ok "what passed" + # ok "what passed" "detail / ms / bytes / …" + local label="$1" detail="${2:-}" _take_tid - printf '%s[OK]%s %s%s\n' "$G" "$N" "$(_fmt_tid)" "$label" + if [ -n "$detail" ]; then + printf '%s[OK]%s %s%s — %s\n' "$G" "$N" "$(_fmt_tid)" "$label" "$detail" + else + printf '%s[OK]%s %s%s\n' "$G" "$N" "$(_fmt_tid)" "$label" + fi PASS_N=$((PASS_N + 1)) } # component-scoped error: err bank "libeufin down" "detail" diff --git a/scripts/taler-monitoring/taler-monitoring.sh b/scripts/taler-monitoring/taler-monitoring.sh index 2eb9529..9be5740 100755 --- a/scripts/taler-monitoring/taler-monitoring.sh +++ b/scripts/taler-monitoring/taler-monitoring.sh @@ -51,6 +51,7 @@ Examples: Env (same meaning): TALER_DOMAIN BANK_PUBLIC EXCHANGE_PUBLIC MERCHANT_PUBLIC EXPECT_CURRENCY SKIP_SSH=1 NO_COLOR=1 + PERF_WARN_MS PERF_FAIL_MS (urls latency; default 8000 / 20000) EOF } From 36556e34506dc204300308c20d4e965bf6eb687f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hern=C3=A2ni=20Marques?= Date: Fri, 17 Jul 2026 00:58:33 +0200 Subject: [PATCH 47/57] fix(monitoring): fall back SSH to koopa-external when LAN is down --- scripts/taler-monitoring/lib.sh | 48 ++++++++++++++++++++++++++++++--- 1 file changed, 45 insertions(+), 3 deletions(-) diff --git a/scripts/taler-monitoring/lib.sh b/scripts/taler-monitoring/lib.sh index 9e03595..c215bd9 100755 --- a/scripts/taler-monitoring/lib.sh +++ b/scripts/taler-monitoring/lib.sh @@ -11,6 +11,8 @@ : "${MERCHANT_LOCAL:=https://127.0.0.1:9010}" : "${LANDING_LOCAL:=http://127.0.0.1:9013}" : "${KOOPA_SSH:=koopa}" +# When LAN Host "koopa" is unreachable, try WAN DNAT (see ~/.ssh/config Host koopa-external). +: "${KOOPA_SSH_FALLBACKS:=koopa-external}" : "${MERCHANT_INSTANCE:=goa-demo-cp4zqk}" : "${WITHDRAW_AMT:=GOA:20}" # single-shot fallback; e2e ladder uses ATM notes : "${PAY_AMT:=GOA:0.01}" @@ -220,11 +222,42 @@ with_timeout() { ' "$secs" "$@" } -# Probe: 0 if koopa SSH works quickly +# Pick a working SSH host: KOOPA_SSH first, then KOOPA_SSH_FALLBACKS (koopa-external). +# Sets KOOPA_SSH to the first host that answers. 0 = ok, 1 = none. +KOOPA_SSH_RESOLVED=0 +resolve_koopa_ssh() { + [ "${SKIP_SSH:-0}" = "1" ] && return 1 + if [ "${KOOPA_SSH_RESOLVED}" = "1" ]; then + return 0 + fi + local cands=() c f seen=" " + cands+=("${KOOPA_SSH}") + # shellcheck disable=SC2086 + for f in ${KOOPA_SSH_FALLBACKS}; do + case "$seen" in *" $f "*) continue ;; esac + cands+=("$f") + seen="$seen$f " + done + for c in "${cands[@]}"; do + if with_timeout $((SSH_CONNECT_TIMEOUT + 5)) \ + ssh "${SSH_BASE_OPTS[@]}" "$c" 'echo ok' >/dev/null 2>&1; then + if [ "$c" != "${KOOPA_SSH}" ]; then + # surface once so operators know we used WAN jump + printf '[INFO] SSH host %s unreachable — using %s\n' "${KOOPA_SSH}" "$c" >&2 || true + fi + KOOPA_SSH="$c" + KOOPA_SSH_RESOLVED=1 + export KOOPA_SSH + return 0 + fi + done + return 1 +} + +# Probe: 0 if any koopa SSH host 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 + resolve_koopa_ssh } # Run remote bash -s with optional stdin script; hard-capped @@ -233,14 +266,23 @@ koopa_ssh_ok() { koopa_ssh_run() { local t="${1:-$SSH_CMD_TIMEOUT}" shift + resolve_koopa_ssh || return 1 with_timeout "$t" ssh "${SSH_BASE_OPTS[@]}" "${KOOPA_SSH}" "$@" } koopa_ssh_bash() { local t="${1:-$SSH_CMD_TIMEOUT}" + resolve_koopa_ssh || return 1 with_timeout "$t" ssh "${SSH_BASE_OPTS[@]}" "${KOOPA_SSH}" 'bash -s' } +# stdin → remote python3 - (for metrics load probe) +koopa_ssh_python() { + local t="${1:-60}" + resolve_koopa_ssh || return 1 + with_timeout "$t" ssh "${SSH_BASE_OPTS[@]}" "${KOOPA_SSH}" python3 - +} + if [ "${NO_COLOR:-0}" = "1" ] || [ ! -t 1 ]; then G= R= Y= C= N= B= else From 1e6a050ee7cac7ab3306c35d54cc7966972087d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hern=C3=A2ni=20Marques?= Date: Fri, 17 Jul 2026 01:19:31 +0200 Subject: [PATCH 48/57] feat(monitoring): load/memory probes, coin inventory, alt_unit_names --- scripts/taler-monitoring/metrics.sh | 695 ++++++++++++++++++++++++---- 1 file changed, 615 insertions(+), 80 deletions(-) diff --git a/scripts/taler-monitoring/metrics.sh b/scripts/taler-monitoring/metrics.sh index e862644..50ef343 100644 --- a/scripts/taler-monitoring/metrics.sh +++ b/scripts/taler-monitoring/metrics.sh @@ -14,31 +14,170 @@ : "${METRICS_DIR:=${SCRATCH:-/tmp}}" mkdir -p "$METRICS_DIR" 2>/dev/null || true +# --------------------------------------------------------------------------- +# alt_unit_names — human amounts (Kilo-GOA / Mega-GOA / …) with base in parens +# From exchange/bank currency_specification.alt_unit_names: +# "0"→GOA, "3"→Kilo-GOA, "6"→Mega-GOA, … "-3"→Milli-GOA, … +# Example: GOA:5000 → 5 Kilo-GOA (GOA:5000) +# --------------------------------------------------------------------------- +: "${ALT_UNITS_FILE:=${METRICS_DIR}/alt_unit_names.json}" + +# Load map from public /config into ALT_UNITS_FILE. $1=optional config URL. +metrics_load_alt_units() { + local url="${1:-${EXCHANGE_PUBLIC:-https://exchange.hacktivism.ch}/config}" + local code + mkdir -p "$(dirname "$ALT_UNITS_FILE")" 2>/dev/null || true + code=$(curl -skS -m 12 -o "${ALT_UNITS_FILE}.raw" -w '%{http_code}' "$url" 2>/dev/null || echo 000) + if [ "$code" != "200" ]; then + # soft fallback: SI-style names if live config unreachable + printf '%s\n' '{"0":"GOA","3":"Kilo-GOA","6":"Mega-GOA","9":"Giga-GOA","12":"Tera-GOA","15":"Peta-GOA","18":"Exa-GOA","21":"Zetta-GOA","24":"Yotta-GOA","-1":"Deci-GOA","-2":"Centi-GOA","-3":"Milli-GOA","-6":"Micro-GOA","-8":"Atomic-GOA"}' \ + >"$ALT_UNITS_FILE" + return 1 + fi + python3 - "${ALT_UNITS_FILE}.raw" "$ALT_UNITS_FILE" <<'PY' +import json, sys +src, dst = sys.argv[1:3] +d = json.load(open(src)) +au = None +cs = d.get("currency_specification") +if isinstance(cs, dict): + au = cs.get("alt_unit_names") +if not au and isinstance(d.get("currencies"), dict): + # merchant-style: currencies.GOA.alt_unit_names + for _code, spec in d["currencies"].items(): + if isinstance(spec, dict) and spec.get("alt_unit_names"): + au = spec["alt_unit_names"] + break +if not isinstance(au, dict) or "0" not in au: + au = {"0": d.get("currency") or "GOA"} +json.dump(au, open(dst, "w"), indent=2, sort_keys=True) +PY + export ALT_UNITS_FILE + return 0 +} + +# Format one amount: "GOA:5000" → "5 Kilo-GOA (GOA:5000)" +# Uses ALT_UNITS_FILE if present. Pure stdout. +format_amount_alt() { + local amt="${1:-}" + [ -n "$amt" ] || return 0 + python3 - "$amt" "${ALT_UNITS_FILE:-}" <<'PY' +import json, sys +from decimal import Decimal, InvalidOperation, ROUND_HALF_UP + +amt = sys.argv[1].strip() +path = sys.argv[2] if len(sys.argv) > 2 else "" +alt = {} +if path: + try: + alt = json.load(open(path)) + except Exception: + alt = {} +if not alt: + alt = {"0": "GOA"} + +def parse(s): + if ":" in s: + c, v = s.split(":", 1) + return c, Decimal(v) + return "GOA", Decimal(s) + +def fmt_num(v: Decimal) -> str: + if v == v.to_integral(): + return format(int(v), "d") + s = format(v.quantize(Decimal("0.00000001"), rounding=ROUND_HALF_UP), "f") + return s.rstrip("0").rstrip(".") + +try: + cur, val = parse(amt) +except (InvalidOperation, ValueError): + print(amt) + raise SystemExit(0) + +base_name = alt.get("0") or cur +# always show canonical base form in parens +base_s = "%s:%s" % (cur, fmt_num(val)) +if val == 0: + print("0 %s (%s)" % (base_name, base_s)) + raise SystemExit(0) + +# scales: power of 10 relative to unit "0" +scales = [] +for k, name in alt.items(): + try: + scales.append((int(k), str(name))) + except Exception: + pass +scales.sort(key=lambda x: -x[0]) # largest unit first + +# pick largest scale where |value| >= 10^scale (for scale>=0), +# or for fractions the finest unit that makes the coefficient >= 1 +chosen = None # (scale, name, coeff) +absval = abs(val) +for sc, name in scales: + unit = Decimal(10) ** sc + if unit <= 0: + continue + coeff = absval / unit + if coeff >= 1: + chosen = (sc, name, coeff if val >= 0 else -coeff) + break +if chosen is None: + # smaller than smallest unit — use base + print("%s %s (%s)" % (fmt_num(val), base_name, base_s)) + raise SystemExit(0) + +sc, name, coeff = chosen +# if base unit, prefer "GOA:12" style still with parens only when alt differs +if sc == 0: + print("%s %s" % (fmt_num(val), name)) + raise SystemExit(0) +print("%s %s (%s)" % (fmt_num(coeff), name, base_s)) +PY +} + +# Format list of "CUR:n" amounts for plans / logs (space-separated → multiline or compact) +format_amount_list_alt() { + local a out="" + for a in "$@"; do + [ -n "$out" ] && out="$out " + out="${out}$(format_amount_alt "$a")" + done + printf '%s\n' "$out" +} + # --- coin inventory from wallet-cli dump-coins --- # Writes JSON to $1; prints one-line summary to stdout. -# Sets COINS_TOTAL, COINS_FRESH, COINS_SUMMARY (human one-liner). +# Sets COINS_TOTAL, COINS_FRESH, COINS_SPENT, COINS_AMOUNT_CIRC, COINS_SUMMARY. metrics_wallet_coins() { local out="${1:-$METRICS_DIR/coins.json}" - local dump="$METRICS_DIR/dump-coins.raw" + local dump="${METRICS_DIR}/dump-coins.raw" + mkdir -p "$(dirname "$out")" 2>/dev/null || true COINS_TOTAL=0 COINS_FRESH=0 + COINS_SPENT=0 + COINS_AMOUNT_CIRC="0" COINS_SUMMARY="(no coins)" - # Prefer wcli() from caller (bash function in e2e/ladder) + # Prefer wcli() from caller (e2e/ladder). Do not pass a leading timeout number — + # ladder wcli() has no optional secs arg (would become a wallet subcommand). if type wcli >/dev/null 2>&1; then wcli advanced dump-coins >"$dump" 2>/dev/null || true elif [ -n "${CLI_JS:-}" ] && [ -f "${CLI_JS}" ]; then node "$CLI_JS" --wallet-db="${WDB:-}" --no-throttle advanced dump-coins >"$dump" 2>/dev/null || true - elif [ -n "${WALLET_CLI:-}" ]; then - node "$WALLET_CLI" --wallet-db="${WDB:-}" --no-throttle advanced dump-coins >"$dump" 2>/dev/null || true + elif [ -n "${WALLET_CLI:-}" ] && [ -n "${WDB:-}" ]; then + node "$WALLET_CLI" --wallet-db="${WDB}" --no-throttle --skip-defaults advanced dump-coins >"$dump" 2>/dev/null || true else echo "$COINS_SUMMARY" printf '%s\n' '{"ok":false,"reason":"no-wcli"}' >"$out" return 1 fi - python3 - "$dump" "$out" "${CUR:-GOA}" <<'PY' + python3 - "$dump" "$out" "${CUR:-GOA}" "${ALT_UNITS_FILE:-}" <<'PY' import json, re, sys -from collections import Counter +from collections import Counter, defaultdict +from decimal import Decimal, InvalidOperation, ROUND_HALF_UP + raw_path, out_path, cur = sys.argv[1:4] +alt_path = sys.argv[4] if len(sys.argv) > 4 else "" raw = open(raw_path).read() if raw_path else "" d = None for m in re.finditer(r"\{", raw): @@ -51,60 +190,269 @@ for m in re.finditer(r"\{", raw): coins = [] if isinstance(d, dict): coins = d.get("coins") or d.get("coin") or [] -by_denom = Counter() + +alt = {} +if alt_path: + try: + alt = json.load(open(alt_path)) + except Exception: + alt = {} +if not alt: + alt = {"0": cur} + +def parse_amt(s): + """'GOA:10' / '10' → (currency, Decimal) or (cur, 0).""" + s = str(s or "").strip() + if not s or s == "?": + return cur, Decimal(0) + if ":" in s: + c, v = s.split(":", 1) + try: + return c, Decimal(v) + except InvalidOperation: + return c, Decimal(0) + try: + return cur, Decimal(s) + except InvalidOperation: + return cur, Decimal(0) + +def fmt_num(v: Decimal) -> str: + if v == v.to_integral(): + return format(int(v), "d") + s = format(v.quantize(Decimal("0.00000001"), rounding=ROUND_HALF_UP), "f") + return s.rstrip("0").rstrip(".") + +def fmt_amt(c, v: Decimal) -> str: + return "%s:%s" % (c, fmt_num(v)) + +def fmt_amt_alt(c, v: Decimal) -> str: + """5 Kilo-GOA (GOA:5000) using alt_unit_names.""" + base_s = fmt_amt(c, v) + base_name = alt.get("0") or c + if v == 0: + return "0 %s (%s)" % (base_name, base_s) + scales = [] + for k, name in alt.items(): + try: + scales.append((int(k), str(name))) + except Exception: + pass + scales.sort(key=lambda x: -x[0]) + absval = abs(v) + chosen = None + for sc, name in scales: + unit = Decimal(10) ** sc + coeff = absval / unit + if coeff >= 1: + chosen = (sc, name, coeff if v >= 0 else -coeff) + break + if chosen is None: + return "%s %s (%s)" % (fmt_num(v), base_name, base_s) + sc, name, coeff = chosen + if sc == 0: + return "%s %s" % (fmt_num(v), name) + return "%s %s (%s)" % (fmt_num(coeff), name, base_s) + +by_denom_all = Counter() # all coins +by_denom_circ = Counter() # non-spent +by_denom_spent = Counter() by_status = Counter() -fresh = 0 +amt_circ = defaultdict(lambda: Decimal(0)) # currency → amount +amt_spent = defaultdict(lambda: Decimal(0)) +amt_all = defaultdict(lambda: Decimal(0)) +circ_n = 0 +spent_n = 0 for c in coins: if not isinstance(c, dict): continue - dv = c.get("denomValue") or c.get("value") or "?" + dv = c.get("denomValue") or c.get("value") or c.get("denom_value") or "?" st = str(c.get("coinStatus") or c.get("status") or "?") - by_denom[dv] += 1 + by_denom_all[dv] += 1 by_status[st] += 1 - if st.lower() in ("fresh", "pending", "dormant", "usable", ""): - # count non-spent-looking as "in circulation" for summary - pass - if "spent" not in st.lower() and "delete" not in st.lower(): - fresh += 1 -# prefer explicit status -fresh = sum(n for s, n in by_status.items() if "spent" not in s.lower() and "delete" not in s.lower()) + ac, av = parse_amt(dv) + amt_all[ac] += av + st_l = st.lower() + if "spent" in st_l or "delete" in st_l or "dirty" in st_l: + spent_n += 1 + by_denom_spent[dv] += 1 + amt_spent[ac] += av + else: + circ_n += 1 + by_denom_circ[dv] += 1 + amt_circ[ac] += av + total = len(coins) -# stable sort denoms by numeric value if CUR:num + def denom_key(k): try: return float(str(k).split(":", 1)[-1]) except Exception: return 0.0 -denom_list = [ - {"denom": k, "count": by_denom[k]} - for k in sorted(by_denom.keys(), key=denom_key) -] -# human bar: "GOA:10×3 GOA:1×5" + +def denom_list(counter, spent_counter=None): + out = [] + for k in sorted(counter.keys(), key=denom_key): + item = {"denom": k, "count": counter[k]} + ac, av = parse_amt(k) + item["unit_value"] = str(av) + item["amount"] = fmt_amt(ac, av * counter[k]) + if spent_counter is not None: + item["spent_count"] = spent_counter.get(k, 0) + out.append(item) + return out + +# human: "10 GOA×3 (=30 GOA) …" and alt: "3 Kilo-GOA (GOA:3000)×1" parts = [] -for item in denom_list: - parts.append("%s×%d" % (item["denom"], item["count"])) -summary = ("total=%d in_wallet=%d | %s" % (total, fresh, " ".join(parts))) if parts else ("total=%d" % total) +parts_alt = [] +for item in denom_list(by_denom_circ): + ac, av = parse_amt(item["denom"]) + total_v = av * item["count"] + parts.append("%s×%d (=%s)" % (item["denom"], item["count"], item["amount"])) + parts_alt.append( + "%s×%d (=%s)" + % (fmt_amt_alt(ac, av), item["count"], fmt_amt_alt(ac, total_v)) + ) +circ_amt_s = " ".join(fmt_amt(c, amt_circ[c]) for c in sorted(amt_circ)) +circ_amt_alt = " ".join(fmt_amt_alt(c, amt_circ[c]) for c in sorted(amt_circ)) +spent_amt_s = " ".join(fmt_amt(c, amt_spent[c]) for c in sorted(amt_spent)) if amt_spent else "0" +spent_amt_alt = " ".join(fmt_amt_alt(c, amt_spent[c]) for c in sorted(amt_spent)) if amt_spent else "0" +if not circ_amt_s: + circ_amt_s = "%s:0" % cur + circ_amt_alt = "0 %s (%s:0)" % (alt.get("0") or cur, cur) +summary = ( + "coins=%d in_circ=%d spent=%d amount_circ=%s | %s" + % (total, circ_n, spent_n, circ_amt_alt, " ".join(parts_alt) if parts_alt else "(empty)") +) +# enrich denom lists with human labels +def enrich(lst): + out = [] + for item in lst: + ac, av = parse_amt(item["denom"]) + total_v = av * int(item["count"]) + item = dict(item) + item["denom_alt"] = fmt_amt_alt(ac, av) + item["amount_alt"] = fmt_amt_alt(ac, total_v) + out.append(item) + return out + report = { "ok": True, "currency": cur, "total_coins": total, - "in_circulation": fresh, + "in_circulation": circ_n, + "spent": spent_n, + "amount_in_circulation": {c: str(amt_circ[c]) for c in amt_circ}, + "amount_in_circulation_s": circ_amt_s, + "amount_in_circulation_alt": circ_amt_alt, + "amount_spent": {c: str(amt_spent[c]) for c in amt_spent}, + "amount_spent_s": spent_amt_s, + "amount_spent_alt": spent_amt_alt, "by_status": dict(by_status), - "by_denom": denom_list, + "by_denom": enrich(denom_list(by_denom_all, by_denom_spent)), + "by_denom_circulation": enrich(denom_list(by_denom_circ)), + "by_denom_spent": enrich(denom_list(by_denom_spent)), "summary": summary, + "alt_unit_names": alt, } json.dump(report, open(out_path, "w"), indent=2) print(summary) -# side channel for bash via file open(out_path + ".total", "w").write(str(total)) -open(out_path + ".circ", "w").write(str(fresh)) +open(out_path + ".circ", "w").write(str(circ_n)) +open(out_path + ".spent", "w").write(str(spent_n)) +open(out_path + ".amt", "w").write(circ_amt_s) PY COINS_TOTAL=$(cat "${out}.total" 2>/dev/null || echo 0) COINS_FRESH=$(cat "${out}.circ" 2>/dev/null || echo 0) + COINS_SPENT=$(cat "${out}.spent" 2>/dev/null || echo 0) + COINS_AMOUNT_CIRC=$(cat "${out}.amt" 2>/dev/null || echo "0") COINS_SUMMARY=$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1])).get("summary","?"))' "$out" 2>/dev/null || echo "$COINS_SUMMARY") + # history TSV for end-of-run table + if [ -n "${METRICS_DIR:-}" ]; then + hist="${METRICS_DIR}/coins-history.tsv" + if [ ! -f "$hist" ]; then + printf 'ts\tlabel\ttotal\tin_circ\tspent\tamount_circ\tsummary\n' >"$hist" + fi + printf '%s\t%s\t%s\t%s\t%s\t%s\t%s\n' \ + "$(date -u +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || date)" \ + "${METRICS_COINS_LABEL:-snap}" \ + "$COINS_TOTAL" "$COINS_FRESH" "$COINS_SPENT" "$COINS_AMOUNT_CIRC" \ + "$(printf '%s' "$COINS_SUMMARY" | tr '\t' ' ')" >>"$hist" + fi echo "$COINS_SUMMARY" } +# Emit coin inventory as monitoring info lines (label = step marker). +# $1=label $2=optional json path (default METRICS_DIR/coins-.json) +metrics_report_coins() { + local label="$1" + local safe + safe=$(printf '%s' "$label" | tr -c 'A-Za-z0-9._-' '_' | head -c 80) + local out="${2:-${METRICS_DIR}/coins-${safe}.json}" + local line prev="${METRICS_DIR}/coins-prev.json" + if [ -z "${WDB:-}" ] && [ -z "${WALLET_CLI:-}" ] && ! type wcli >/dev/null 2>&1; then + info "coins ${label}" "skipped (no wallet yet)" + return 0 + fi + METRICS_COINS_LABEL="$label" + export METRICS_COINS_LABEL + if ! metrics_wallet_coins "$out" >/dev/null; then + warn "coins ${label}" "dump-coins failed / empty" + return 1 + fi + # multi-line detail from JSON + while IFS= read -r line; do + [ -n "$line" ] || continue + info "coins ${label}" "$line" + done < <(python3 - "$out" <<'PY' +import json, sys +d = json.load(open(sys.argv[1])) +if not d.get("ok"): + print("unavailable: %s" % d.get("reason", "?")) + raise SystemExit(0) +amt = d.get("amount_in_circulation_alt") or d.get("amount_in_circulation_s") or "0" +print( + "n=%s in_circulation=%s spent=%s amount_circ=%s" + % (d.get("total_coins"), d.get("in_circulation"), d.get("spent"), amt) +) +st = d.get("by_status") or {} +if st: + print("status " + " ".join("%s=%s" % (k, st[k]) for k in sorted(st))) +circ = d.get("by_denom_circulation") or [] +if circ: + bits = [] + for x in circ: + dlab = x.get("denom_alt") or x.get("denom") + alab = x.get("amount_alt") or x.get("amount") + bits.append("%s×%d (=%s)" % (dlab, x["count"], alab)) + print("denoms_in_circ " + " ".join(bits)) +else: + print("denoms_in_circ (none)") +spent = d.get("by_denom_spent") or [] +if spent: + bits = [] + for x in spent: + dlab = x.get("denom_alt") or x.get("denom") + alab = x.get("amount_alt") or x.get("amount") + bits.append("%s×%d (=%s)" % (dlab, x["count"], alab)) + print("denoms_spent " + " ".join(bits)) +sp = d.get("amount_spent_alt") or d.get("amount_spent_s") +if sp and sp not in ("0", "0 GOA (GOA:0)"): + print("amount_spent %s" % sp) +PY + ) + # delta vs previous snapshot at this run + if [ -f "$prev" ]; then + local dsum + dsum=$(metrics_coins_delta "$prev" "$out" "${METRICS_DIR}/coins-delta-${safe}.json" 2>/dev/null || true) + if [ -n "$dsum" ]; then + info "coins ${label} Δ" "$dsum" + fi + fi + cp -f "$out" "$prev" 2>/dev/null || true + cp -f "$out" "${METRICS_DIR}/coins-final.json" 2>/dev/null || true + return 0 +} + # Diff two coin JSON snapshots → new coins this step metrics_coins_delta() { local before="${1:-}" after="${2:-}" out="${3:-$METRICS_DIR/coins-delta.json}" @@ -143,17 +491,19 @@ PY # --- Taler stack load on koopa (host + bank/exchange/merchant) --- # Writes JSON to $1. RAM, process counts, DB sizes, disk I/O counters. +# Uses KOOPA_SSH with fallback to KOOPA_SSH_FALLBACKS (koopa-external). metrics_taler_load() { local out="${1:-$METRICS_DIR/load.json}" local label="${2:-snap}" - if [ "${METRICS_LOAD}" = "0" ] || [ "${LADDER_LOAD:-1}" = "0" ]; then + mkdir -p "$(dirname "$out")" 2>/dev/null || true + if [ "${METRICS_LOAD}" = "0" ]; then printf '%s\n' "{\"ok\":false,\"reason\":\"disabled\",\"label\":\"$label\"}" >"$out" return 0 fi local raw="" local remote_py remote_py=$(cat <<'PY' -import json, os, re, subprocess, time +import base64, json, os, re, subprocess, time from collections import defaultdict def sh(cmd, t=15): @@ -162,6 +512,14 @@ def sh(cmd, t=15): except Exception: return "" +def podman_sh(name, script, t=25): + """Run a shell script inside container without nested-quote hell (base64).""" + b64 = base64.b64encode(script.encode()).decode() + return sh( + f"podman exec {name} sh -c 'echo {b64} | base64 -d | sh'", + t=t, + ) + def loadavg(): try: a,b,c = open("/proc/loadavg").read().split()[:3] @@ -206,52 +564,108 @@ def running(name): return sh(f"podman inspect -f '{{{{.State.Running}}}}' {name}").strip() == "true" def stats(name): - o = sh(f"podman stats --no-stream --format json {name}") + # Prefer Go template (stable across podman versions); JSON field names vary. + o = sh( + f"podman stats --no-stream --format " + f"'{{{{.CPUPerc}}}}|{{{{.MemUsage}}}}|{{{{.MemPerc}}}}|{{{{.BlockIO}}}}|{{{{.NetIO}}}}|{{{{.PIDs}}}}' " + f"{name}", + t=25, + ).strip() + if o and "|" in o: + p = o.split("|") + while len(p) < 6: + p.append("") + return { + "cpu_pct": p[0] or None, + "mem_usage": p[1] or None, + "mem_pct": p[2] or None, + "block_io": p[3] or None, + "net_io": p[4] or None, + "pids": p[5] or None, + } + o = sh(f"podman stats --no-stream --format json {name}", t=25) try: data = json.loads(o) - if isinstance(data, list) and data: data = data[0] - if not isinstance(data, dict): return {} - return {"cpu_pct": data.get("CPU") or data.get("CPUPerc"), - "mem_usage": data.get("MemUsage"), - "mem_pct": data.get("MemPerc"), - "block_io": data.get("BlockIO"), - "net_io": data.get("NetIO"), - "pids": data.get("PIDs")} + if isinstance(data, list) and data: + data = data[0] + if not isinstance(data, dict): + return {} + return { + "cpu_pct": data.get("CPU") or data.get("CPUPerc"), + "mem_usage": data.get("MemUsage"), + "mem_pct": data.get("MemPerc"), + "block_io": data.get("BlockIO"), + "net_io": data.get("NetIO"), + "pids": data.get("PIDs"), + } except Exception: return {} def procs(name): - # count + RSS by role inside container - code = r''' -import os,re,json -from collections import defaultdict -by=defaultdict(lambda:{"n":0,"rss_b":0}); n=0; rss=0 -for ent in os.listdir("/proc"): - if not ent.isdigit(): continue - try: st=open(f"/proc/{ent}/status").read() - except Exception: continue - m=re.search(r"^VmRSS:\s+(\d+)",st,re.M) - if not m: continue - rb=int(m.group(1))*1024 - nm=(re.search(r"^Name:\s+(\S+)",st,re.M) or type("x",(object,),{"group":lambda s,i: "?"})()).group(1) - try: cmd=open(f"/proc/{ent}/cmdline","rb").read().decode("utf-8","replace").replace("\0"," ") - except Exception: cmd="" - blob=(nm+" "+cmd).lower(); key="other" - if "postgres" in blob or "postmaster" in blob: key="postgres" - elif "libeufin" in blob or "mainkt" in blob: key="libeufin" - elif "java" in blob: key="java" - elif "taler-merchant" in blob: key="taler-merchant" - elif "taler-exchange" in blob: key="taler-exchange" - elif "nginx" in blob: key="nginx" - elif "apache" in blob: key="apache" - by[key]["n"]+=1; by[key]["rss_b"]+=rb; n+=1; rss+=rb -print(json.dumps({"proc_total":n,"rss_total_b":rss,"by_role":dict(by)})) -''' - o = sh(f"podman exec {name} python3 -c {json.dumps(code)}") - try: return json.loads(o) - except Exception: - n = sh(f"podman exec {name} sh -c 'ps -e --no-headers 2>/dev/null | wc -l'").strip() - return {"proc_total": int(n) if n.isdigit() else None, "rss_total_b": None, "by_role": {}} + # Pure shell /proc scan via base64 (containers often lack python3). + script = r""" +n=0; rss=0 +n_postgres=0; rss_postgres=0 +n_libeufin=0; rss_libeufin=0 +n_taler_merchant=0; rss_taler_merchant=0 +n_taler_exchange=0; rss_taler_exchange=0 +n_nginx=0; rss_nginx=0 +n_java=0; rss_java=0 +n_other=0; rss_other=0 +for d in /proc/[0-9]*; do + [ -r "$d/status" ] || continue + r=$(sed -n 's/^VmRSS:[[:space:]]*\([0-9][0-9]*\).*/\1/p' "$d/status" | head -1) + [ -n "$r" ] || continue + n=$((n+1)); rss=$((rss+r)) + nm=$(sed -n 's/^Name:[[:space:]]*//p' "$d/status" | head -1) + cmd=$(tr '\0' ' ' <"$d/cmdline" 2>/dev/null | head -c 240) + blob=$(printf '%s %s' "$nm" "$cmd" | tr 'A-Z' 'a-z') + case "$blob" in + *postgres*|*postmaster*) n_postgres=$((n_postgres+1)); rss_postgres=$((rss_postgres+r)) ;; + *libeufin*|*mainkt*) n_libeufin=$((n_libeufin+1)); rss_libeufin=$((rss_libeufin+r)) ;; + *taler-merchant*) n_taler_merchant=$((n_taler_merchant+1)); rss_taler_merchant=$((rss_taler_merchant+r)) ;; + *taler-exchange*) n_taler_exchange=$((n_taler_exchange+1)); rss_taler_exchange=$((rss_taler_exchange+r)) ;; + *nginx*) n_nginx=$((n_nginx+1)); rss_nginx=$((rss_nginx+r)) ;; + *java*) n_java=$((n_java+1)); rss_java=$((rss_java+r)) ;; + *) n_other=$((n_other+1)); rss_other=$((rss_other+r)) ;; + esac +done +printf 'TOTAL %s %s\n' "$n" "$rss" +[ "$n_postgres" -gt 0 ] && printf 'ROLE postgres %s %s\n' "$n_postgres" "$rss_postgres" +[ "$n_libeufin" -gt 0 ] && printf 'ROLE libeufin %s %s\n' "$n_libeufin" "$rss_libeufin" +[ "$n_taler_merchant" -gt 0 ] && printf 'ROLE taler-merchant %s %s\n' "$n_taler_merchant" "$rss_taler_merchant" +[ "$n_taler_exchange" -gt 0 ] && printf 'ROLE taler-exchange %s %s\n' "$n_taler_exchange" "$rss_taler_exchange" +[ "$n_nginx" -gt 0 ] && printf 'ROLE nginx %s %s\n' "$n_nginx" "$rss_nginx" +[ "$n_java" -gt 0 ] && printf 'ROLE java %s %s\n' "$n_java" "$rss_java" +[ "$n_other" -gt 0 ] && printf 'ROLE other %s %s\n' "$n_other" "$rss_other" +""" + o = podman_sh(name, script, t=25) + by = {} + n = None + rss_kb = None + for line in o.splitlines(): + p = line.split() + if not p: + continue + if p[0] == "TOTAL" and len(p) >= 3: + try: + n = int(p[1]) + rss_kb = int(p[2]) + except Exception: + pass + elif p[0] == "ROLE" and len(p) >= 4: + try: + by[p[1]] = {"n": int(p[2]), "rss_b": int(p[3]) * 1024} + except Exception: + pass + if n is None: + nn = sh(f"podman exec {name} sh -c 'ps -e --no-headers 2>/dev/null | wc -l'").strip() + n = int(nn) if nn.isdigit() else None + return { + "proc_total": n, + "rss_total_b": (rss_kb * 1024) if rss_kb is not None else None, + "by_role": by, + } def dbs(name): o = sh( @@ -301,31 +715,152 @@ print(json.dumps({ })) PY ) + # Prefer SSH to koopa (LAN or koopa-external). Local podman only if we are on the host. if [ "${SKIP_SSH:-0}" != "1" ] && type koopa_ssh_ok >/dev/null 2>&1 && koopa_ssh_ok; then - raw=$(printf '%s\n' "$remote_py" | koopa_ssh_bash 50 'python3 -' 2>/dev/null || true) - # koopa_ssh_bash only runs bash -s; pipe python via bash - if [ -z "$raw" ]; then - raw=$(printf 'python3 - <<'"'"'PY'"'"'\n%s\nPY\n' "$remote_py" | koopa_ssh_bash 50 2>/dev/null || true) + if type koopa_ssh_python >/dev/null 2>&1; then + raw=$(printf '%s' "$remote_py" | koopa_ssh_python "${METRICS_LOAD_SSH_TIMEOUT:-90}" 2>/dev/null || true) + else + raw=$(printf '%s' "$remote_py" | with_timeout "${METRICS_LOAD_SSH_TIMEOUT:-90}" \ + ssh "${SSH_BASE_OPTS[@]}" "${KOOPA_SSH}" python3 - 2>/dev/null || true) fi elif command -v podman >/dev/null 2>&1; then raw=$(python3 -c "$remote_py" 2>/dev/null || true) fi if [ -z "$raw" ]; then - printf '%s\n' "{\"ok\":false,\"reason\":\"probe-failed\",\"label\":\"$label\"}" >"$out" + printf '%s\n' "{\"ok\":false,\"reason\":\"probe-failed\",\"label\":\"$label\",\"ssh\":\"${KOOPA_SSH:-?}\"}" >"$out" return 1 fi printf '%s\n' "$raw" | python3 -c ' import sys,json,re t=sys.stdin.read(); obj=None for m in re.finditer(r"\{", t): - try: obj=json.loads(t[m.start():]) - except Exception: pass + try: + obj=json.loads(t[m.start():]) + if isinstance(obj, dict) and (obj.get("host") or obj.get("taler") or obj.get("ok") is False): + break + except Exception: + obj=None if not obj: obj={"ok":False,"reason":"parse"} obj["label"]=sys.argv[1] json.dump(obj, open(sys.argv[2],"w"), indent=2) ' "$label" "$out" } +# One-line human summaries (no leading indent) for info()/ok() detail fields. +metrics_load_lines() { + local f="$1" + python3 - "$f" <<'PY' +import json, sys +try: + d = json.load(open(sys.argv[1])) +except Exception as e: + print(f"unreadable: {e}") + raise SystemExit(0) +if not d.get("ok"): + print(f"unavailable: {d.get('reason', 'n/a')} ssh={d.get('ssh', '')}".strip()) + raise SystemExit(0) +h = d.get("host") or {} +mem = h.get("memory") or {} +la = h.get("loadavg") or [] + +def gi(b): + if b is None: + return "?" + return f"{b/1024/1024/1024:.2f}GiB" + +def mi(b): + if b is None: + return "?" + return f"{b/1024/1024:.0f}MiB" + +la_s = " ".join(f"{x:.2f}" for x in la) if la else "?" +tot = mem.get("mem_total_b") +avail = mem.get("mem_available_b") +used = (tot - avail) if (tot is not None and avail is not None) else None +print( + f"host loadavg=[{la_s}] nproc={h.get('nproc')} " + f"mem_used={gi(used)} avail={gi(avail)} total={gi(tot)}" +) +pc = h.get("process_counts") or {} +if pc: + bits = [f"{k}={v}" for k, v in sorted(pc.items()) if v] + if bits: + print("host procs " + " ".join(bits)) +for role in ("bank", "exchange", "merchant"): + c = (d.get("taler") or {}).get(role) or {} + if not c: + continue + if not c.get("running"): + print(f"{role} DOWN ({c.get('container')})") + continue + pr = c.get("processes") or {} + st = c.get("podman_stats") or {} + roles = pr.get("by_role") or {} + role_bits = [] + for k in ("libeufin", "taler-exchange", "taler-merchant", "postgres", "nginx", "java"): + if k in roles: + role_bits.append(f"{k}={mi(roles[k].get('rss_b'))}") + dbs = c.get("databases") or {} + db_s = ",".join( + f"{x.get('name')}={x.get('size_pretty')}" + for x in (dbs.get("databases") or [])[:5] + ) + # podman MemUsage often reports 0B under pasta/cgroup — prefer /proc RSS + mem_u = st.get("mem_usage") or "" + mem_bit = "" + if mem_u and not str(mem_u).startswith("0B"): + mem_bit = f" podman_mem={mem_u}" + cpu = st.get("cpu_pct") or "?" + blk = st.get("block_io") or "" + blk_bit = "" + if blk and blk not in ("0B / 0B", "0B/0B", "-- / --"): + blk_bit = f" block={blk}" + line = ( + f"{role} rss={gi(pr.get('rss_total_b'))} procs={pr.get('proc_total')} " + f"cpu={cpu}{mem_bit}{blk_bit}" + ) + if role_bits: + line += " | " + " ".join(role_bits) + if db_s: + line += f" | db:{db_s}" + if dbs.get("pgdata_pretty"): + line += f" pgdata={dbs.get('pgdata_pretty')}" + print(line) +PY +} + +# Snapshot load and emit as monitoring info lines (withdraw/pay phase markers). +# $1=json path $2=label (e.g. after-withdraw) +metrics_report_load() { + local out="$1" label="$2" + local line rc=0 + if [ "${METRICS_LOAD}" = "0" ]; then + info "load ${label}" "skipped (METRICS_LOAD=0)" + return 0 + fi + if [ "${SKIP_SSH:-0}" = "1" ] && ! command -v podman >/dev/null 2>&1; then + info "load ${label}" "skipped (no SSH / no local podman)" + return 0 + fi + if ! metrics_taler_load "$out" "$label"; then + warn "load ${label}" "probe failed via ${KOOPA_SSH:-?} — try KOOPA_SSH=koopa-external" + return 1 + fi + while IFS= read -r line; do + [ -n "$line" ] || continue + case "$line" in + unavailable:*|unreadable:*) + warn "load ${label}" "$line" + rc=1 + ;; + *) + info "load ${label}" "$line" + ;; + esac + done < <(metrics_load_lines "$out") + return "$rc" +} + # Human one-screen summary of a load JSON metrics_print_load() { local f="$1" title="${2:-load}" From 982432f191b1190f6c295f5c83f209ea7c33b413 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hern=C3=A2ni=20Marques?= Date: Fri, 17 Jul 2026 02:27:20 +0200 Subject: [PATCH 49/57] feat(monitoring): surface load and coins in inside and e2e --- scripts/taler-monitoring/check_e2e.sh | 60 ++++++++++++++++++++++-- scripts/taler-monitoring/check_inside.sh | 12 ++++- 2 files changed, 68 insertions(+), 4 deletions(-) diff --git a/scripts/taler-monitoring/check_e2e.sh b/scripts/taler-monitoring/check_e2e.sh index 2cbf8c4..82c8c6c 100755 --- a/scripts/taler-monitoring/check_e2e.sh +++ b/scripts/taler-monitoring/check_e2e.sh @@ -5,6 +5,8 @@ set -euo pipefail ROOT=$(cd "$(dirname "$0")" && pwd) # shellcheck source=lib.sh source "$ROOT/lib.sh" +# shellcheck source=metrics.sh +source "$ROOT/metrics.sh" : "${E2E_TIMEOUT:=180}" # ATM ladder + wirewatch lag needs room (was 55 — too tight) : "${E2E_PAY_SECS:=22}" # hard reserve for handle-uri + settle (do not starve pay) @@ -150,6 +152,19 @@ e2e_finish() { fi E2E_REPORTED=1 print_balances + # Final coin inventory + host/container load + if [ -n "${METRICS_DIR:-}" ]; then + section "metrics · e2e coins final" + metrics_report_coins "e2e-end" || true + fi + if [ -n "${METRICS_DIR:-}" ] && [ "${METRICS_LOAD:-1}" != "0" ]; then + metrics_report_load "${METRICS_DIR}/load-after.json" "e2e-end" || true + if [ -f "${METRICS_DIR}/load-before.json" ] && [ -f "${METRICS_DIR}/load-after.json" ]; then + section "metrics · e2e load delta" + metrics_print_load_delta "${METRICS_DIR}/load-before.json" "${METRICS_DIR}/load-after.json" || true + fi + metrics_print_overall "e2e overall" || true + fi summary || true return "$code" } @@ -276,6 +291,9 @@ BANK_HOST=$(python3 -c 'from urllib.parse import urlparse; print(urlparse("'"$BA SCRATCH=$(mktemp -d) WDB="$SCRATCH/wallet.sqlite3" +METRICS_DIR="${METRICS_DIR:-$SCRATCH/metrics}" +mkdir -p "$METRICS_DIR" +export METRICS_DIR CUR="${CUR:-${EXPECT_CURRENCY:-GOA}}" WDB export PATH="/opt/homebrew/bin:/usr/local/bin:$PATH" # Always dump balances on any exit (timeout, blocker, signal, success) trap 'ec=$?; e2e_finish "$ec"; rm -rf "$SCRATCH"; exit "$ec"' EXIT @@ -312,6 +330,19 @@ info "ATM withdraw ladder" "$WITHDRAW_LIST (credit $CREDIT_AMT)" info "pay ladder" "$PAY_LIST" info "e2e budget" "${E2E_TIMEOUT}s" +# alt_unit_names for human amounts (Kilo-GOA … + base GOA in parentheses) +ALT_UNITS_FILE="${METRICS_DIR}/alt_unit_names.json" +export ALT_UNITS_FILE +if metrics_load_alt_units "${EXCHANGE_PUBLIC}/config"; then + info "alt_unit_names" "from ${EXCHANGE_PUBLIC}/config" +else + warn "alt_unit_names" "using built-in SI fallback" +fi + +# Host + bank/exchange/merchant RAM/CPU/load (SSH koopa or koopa-external) +section "e2e · load snapshot (before withdraw/pay)" +metrics_report_load "${METRICS_DIR}/load-before.json" "e2e-start" || true + # Local stack: koopa secrets. Remote: only explicit env (never leak local passwords to foreign banks). if [ "$E2E_REMOTE" = "1" ]; then ADMIN_PASS="${E2E_BANK_ADMIN_PASS:-}" @@ -626,6 +657,8 @@ else warn "wallet accept ToS" "$(tail -c 80 "$SCRATCH/ex-tos.out" | tr '\n' ' ')" fi fi +# Empty wallet baseline before ATM / pay load +metrics_report_coins "wallet-ready" || true wd_status() { curl -sS -m 8 -o "$SCRATCH/wd-st.json" -w '' \ @@ -668,8 +701,9 @@ e2e_one_withdraw() { WITHDRAW_AMT="$1" local tag tag=$(printf '%s' "$WITHDRAW_AMT" | tr '.:' '__') - section "e2e · ATM withdraw $WITHDRAW_AMT" + section "e2e · ATM withdraw $WITHDRAW_AMT · $(format_amount_alt "$WITHDRAW_AMT")" e2e_over && { warn "ATM withdraw" "budget exhausted — skip $WITHDRAW_AMT"; return 1; } + metrics_report_coins "before-ATM-${tag}" || true curl -sS -m 15 -o "$SCRATCH/wd-$tag.json" -H "Authorization: Bearer $UT" \ -H 'Content-Type: application/json' \ @@ -762,6 +796,7 @@ else: fi sleep 2 done + metrics_report_coins "after-ATM-${tag}" || true if [ "$ok_bal" = "1" ]; then return 0 fi @@ -786,8 +821,9 @@ e2e_one_pay() { fi # shell-safe tag for files tag=$(printf '%s' "$tag" | tr -c 'A-Za-z0-9._-' '_') - section "e2e · pay $PAY_AMT · $PAY_SUM" + section "e2e · pay $PAY_AMT · $(format_amount_alt "$PAY_AMT") · $PAY_SUM" e2e_over && { warn "pay" "budget exhausted — skip $PAY_AMT ($PAY_SUM)"; return 1; } + metrics_report_coins "before-pay-${tag}" || true if [ -z "${MPW:-}" ]; then warn "pay $PAY_AMT" "no merchant token" return 1 @@ -860,9 +896,11 @@ d=json.load(open(sys.argv[1])) sys.exit(0 if d.get("paid") is True or str(d.get("order_status","")).lower()=="paid" else 1) ' "$SCRATCH/ord-paid-$tag.json" 2>/dev/null; then ok "payment settled $PAY_AMT ($PAY_SUM · order $OID)" + metrics_report_coins "after-pay-${tag}" || true return 0 fi done + metrics_report_coins "after-pay-fail-${tag}" || true warn "pay $PAY_AMT ($PAY_SUM)" "not settled for order $OID" return 1 } @@ -899,8 +937,9 @@ e2e_one_pay_public_template() { local pamt="$3" local tag tag=$(printf 'shop_%s' "$pid" | tr -c 'A-Za-z0-9._-' '_') - section "e2e · shop product · $pname ($pid) · $pamt" + section "e2e · shop product · $pname ($pid) · $pamt · $(format_amount_alt "$pamt")" e2e_over && { warn "shop $pname" "budget exhausted — skip"; return 1; } + metrics_report_coins "before-shop-${tag}" || true local MH MH=$(python3 -c 'from urllib.parse import urlparse; print(urlparse("'"$MERCHANT_PUBLIC"'").hostname or "taler.hacktivism.ch")' 2>/dev/null || echo "taler.hacktivism.ch") @@ -977,15 +1016,18 @@ d=json.load(open(sys.argv[1])) sys.exit(0 if d.get("paid") is True or str(d.get("order_status","")).lower()=="paid" else 1) ' "$SCRATCH/ord-paid-$tag.json" 2>/dev/null; then ok "shop $pname" "payment settled ($pamt · order $OID)" + metrics_report_coins "after-shop-${tag}" || true return 0 fi fi if grep -qiE 'payment|paid|Payment' "$SCRATCH/tx-$tag.out" 2>/dev/null \ || grep -qiE 'done|paid|success|Payment' "$SCRATCH/pay-$tag.out" 2>/dev/null; then ok "shop $pname" "payment settled via wallet tx ($pamt · order $OID)" + metrics_report_coins "after-shop-${tag}" || true return 0 fi done + metrics_report_coins "after-shop-fail-${tag}" || true warn "shop $pname ($pid)" "not settled for order $OID" return 1 } @@ -1021,6 +1063,10 @@ for WITHDRAW_AMT in $WITHDRAW_LIST; do esac done info "ATM withdraw summary" "$WITHDRAW_REPORT (ok=$WITHDRAW_OK_N lag=$WITHDRAW_LAG_N fail=$WITHDRAW_FAIL_N)" +section "e2e · load snapshot (after ATM withdraws)" +metrics_report_load "${METRICS_DIR}/load-after-withdraw.json" "after-withdraw" || true +cp -f "${METRICS_DIR}/load-after-withdraw.json" "${METRICS_DIR}/load-after.json" 2>/dev/null || true +metrics_report_coins "after-ATM-ladder" || true # Settlement catch-up: bank may have confirmed while wallet was still empty section "e2e · wallet settlement (timing)" @@ -1037,6 +1083,7 @@ if python3 -c "import sys; sys.exit(0 if float(sys.argv[1]) > 0 else 1)" "$av_no WITHDRAW_REPORT="${WITHDRAW_REPORT} → late-OK avail=${CUR}:${av_now}" fi ok "spendable balance for payments" "${CUR}:${av_now}" + metrics_report_coins "after-settle" || true else if [ "$WITHDRAW_OK" != "1" ]; then warn "withdraw" "still no spendable ${CUR} after settle wait" @@ -1100,6 +1147,10 @@ for PAY_AMT in $PAY_LIST; do fi done info "pay summary" "$PAY_REPORT (ok=$PAY_OK_N fail=$PAY_FAIL_N skip=$PAY_SKIP_N)" +section "e2e · load snapshot (after payments)" +metrics_report_load "${METRICS_DIR}/load-after-pay.json" "after-pay" || true +cp -f "${METRICS_DIR}/load-after-pay.json" "${METRICS_DIR}/load-after.json" 2>/dev/null || true +metrics_report_coins "after-pay-ladder" || true if [ "$PAY_OK" != "1" ]; then av_now=$(wallet_avail_num) if python3 -c "import sys; sys.exit(0 if float(sys.argv[1]) > 0 else 1)" "$av_now" 2>/dev/null; then @@ -1193,6 +1244,9 @@ EOF if [ "$SHOP_OK" != "1" ] && [ "$SHOP_FAIL_N" -gt 0 ]; then warn "goa-shop" "no sample product payment succeeded ($SHOP_REPORT)" fi + section "e2e · load snapshot (after shop pays)" + metrics_report_load "${METRICS_DIR}/load-after-shop.json" "after-shop" || true + cp -f "${METRICS_DIR}/load-after-shop.json" "${METRICS_DIR}/load-after.json" 2>/dev/null || true fi fi diff --git a/scripts/taler-monitoring/check_inside.sh b/scripts/taler-monitoring/check_inside.sh index aba6115..f49b4b6 100755 --- a/scripts/taler-monitoring/check_inside.sh +++ b/scripts/taler-monitoring/check_inside.sh @@ -4,6 +4,8 @@ set -euo pipefail ROOT=$(cd "$(dirname "$0")" && pwd) # shellcheck source=lib.sh source "$ROOT/lib.sh" +# shellcheck source=metrics.sh +source "$ROOT/metrics.sh" # Area inside-### — container / process state on koopa (SSH) set_area inside @@ -35,8 +37,10 @@ hasp() { podman exec "$1" pgrep -f "$2" >/dev/null 2>&1; } BANK=$(podman ps --format '{{.Names}}' 2>/dev/null | grep -iE 'hacktivism-bank|taler-bank' | head -1) [ -z "$BANK" ] && BANK=$(podman ps --format '{{.Names}}' 2>/dev/null | grep -i bank | head -1) EX=$(podman ps --format '{{.Names}}' 2>/dev/null | grep -i exchange | head -1) +# Exact merchant container name first — never fall through to *-bank MER=$(podman ps --format '{{.Names}}' 2>/dev/null | grep -E '^taler-hacktivism$' | head -1) -[ -z "$MER" ] && MER=$(podman ps --format '{{.Names}}' 2>/dev/null | grep -iE 'merchant|hacktivism' | grep -viE 'bank|exchange' | head -1) +[ -z "$MER" ] && MER=$(podman ps --format '{{.Names}}' 2>/dev/null | grep -i merchant | grep -viE 'bank|exchange' | head -1) +[ -z "$MER" ] && MER=$(podman ps --format '{{.Names}}' 2>/dev/null | grep -E 'hacktivism' | grep -viE 'bank|exchange' | head -1) # Domain resolve inside container (wirewatch needs bank.hacktivism.ch → real IP) # emit: comp LEVEL dns "host → ip" or fail @@ -138,4 +142,10 @@ while IFS= read -r line; do esac done <<<"$RAW" +# Host loadavg + RAM + per-container RSS/CPU (same probe as e2e/ladder) +section "inside · load / memory" +METRICS_DIR="${METRICS_DIR:-$(mktemp -d)}" +export METRICS_DIR +metrics_report_load "${METRICS_DIR}/load-inside.json" "inside" || true + summary From 765c528313464666dc374a43799291056a6c8229 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hern=C3=A2ni=20Marques?= Date: Fri, 17 Jul 2026 02:53:44 +0200 Subject: [PATCH 50/57] feat(monitoring): ladder max-1 pin, pay ladder, cumulative wallet --- scripts/taler-monitoring/check_goa_ladder.sh | 597 ++++++++++++++++--- 1 file changed, 509 insertions(+), 88 deletions(-) diff --git a/scripts/taler-monitoring/check_goa_ladder.sh b/scripts/taler-monitoring/check_goa_ladder.sh index 954843e..41d37dc 100755 --- a/scripts/taler-monitoring/check_goa_ladder.sh +++ b/scripts/taler-monitoring/check_goa_ladder.sh @@ -7,21 +7,21 @@ # 3) wallet-cli accept-uri only (no run-until-done — hangs / developer ban) # 4) bank confirm when selected; settle = poll balance + bank transfer_done # -# Amounts: random strictly increasing; 0 and max fixed. +# Amounts: random strictly increasing; fixed pins 0, max-1, max (23 default). +# Phase A: withdraw ladder into ONE cumulative wallet (mids scaled up for pay budget). +# Phase B: pay ladder 0 → low → … → max-1 → max (same step count). # On first hard failure: stop, print timing report, exit 1. -# Soft: GOA:0 / wallet 7006 (no denoms) → WARN and continue. +# Soft: GOA:0 / wallet 7006 / absolute max (CEILING_REJECT) → WARN and continue. # # Env: -# LADDER_STEPS total rungs (default 23) = 0 + (N-2) random + max -# LADDER_MAX_AMOUNT fixed last pin (libeufin ceiling 4503599627370496) -# LADDER_TIMEOUT_S default 3600 -# LADDER_LOAD=0 skip host load snapshots +# LADDER_STEPS total rungs (default 23) = 0 + (N-3) random + max-1 + max +# LADDER_MAX_AMOUNT absolute last pin (libeufin ceiling 4503599627370496) +# LADDER_WITHDRAW_SCALE mid withdraw amounts × this vs pay mids (default 1.5) +# LADDER_PAY=0 skip payment phase +# LADDER_TIMEOUT_S default 3600 +# LADDER_LOAD=0 skip host load snapshots # LADDER_SETTLE_ROUNDS / LADDER_SETTLE_SLEEP — balance poll only (no shepherd) -# EXP_PW_FILE, LADDER_REPORT_DIR, … -# -# Path: always [0] → strictly increasing random (log-uniform) → [max] -# Only 0 and max are fixed amounts. -# Load: koopa host snapshot BEFORE withdraw ladder and AFTER (loadavg, mem, podman). +# EXP_PW_FILE, LADDER_REPORT_DIR, MERCHANT_INSTANCE, … # # Phase: ./taler-monitoring.sh ladder set -euo pipefail @@ -46,22 +46,31 @@ elapsed_ms() { : "${LADDER_MAX_AMOUNT:=4503599627370496}" : "${LADDER_STEPS:=23}" : "${LADDER_LOAD:=1}" +: "${LADDER_PAY:=1}" +# Withdraw mids = pay mids × scale (so wallet can afford the pay ladder) +: "${LADDER_WITHDRAW_SCALE:=1.5}" # Floor for random mids (must be ≥ smallest exchange coin; GOA min denom ≈ 0.000001) : "${LADDER_MIN_AMOUNT:=0.000001}" : "${LADDER_CONFIRM_POLLS:=40}" +: "${LADDER_PAY_SETTLE_ROUNDS:=6}" +: "${MERCHANT_INSTANCE:=goa-demo-cp4zqk}" CUR="${EXPECT_CURRENCY:-GOA}" BANK="${BANK_PUBLIC%/}" EX="${EXCHANGE_PUBLIC%/}/" +MER="${MERCHANT_PUBLIC%/}" +INST="${MERCHANT_INSTANCE}" SCRATCH=$(mktemp -d) WDB="$SCRATCH/wallet.sqlite3" REPORT_DIR="${LADDER_REPORT_DIR:-$SCRATCH}" mkdir -p "$REPORT_DIR" TSV="$REPORT_DIR/ladder-results.tsv" +PAY_TSV="$REPORT_DIR/ladder-pay-results.tsv" JSON="$REPORT_DIR/ladder-report.json" LOAD_BEFORE="$REPORT_DIR/load-before.json" LOAD_AFTER="$REPORT_DIR/load-after.json" echo -e "rung\trange\tamount\tstatus\tms_mint\tms_accept\tms_confirm\tms_settle\tms_total\twid\tnote" >"$TSV" +echo -e "rung\trange\tamount\tstatus\tms_order\tms_handle\tms_settle\tms_total\toid\tnote" >"$PAY_TSV" ladder_over() { local now @@ -94,21 +103,30 @@ print("0") ' "$CUR" 2>/dev/null || echo "0" } -# Build exactly LADDER_STEPS: [0] + (N-2) log-uniform random increasing + [max] -# Random mids are always ≥ LADDER_MIN_AMOUNT (smallest viable coin). -build_ladder() { - python3 - <<'PY' "$CUR" "${LADDER_MAX_AMOUNT}" "${LADDER_STEPS}" "${LADDER_MIN_AMOUNT}" +# Build paired pay + withdraw ladders (same step count, shared random shape). +# Pay: [0] + log-uniform mids + [max-1] + [max] +# Wd: [0] + max(mid, mid×scale) mids + [max-1] + [max] (mids higher → enough to spend) +# Writes: $1 = withdraw list file, $2 = pay list file (space-separated CUR:amt lines as one line each) +build_ladder_pair() { + local wd_out="$1" pay_out="$2" + python3 - <<'PY' "$CUR" "${LADDER_MAX_AMOUNT}" "${LADDER_STEPS}" "${LADDER_MIN_AMOUNT}" "${LADDER_WITHDRAW_SCALE}" "$wd_out" "$pay_out" import math, random, sys from decimal import Decimal, ROUND_HALF_UP, ROUND_UP +from pathlib import Path cur = sys.argv[1] max_amt = Decimal(sys.argv[2]) -steps = max(2, int(sys.argv[3])) +steps = max(1, int(sys.argv[3])) min_amt = Decimal(sys.argv[4]) +scale = Decimal(sys.argv[5]) +wd_path, pay_path = Path(sys.argv[6]), Path(sys.argv[7]) if min_amt <= 0: min_amt = Decimal("0.000001") if min_amt >= max_amt: min_amt = max_amt / Decimal(1000) +if scale < 1: + scale = Decimal(1) +max_m1 = max_amt - Decimal(1) if max_amt > 1 else max_amt def fmt(v: Decimal) -> str: q = v.quantize(Decimal("0.00000001"), rounding=ROUND_HALF_UP) @@ -119,61 +137,134 @@ def fmt(v: Decimal) -> str: def quant(v: Decimal) -> Decimal: if v >= 1: return v.quantize(Decimal(1), rounding=ROUND_HALF_UP) - # snap up to min_amt grid for sub-unit amounts if v < min_amt: return min_amt return v.quantize(Decimal("0.00000001"), rounding=ROUND_UP) -mid = steps - 2 # between 0 and max -out = ["%s:0" % cur] -if mid > 0 and max_amt > 0: - lo, hi = float(min_amt), float(max_amt) * 0.999999 +def amt(v: Decimal) -> str: + return "%s:%s" % (cur, fmt(v)) + +def make_mids(n_mid: int, hi_cap: Decimal): + if n_mid <= 0 or hi_cap <= min_amt: + return [] + lo, hi = float(min_amt), float(hi_cap) * 0.999999 if hi <= lo: hi = lo * 10 - cuts = sorted(math.exp(random.uniform(math.log(lo), math.log(hi))) for _ in range(mid)) - prev = Decimal(0) + cuts = sorted(math.exp(random.uniform(math.log(lo), math.log(hi))) for _ in range(n_mid)) + out, prev = [], Decimal(0) for c in cuts: v = quant(Decimal(str(c))) if v <= prev: step = min_amt if prev < 1 else max(prev * Decimal("1e-6"), Decimal(1)) v = quant(prev + step) - if v >= max_amt: - v = max_amt - (Decimal(1) if max_amt > 1 else min_amt) - v = quant(v) - if v <= prev or v >= max_amt: + if v >= hi_cap: + v = quant(hi_cap - (Decimal(1) if hi_cap > 1 else min_amt)) + if v <= prev or v >= hi_cap: continue - out.append("%s:%s" % (cur, fmt(v))) + out.append(v) prev = v -out.append("%s:%s" % (cur, fmt(max_amt))) -while len(out) > steps: - out.pop(len(out) // 2) -while len(out) < steps and len(out) >= 2: - i = len(out) // 2 - a = Decimal(out[i - 1].split(":", 1)[1]) - b = Decimal(out[i].split(":", 1)[1]) - if a <= 0: - m = max(min_amt, b / 2 if b > 0 else min_amt) - else: - m = (a * b).sqrt() if a * b > 0 else (a + b) / 2 - m = quant(m) - if m <= a or m >= b: - break - out.insert(i, "%s:%s" % (cur, fmt(m))) -print(" ".join(out[:steps])) + return out + +# Build withdraw ladder first (full range), then pay mids = withdraw/scale +# so each pay mid is always cheaper than the matching withdraw mid. +if steps == 1: + wd_vals = [max_amt] +elif steps == 2: + wd_vals = [Decimal(0), max_amt] +else: + n_mid = max(0, steps - 3) + wd_vals = [Decimal(0)] + make_mids(n_mid, max_m1) + [max_m1, max_amt] + while len(wd_vals) > steps and len(wd_vals) > 3: + wd_vals.pop(len(wd_vals) // 2) + while len(wd_vals) < steps and len(wd_vals) >= 2: + i = max(1, len(wd_vals) - 2) + a, b = wd_vals[i - 1], wd_vals[i] + if a <= 0: + m = max(min_amt, b / 2 if b > 0 else min_amt) + else: + m = (a * b).sqrt() if a * b > 0 else (a + b) / 2 + m = quant(m) + if m <= a or m >= b: + break + wd_vals.insert(i, m) + wd_vals = wd_vals[:steps] + +pay_vals = [] +prev_p = Decimal(-1) +for i, w in enumerate(wd_vals): + is_first = i == 0 + is_last = i == len(wd_vals) - 1 + is_m1 = (not is_last) and w == max_m1 and i == len(wd_vals) - 2 + if is_first and w == 0: + pay_vals.append(Decimal(0)) + prev_p = Decimal(0) + continue + if is_last: + pay_vals.append(max_amt) + continue + if is_m1 or w == max_m1: + pay_vals.append(max_m1) + prev_p = max_m1 + continue + # pay mid = withdraw / scale (strictly less funding needed per step) + p = quant(w / scale) if scale > 0 else w + if p < min_amt and w >= min_amt: + p = min_amt + if p <= prev_p: + p = quant(prev_p + (min_amt if prev_p < 1 else Decimal(1))) + if p >= w: + # keep pay strictly below this withdraw rung when possible + p = quant(w - (Decimal(1) if w > 1 else min_amt)) if w > prev_p else prev_p + if p <= prev_p: + p = prev_p # flat ok only if stuck; still ≤ w + pay_vals.append(p) + prev_p = p + +assert len(pay_vals) == len(wd_vals) +# sanity: every non-pin pay mid ≤ matching withdraw mid +for i, (p, w) in enumerate(zip(pay_vals, wd_vals)): + if i == 0 or i >= len(wd_vals) - 2: + continue + if p > w: + pay_vals[i] = w + +wd_path.write_text(" ".join(amt(v) for v in wd_vals) + "\n") +pay_path.write_text(" ".join(amt(v) for v in pay_vals) + "\n") +print(" ".join(amt(v) for v in wd_vals)) PY } # shellcheck source=metrics.sh source "$ROOT/metrics.sh" METRICS_DIR="$REPORT_DIR" -export METRICS_DIR CUR WDB CLI_JS +ALT_UNITS_FILE="${REPORT_DIR}/alt_unit_names.json" +export METRICS_DIR CUR WDB CLI_JS ALT_UNITS_FILE +# Ladder can disable host load without killing coin metrics +if [ "${LADDER_LOAD:-1}" = "0" ]; then + METRICS_LOAD=0 + export METRICS_LOAD +fi section "ladder · GOA withdraw (0 → random → max · ${LADDER_STEPS} steps)" info "bank" "$BANK" info "exchange" "$EX" info "currency" "$CUR" +# alt_unit_names for human amounts (Kilo-GOA / Mega-GOA / … + GOA in parentheses) +if metrics_load_alt_units "${EX%/}/config"; then + info "alt_unit_names" "from ${EX%/}/config → $ALT_UNITS_FILE" +else + warn "alt_unit_names" "using built-in SI fallback ($ALT_UNITS_FILE)" +fi info "budget" "${LADDER_TIMEOUT_S}s" -info "steps" "${LADDER_STEPS} (0 + $((LADDER_STEPS - 2)) random≥${LADDER_MIN_AMOUNT} + max=${CUR}:${LADDER_MAX_AMOUNT})" +_max_m1=$(python3 -c 'import sys; print(int(sys.argv[1])-1)' "${LADDER_MAX_AMOUNT}" 2>/dev/null || echo "${LADDER_MAX_AMOUNT}-1") +info "steps" "${LADDER_STEPS} (0 + random≥${LADDER_MIN_AMOUNT} + max-1=${CUR}:${_max_m1} + max=${CUR}:${LADDER_MAX_AMOUNT})" +info "withdraw_scale" "${LADDER_WITHDRAW_SCALE}× pay mids (fund pay ladder)" +info "pay_phase" "$([ "${LADDER_PAY}" = "1" ] && echo enabled || echo disabled)" + +section "ladder · load snapshot (before withdraws)" +metrics_report_load "$LOAD_BEFORE" "ladder-start" || true +# Fresh wallet per rung — baseline empty (or last-rung DB if re-used later) +metrics_report_coins "ladder-start" || true if [ ! -f "$EXP_PW_FILE" ]; then err bank "explorer password missing" "$EXP_PW_FILE" @@ -207,24 +298,28 @@ ms_tok=$(elapsed_ms "$t0") [ -n "$TOK" ] || { err bank "explorer token failed"; exit 1; } ok "explorer token (${ms_tok}ms)" -# Fresh wallet DB per rung: without run-until-done the same reserve_pub is reused -# → bank 409 "Reserve pub already used" on later force-selects. +# ONE cumulative wallet for all withdraws + pays (need balance to spend). +# force-select uses *last* reserve_pub from the current accept output. wallet_prepare() { local label="${1:-wallet}" WDB="$SCRATCH/wallet-${label}.sqlite3" export WDB - rm -f "$WDB" - wcli exchanges add "$EX" >"$SCRATCH/ex-add-$label.out" 2>&1 || true - wcli exchanges update "$EX" >"$SCRATCH/ex-upd-$label.out" 2>&1 || true - wcli exchanges accept-tos "$EX" >"$SCRATCH/ex-tos-$label.out" 2>&1 || true + if [ ! -f "$WDB" ]; then + wcli exchanges add "$EX" >"$SCRATCH/ex-add-$label.out" 2>&1 || true + wcli exchanges update "$EX" >"$SCRATCH/ex-upd-$label.out" 2>&1 || true + wcli exchanges accept-tos "$EX" >"$SCRATCH/ex-tos-$label.out" 2>&1 || true + fi } t0=$(now_ms) -wallet_prepare "bootstrap" +rm -f "$SCRATCH/wallet-main.sqlite3" +wallet_prepare "main" ms_tos=$(elapsed_ms "$t0") -ok "wallet exchange + ToS (${ms_tos}ms) — fresh DB per rung (no run-until-done)" +ok "wallet exchange + ToS (${ms_tos}ms) — cumulative DB for withdraw+pay (no run-until-done)" -LADDER_LIST=$(build_ladder) +build_ladder_pair "$SCRATCH/ladder-wd-plan.txt" "$SCRATCH/ladder-pay-plan.txt" +LADDER_LIST=$(tr -d '\n' <"$SCRATCH/ladder-wd-plan.txt") +PAY_LIST=$(tr -d '\n' <"$SCRATCH/ladder-pay-plan.txt") : "${LADDER_MAX_RUNGS:=99}" # shellcheck disable=SC2086 set -- $LADDER_LIST @@ -233,15 +328,23 @@ if [ "$#" -gt "$LADDER_MAX_RUNGS" ]; then set -- $(printf '%s\n' "$@" | head -n "$LADDER_MAX_RUNGS") fi LADDER_N=$# -info "ladder plan" "$*" +info "withdraw plan" "$*" +info "withdraw plan (alt)" "$(format_amount_list_alt "$@")" +info "pay plan" "$PAY_LIST" +# shellcheck disable=SC2086 +info "pay plan (alt)" "$(format_amount_list_alt $PAY_LIST)" printf '%s\n' "$@" >"$SCRATCH/ladder-plan.txt" +printf '%s\n' $PAY_LIST >"$SCRATCH/ladder-pay-plan-lines.txt" 2>/dev/null || true OK_N=0 FAIL_N_L=0 +PAY_OK_N=0 +PAY_FAIL_N=0 STOP_REASON="" STOP_AMOUNT="" declare -a RUNG_JSON=() +section "ladder · phase A · withdraw (${LADDER_N} rungs)" rung=0 for AMT in "$@"; do rung=$((rung + 1)) @@ -252,13 +355,15 @@ for AMT in "$@"; do break fi - section "ladder · rung $rung $AMT" + section "ladder · rung $rung $AMT · $(format_amount_alt "$AMT")" tag=$(printf '%s' "$AMT" | tr '.:' '__') - # TSV "range" column: fixed pins at ends, random mids (no legacy LADDER_RANGES) + # TSV "range" column: fixed pins at ends, random mids (refined after AMT_NUM) if [ "$rung" -eq 1 ]; then range_note="pin:0" elif [ "$rung" -eq "$LADDER_N" ]; then range_note="pin:max" + elif [ "$rung" -eq $((LADDER_N - 1)) ] && [ "$LADDER_N" -ge 3 ]; then + range_note="pin:max-1" else range_note="random" fi @@ -269,8 +374,8 @@ for AMT in "$@"; do WID="-" FORCE_SEL_409_LOGGED=0 - # New wallet sqlite for this rung → unique reserve_pub (avoids bank 5114) - wallet_prepare "r${rung}" + # keep cumulative main wallet + wallet_prepare "main" # mint from explorer pool t0=$(now_ms) @@ -282,13 +387,24 @@ for AMT in "$@"; do ms_mint=$(elapsed_ms "$t0") WID=$(python3 -c 'import json;d=json.load(open("'"$SCRATCH"'/wd-'"$tag"'.json"));print(d.get("withdrawal_id") or "")' 2>/dev/null || true) URI=$(python3 -c 'import json;u=json.load(open("'"$SCRATCH"'/wd-'"$tag"'.json")).get("taler_withdraw_uri") or "";print(u.replace(":443/","/"))' 2>/dev/null || true) - # numeric amount (for zero / settle special-cases) + # numeric amount (for zero / settle / ceiling special-cases) AMT_NUM=$(python3 -c 'import sys; print(sys.argv[1].split(":",1)[-1])' "$AMT") IS_ZERO=0 python3 -c 'import sys; from decimal import Decimal; sys.exit(0 if Decimal(sys.argv[1])==0 else 1)' "$AMT_NUM" 2>/dev/null && IS_ZERO=1 + IS_MAX_PIN=0 + IS_MAX_M1_PIN=0 + if [ "$AMT_NUM" = "${LADDER_MAX_AMOUNT}" ]; then + IS_MAX_PIN=1 + range_note="pin:max" + elif [ "$AMT_NUM" = "$((LADDER_MAX_AMOUNT - 1))" ] 2>/dev/null || \ + [ "$AMT_NUM" = "$(python3 -c 'print(int("'"$LADDER_MAX_AMOUNT"'")-1)')" ]; then + IS_MAX_M1_PIN=1 + range_note="pin:max-1" + fi if [ "$code" != "200" ] && [ "$code" != "201" ] || [ -z "$WID" ] || [ -z "$URI" ]; then - note="mint HTTP $code $(head -c 100 "$SCRATCH/wd-$tag.json" 2>/dev/null | tr '\n' ' ')" + # strip quotes so STOP_REASON never breaks later shell/python argv + note="mint HTTP $code $(head -c 100 "$SCRATCH/wd-$tag.json" 2>/dev/null | tr '\n\"' ' ')" ms_total=$(elapsed_ms "$t_rung") if [ "$IS_ZERO" = "1" ]; then # Probe only: bank may reject GOA:0 — record and continue ladder @@ -300,6 +416,15 @@ for AMT in "$@"; do OK_N=$((OK_N + 1)) continue fi + # Absolute max is a ceiling probe — bank often returns SQL P0001/5110; do not abort. + if [ "$IS_MAX_PIN" = "1" ]; then + status="CEILING_REJECT" + note="absolute max rejected (ceiling probe; max-1 is the hard pin): $note" + warn bank "mint $AMT rejected (ceiling)" \ + "problem: bank rejects absolute LADDER_MAX_AMOUNT (often SQL P0001/5110). max-1 rung is the last expected success. Ladder continues to report. detail: $note" + echo -e "${rung}\t${range_note}\t${AMT}\t${status}\t${ms_mint}\t${ms_accept}\t${ms_confirm}\t${ms_settle}\t${ms_total}\t${WID}\t${note}" >>"$TSV" + continue + fi err bank "mint $AMT failed" "$note" status="FAIL_MINT" STOP_REASON="$note" @@ -538,12 +663,14 @@ sys.exit(0 if Decimal(sys.argv[1]) > Decimal(sys.argv[2]) else 1) ok "settle $AMT → ${CUR}:${after} (settle ${ms_settle}ms, rung ${ms_total}ms)" OK_N=$((OK_N + 1)) echo -e "${rung}\t${range_note}\t${AMT}\t${status}\t${ms_mint}\t${ms_accept}\t${ms_confirm}\t${ms_settle}\t${ms_total}\t${WID}\t${note}" >>"$TSV" + metrics_report_coins "ladder-r${rung}-after-${tag}" || true elif echo "$xfer" | grep -qi True; then status="OK_BANK" note="bank transfer_done avail=${after} $xfer (no run-until-done)" ok "settle $AMT bank transfer_done (wallet avail=${CUR}:${after}, ${ms_settle}ms)" OK_N=$((OK_N + 1)) echo -e "${rung}\t${range_note}\t${AMT}\t${status}\t${ms_mint}\t${ms_accept}\t${ms_confirm}\t${ms_settle}\t${ms_total}\t${WID}\t${note}" >>"$TSV" + metrics_report_coins "ladder-r${rung}-after-${tag}" || true else note="no coins / no transfer_done avail=${after} $xfer" err wallet "settle $AMT" "$note" @@ -552,10 +679,250 @@ sys.exit(0 if Decimal(sys.argv[1]) > Decimal(sys.argv[2]) else 1) STOP_AMOUNT="$AMT" FAIL_N_L=$((FAIL_N_L + 1)) echo -e "${rung}\t${range_note}\t${AMT}\t${status}\t${ms_mint}\t${ms_accept}\t${ms_confirm}\t${ms_settle}\t${ms_total}\t${WID}\t${note}" >>"$TSV" + metrics_report_coins "ladder-r${rung}-fail-${tag}" || true break fi done +# --------------------------------------------------------------------------- +# Phase B — payment ladder (same shape: 0 → low → … → max-1 → max) +# --------------------------------------------------------------------------- +PAY_OK_N=0 +PAY_FAIL_N=0 +if [ "${LADDER_PAY}" = "1" ] && [ -n "${PAY_LIST:-}" ] && [ "$FAIL_N_L" -eq 0 ]; then + section "ladder · phase B · pay" + metrics_report_coins "before-pay-ladder" || true + # Merchant secret (same as e2e) + MPW="${E2E_MERCHANT_TOKEN:-${MERCHANT_TOKEN:-}}" + if [ -z "$MPW" ]; then + MPW=$(read_secret "taler-merchant/merchant-${INST}-password.txt" 2>/dev/null || true) + fi + if [ -z "$MPW" ]; then + MPW=$(read_secret "taler-merchant/merchant-goa-demo-cp4zqk-password.txt" 2>/dev/null || true) + fi + if [ -z "$MPW" ]; then + warn pay "no merchant token — skip pay ladder (set E2E_MERCHANT_TOKEN or secrets)" + else + ok "merchant token" "instance ${INST}" + AUTH="Authorization: Bearer secret-token:${MPW}" + wallet_prepare "main" + # shellcheck disable=SC2086 + set -- $PAY_LIST + PAY_N=$# + info "pay rungs" "$PAY_N · $*" + prung=0 + for PAMT in "$@"; do + prung=$((prung + 1)) + if ladder_over; then + warn pay "time budget exhausted" "after ${PAY_OK_N} ok pays" + break + fi + section "ladder · pay rung $prung $PAMT · $(format_amount_alt "$PAMT")" + ptag=$(printf '%s' "$PAMT" | tr '.:' '__') + if [ "$prung" -eq 1 ]; then + prange="pin:0" + elif [ "$prung" -eq "$PAY_N" ]; then + prange="pin:max" + elif [ "$prung" -eq $((PAY_N - 1)) ] && [ "$PAY_N" -ge 3 ]; then + prange="pin:max-1" + else + prange="random" + fi + PNUM=$(python3 -c 'import sys; print(sys.argv[1].split(":",1)[-1])' "$PAMT") + IS_PZERO=0 + python3 -c 'import sys; from decimal import Decimal; sys.exit(0 if Decimal(sys.argv[1])==0 else 1)' "$PNUM" 2>/dev/null && IS_PZERO=1 + IS_PMAX=0 + [ "$PNUM" = "${LADDER_MAX_AMOUNT}" ] && IS_PMAX=1 + t_pay=$(now_ms) + ms_order=0 ms_handle=0 ms_psettle=0 + pnote="" + pstatus="FAIL" + OID="-" + + metrics_report_coins "before-pay-${ptag}" || true + bal=$(wallet_avail) + + if [ "$IS_PZERO" = "1" ]; then + pstatus="ZERO_SKIP" + pnote="zero pay probe skipped" + warn pay "pay $PAMT skipped" "problem: zero amount order not useful. Ladder continues." + ms_total=$(elapsed_ms "$t_pay") + echo -e "${prung}\t${prange}\t${PAMT}\t${pstatus}\t${ms_order}\t${ms_handle}\t${ms_psettle}\t${ms_total}\t${OID}\t${pnote}" >>"$PAY_TSV" + PAY_OK_N=$((PAY_OK_N + 1)) + continue + fi + + # Soft: absolute max pay is a ceiling probe (unlikely affordable / merchant may reject) + if [ "$IS_PMAX" = "1" ]; then + # try once; on fail CEILING_REJECT + : + fi + + # Insufficient balance → soft skip for max pin only; hard fail for mid/max-1 + if ! python3 -c 'import sys; from decimal import Decimal; sys.exit(0 if Decimal(sys.argv[1])>=Decimal(sys.argv[2]) else 1)' "$bal" "$PNUM" 2>/dev/null; then + pnote="insufficient balance avail=${CUR}:${bal} need=${PAMT}" + ms_total=$(elapsed_ms "$t_pay") + if [ "$IS_PMAX" = "1" ]; then + pstatus="CEILING_SKIP" + warn pay "pay $PAMT skipped (ceiling)" "$pnote" + echo -e "${prung}\t${prange}\t${PAMT}\t${pstatus}\t0\t0\t0\t${ms_total}\t-\t${pnote}" >>"$PAY_TSV" + continue + fi + err pay "pay $PAMT" "$pnote" + pstatus="FAIL_BALANCE" + PAY_FAIL_N=$((PAY_FAIL_N + 1)) + FAIL_N_L=$((FAIL_N_L + 1)) + STOP_REASON="$pnote" + STOP_AMOUNT="$PAMT" + echo -e "${prung}\t${prange}\t${PAMT}\t${pstatus}\t0\t0\t0\t${ms_total}\t-\t${pnote}" >>"$PAY_TSV" + break + fi + + t0=$(now_ms) + SUM_JSON=$(python3 -c 'import json,sys; print(json.dumps(sys.argv[1]))' "ladder pay ${PAMT}" 2>/dev/null || echo '"ladder pay"') + curl -skS -m 20 -o "$SCRATCH/ord-$ptag.json" -X POST \ + -H "$AUTH" -H 'Content-Type: application/json' \ + -d "{\"order\":{\"summary\":${SUM_JSON},\"amount\":\"${PAMT}\",\"fulfillment_message\":\"ok\"},\"create_token\":true}" \ + "${MER}/instances/${INST}/private/orders" 2>"$SCRATCH/ord-$ptag.err" || true + ms_order=$(elapsed_ms "$t0") + OID=$(python3 -c 'import json,re,sys;t=open(sys.argv[1]).read() +try: print(json.loads(t).get("order_id") or "") +except Exception: + m=re.search(r"\"order_id\"\s*:\s*\"([^\"]+)\"",t); print(m.group(1) if m else "") +' "$SCRATCH/ord-$ptag.json" 2>/dev/null || true) + OTOK=$(python3 -c 'import json,re,sys;t=open(sys.argv[1]).read() +try: print(json.loads(t).get("token") or "") +except Exception: + m=re.search(r"\"token\"\s*:\s*\"([^\"]+)\"",t); print(m.group(1) if m else "") +' "$SCRATCH/ord-$ptag.json" 2>/dev/null || true) + if [ -z "$OID" ]; then + pnote="order create failed $(head -c 80 "$SCRATCH/ord-$ptag.json" 2>/dev/null | tr '\n\"' ' ')" + ms_total=$(elapsed_ms "$t_pay") + if [ "$IS_PMAX" = "1" ]; then + pstatus="CEILING_REJECT" + warn pay "pay $PAMT rejected (ceiling)" "$pnote" + echo -e "${prung}\t${prange}\t${PAMT}\t${pstatus}\t${ms_order}\t0\t0\t${ms_total}\t-\t${pnote}" >>"$PAY_TSV" + continue + fi + err pay "order $PAMT" "$pnote" + pstatus="FAIL_ORDER" + PAY_FAIL_N=$((PAY_FAIL_N + 1)) + FAIL_N_L=$((FAIL_N_L + 1)) + STOP_REASON="$pnote" + STOP_AMOUNT="$PAMT" + echo -e "${prung}\t${prange}\t${PAMT}\t${pstatus}\t${ms_order}\t0\t0\t${ms_total}\t-\t${pnote}" >>"$PAY_TSV" + break + fi + ok "order $OID ($PAMT) ${ms_order}ms" + + curl -skS -m 12 -o "$SCRATCH/ord-det-$ptag.json" -H "$AUTH" \ + "${MER}/instances/${INST}/private/orders/${OID}" 2>/dev/null || true + PAYURI=$(python3 -c 'import json,sys +try: print(json.load(open(sys.argv[1])).get("taler_pay_uri") or "") +except Exception: print("") +' "$SCRATCH/ord-det-$ptag.json" 2>/dev/null || true) + if [ -z "$PAYURI" ] && [ -n "$OTOK" ]; then + MH=$(python3 -c 'from urllib.parse import urlparse; print(urlparse("'"$MER"'").hostname or "taler.hacktivism.ch")' 2>/dev/null || echo "taler.hacktivism.ch") + PAYURI="taler://pay/${MH}/instances/${INST}/${OID}/?c=${OTOK}" + fi + PAYURI=$(printf '%s' "$PAYURI" | sed 's/:443\//\//g; s/:443?/?/g') + if [ -z "$PAYURI" ]; then + pnote="no pay URI for $OID" + ms_total=$(elapsed_ms "$t_pay") + err pay "uri $PAMT" "$pnote" + pstatus="FAIL_URI" + PAY_FAIL_N=$((PAY_FAIL_N + 1)) + FAIL_N_L=$((FAIL_N_L + 1)) + STOP_REASON="$pnote" + STOP_AMOUNT="$PAMT" + echo -e "${prung}\t${prange}\t${PAMT}\t${pstatus}\t${ms_order}\t0\t0\t${ms_total}\t${OID}\t${pnote}" >>"$PAY_TSV" + break + fi + + t0=$(now_ms) + if ! wcli handle-uri --yes "$PAYURI" >"$SCRATCH/pay-$ptag.out" 2>&1; then + ms_handle=$(elapsed_ms "$t0") + pnote="handle-uri failed $(tail -c 100 "$SCRATCH/pay-$ptag.out" | tr '\n\"' ' ')" + ms_total=$(elapsed_ms "$t_pay") + if [ "$IS_PMAX" = "1" ]; then + pstatus="CEILING_REJECT" + warn pay "pay $PAMT handle failed (ceiling)" "$pnote" + echo -e "${prung}\t${prange}\t${PAMT}\t${pstatus}\t${ms_order}\t${ms_handle}\t0\t${ms_total}\t${OID}\t${pnote}" >>"$PAY_TSV" + continue + fi + err pay "handle $PAMT" "$pnote" + pstatus="FAIL_HANDLE" + PAY_FAIL_N=$((PAY_FAIL_N + 1)) + FAIL_N_L=$((FAIL_N_L + 1)) + STOP_REASON="$pnote" + STOP_AMOUNT="$PAMT" + echo -e "${prung}\t${prange}\t${PAMT}\t${pstatus}\t${ms_order}\t${ms_handle}\t0\t${ms_total}\t${OID}\t${pnote}" >>"$PAY_TSV" + break + fi + ms_handle=$(elapsed_ms "$t0") + ok "handle-uri $PAMT ${ms_handle}ms" + + # Short settle polls (bounded — no infinite hang) + t0=$(now_ms) + settled=0 + r=0 + while [ "$r" -lt "${LADDER_PAY_SETTLE_ROUNDS}" ]; do + r=$((r + 1)) + if command -v perl >/dev/null 2>&1; then + perl -e 'alarm shift; exec @ARGV' 10 \ + "$CLI_JS" --wallet-db="$WDB" --no-throttle run-until-done \ + >"$SCRATCH/pay-run-$ptag.out" 2>&1 || true + else + wcli run-until-done >"$SCRATCH/pay-run-$ptag.out" 2>&1 || true + fi + wcli transactions >"$SCRATCH/tx-$ptag.out" 2>&1 || true + curl -skS -m 8 -o "$SCRATCH/ord-paid-$ptag.json" -H "$AUTH" \ + "${MER}/instances/${INST}/private/orders/${OID}" 2>/dev/null || true + if grep -qiE 'payment|paid|Payment' "$SCRATCH/tx-$ptag.out" 2>/dev/null \ + || python3 -c 'import json,sys +d=json.load(open(sys.argv[1])) +sys.exit(0 if d.get("paid") is True or str(d.get("order_status","")).lower()=="paid" else 1) +' "$SCRATCH/ord-paid-$ptag.json" 2>/dev/null; then + settled=1 + break + fi + done + ms_psettle=$(elapsed_ms "$t0") + ms_total=$(elapsed_ms "$t_pay") + after=$(wallet_avail) + if [ "$settled" = "1" ]; then + pstatus="OK" + pnote="avail=${CUR}:${after}" + ok "pay settled $PAMT → bal ${CUR}:${after} (total ${ms_total}ms)" + PAY_OK_N=$((PAY_OK_N + 1)) + echo -e "${prung}\t${prange}\t${PAMT}\t${pstatus}\t${ms_order}\t${ms_handle}\t${ms_psettle}\t${ms_total}\t${OID}\t${pnote}" >>"$PAY_TSV" + metrics_report_coins "after-pay-${ptag}" || true + else + pnote="not settled order=$OID avail=${CUR}:${after}" + if [ "$IS_PMAX" = "1" ]; then + pstatus="CEILING_REJECT" + warn pay "pay $PAMT not settled (ceiling)" "$pnote" + echo -e "${prung}\t${prange}\t${PAMT}\t${pstatus}\t${ms_order}\t${ms_handle}\t${ms_psettle}\t${ms_total}\t${OID}\t${pnote}" >>"$PAY_TSV" + continue + fi + err pay "settle $PAMT" "$pnote" + pstatus="FAIL_SETTLE" + PAY_FAIL_N=$((PAY_FAIL_N + 1)) + FAIL_N_L=$((FAIL_N_L + 1)) + STOP_REASON="$pnote" + STOP_AMOUNT="$PAMT" + echo -e "${prung}\t${prange}\t${PAMT}\t${pstatus}\t${ms_order}\t${ms_handle}\t${ms_psettle}\t${ms_total}\t${OID}\t${pnote}" >>"$PAY_TSV" + metrics_report_coins "after-pay-fail-${ptag}" || true + break + fi + done + metrics_report_coins "after-pay-ladder" || true + info "pay summary" "ok=$PAY_OK_N fail=$PAY_FAIL_N tsv=$PAY_TSV" + fi +elif [ "${LADDER_PAY}" = "1" ] && [ "$FAIL_N_L" -gt 0 ]; then + warn pay "skipped pay ladder" "withdraw phase already failed" +fi + ms_phase=$(python3 -c 'import sys,time; print(int((time.time()-float(sys.argv[1]))*1000))' "$SECTION_T0") # --- report --- @@ -563,22 +930,39 @@ section "ladder · report" info "auto-account" "$ACCT_USER" info "ok_rungs" "$OK_N" info "fail_rungs" "$FAIL_N_L" +info "pay_ok" "$PAY_OK_N" +info "pay_fail" "$PAY_FAIL_N" info "phase_ms" "$ms_phase" info "tsv" "$TSV" +info "pay_tsv" "$PAY_TSV" -# speed summary via python -python3 - "$TSV" "$JSON" "$OK_N" "$FAIL_N_L" "${STOP_AMOUNT:-}" "${STOP_REASON:-}" "$ms_phase" "$ACCT_USER" "$CUR" <<'PY' -import csv, json, sys, statistics -tsv, jpath, ok_n, fail_n, stop_amt, stop_reason, phase_ms, acct, cur = sys.argv[1:10] -rows = [] -with open(tsv, newline="") as f: - r = csv.DictReader(f, delimiter="\t") - for row in r: - rows.append(row) +# speed summary via python — free-text stop reason via env (JSON " in bank errors +# used to break shell argv quoting → "syntax error near unexpected token '('") +export LADDER_REPORT_STOP_AMOUNT="${STOP_AMOUNT:-}" +export LADDER_REPORT_STOP_REASON="${STOP_REASON:-}" +python3 - "$TSV" "$PAY_TSV" "$JSON" "$OK_N" "$FAIL_N_L" "$PAY_OK_N" "$PAY_FAIL_N" "$ms_phase" "$ACCT_USER" "$CUR" <<'PY' +import csv, json, os, sys, statistics +tsv, pay_tsv, jpath = sys.argv[1:4] +ok_n, fail_n, pay_ok, pay_fail, phase_ms, acct, cur = sys.argv[4:11] +stop_amt = os.environ.get("LADDER_REPORT_STOP_AMOUNT") or None +stop_reason = os.environ.get("LADDER_REPORT_STOP_REASON") or None -def nums(key): +def load_rows(path): + rows = [] + try: + with open(path, newline="") as f: + for row in csv.DictReader(f, delimiter="\t"): + rows.append(row) + except Exception: + pass + return rows + +rows = load_rows(tsv) +prows = load_rows(pay_tsv) + +def nums(rs, key): out = [] - for row in rows: + for row in rs: try: out.append(int(row[key])) except Exception: @@ -601,41 +985,78 @@ report = { "auto_account": acct, "ok_rungs": int(ok_n), "fail_rungs": int(fail_n), + "pay_ok": int(pay_ok), + "pay_fail": int(pay_fail), "stopped_at_amount": stop_amt or None, "stop_reason": stop_reason or None, "phase_ms": int(phase_ms), "timing": { - "mint": stats(nums("ms_mint")), - "accept": stats(nums("ms_accept")), - "confirm": stats(nums("ms_confirm")), - "settle": stats(nums("ms_settle")), - "rung_total": stats(nums("ms_total")), + "mint": stats(nums(rows, "ms_mint")), + "accept": stats(nums(rows, "ms_accept")), + "confirm": stats(nums(rows, "ms_confirm")), + "settle": stats(nums(rows, "ms_settle")), + "rung_total": stats(nums(rows, "ms_total")), + "pay_order": stats(nums(prows, "ms_order")), + "pay_handle": stats(nums(prows, "ms_handle")), + "pay_settle": stats(nums(prows, "ms_settle")), + "pay_total": stats(nums(prows, "ms_total")), }, "rungs": rows, + "pays": prows, } json.dump(report, open(jpath, "w"), indent=2) print("JSON", jpath) -print("--- speed (ms) ---") -for k, v in report["timing"].items(): +print("--- withdraw speed (ms) ---") +for k in ("mint", "accept", "confirm", "settle", "rung_total"): + v = report["timing"][k] if v.get("n"): - print(f" {k:12} n={v['n']} min={v['min_ms']} p50={v['p50_ms']} avg={v['avg_ms']} max={v['max_ms']}") -print("--- rungs ---") + print(" %s n=%s min=%s p50=%s avg=%s max=%s" % (k.ljust(12), v["n"], v["min_ms"], v["p50_ms"], v["avg_ms"], v["max_ms"])) +print("--- pay speed (ms) ---") +for k in ("pay_order", "pay_handle", "pay_settle", "pay_total"): + v = report["timing"][k] + if v.get("n"): + print(" %s n=%s min=%s p50=%s avg=%s max=%s" % (k.ljust(12), v["n"], v["min_ms"], v["p50_ms"], v["avg_ms"], v["max_ms"])) +print("--- withdraw rungs ---") for row in rows: - print(f" {row['rung']:>2} {row['amount']:16} {row['status']:12} total={row['ms_total']}ms mint={row['ms_mint']} accept={row['ms_accept']} conf={row['ms_confirm']} set={row['ms_settle']}") + print(" %2s %-16s %-12s total=%sms" % (row.get("rung"), row.get("amount"), row.get("status"), row.get("ms_total"))) +print("--- pay rungs ---") +for row in prows: + print(" %2s %-16s %-12s total=%sms oid=%s" % (row.get("rung"), row.get("amount"), row.get("status"), row.get("ms_total"), row.get("oid"))) if stop_amt: - print(f"STOPPED at {stop_amt}: {stop_reason}") + print("STOPPED at %s: %s" % (stop_amt, stop_reason)) else: print("Completed without hard failure (or budget stop without fail).") PY wcli balance 2>&1 | tee "$REPORT_DIR/balance-final.out" | tail -20 || true +section "ladder · load snapshot (after withdraws)" +metrics_report_load "$LOAD_AFTER" "ladder-end" || true +if [ -f "$LOAD_BEFORE" ] && [ -f "$LOAD_AFTER" ]; then + section "metrics · ladder load delta" + metrics_print_load_delta "$LOAD_BEFORE" "$LOAD_AFTER" || true +fi +# Speed timings → metrics overall (min/p50/avg/max per phase) +if [ -f "$JSON" ]; then + python3 - "$JSON" "${METRICS_DIR}/perf-summary.json" <<'PY' 2>/dev/null || true +import json, sys +rep = json.load(open(sys.argv[1])) +out = {} +for k, v in (rep.get("timing") or {}).items(): + if isinstance(v, dict) and v.get("n"): + out[k] = v +json.dump(out, open(sys.argv[2], "w"), indent=2) +PY +fi +metrics_print_overall "ladder overall" || true + # Keep scratch if LADDER_REPORT_DIR set; else copy key files to /tmp if [ -z "${LADDER_REPORT_DIR:-}" ]; then KEEP="/tmp/goa-ladder-report-$(date +%Y%m%d-%H%M%S)" mkdir -p "$KEEP" - cp -a "$TSV" "$JSON" "$SCRATCH/auto-account.json" "$SCRATCH/ladder-plan.txt" \ - "$REPORT_DIR/balance-final.out" "$KEEP/" 2>/dev/null || true + cp -a "$TSV" "$PAY_TSV" "$JSON" "$SCRATCH/auto-account.json" "$SCRATCH/ladder-plan.txt" \ + "$SCRATCH/ladder-wd-plan.txt" "$SCRATCH/ladder-pay-plan.txt" \ + "$REPORT_DIR/balance-final.out" "$LOAD_BEFORE" "$LOAD_AFTER" "$KEEP/" 2>/dev/null || true info "report_dir" "$KEEP" echo "$KEEP" >"$SCRATCH/KEEP_PATH" fi @@ -645,8 +1066,8 @@ if [ "$FAIL_N_L" -gt 0 ]; then exit 1 fi if [ "$OK_N" -eq 0 ]; then - blocker "ladder" "no successful rungs" + blocker "ladder" "no successful withdraw rungs" exit 1 fi -ok "ladder finished ok_rungs=$OK_N phase=${ms_phase}ms" +ok "ladder finished withdraw_ok=$OK_N pay_ok=$PAY_OK_N phase=${ms_phase}ms" exit 0 From 51785031d649130ea3c8e0c8f5e02e29124d1728 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hern=C3=A2ni=20Marques?= Date: Fri, 17 Jul 2026 03:04:54 +0200 Subject: [PATCH 51/57] docs(monitoring): load, coins, pay ladder, alt units, SSH fallbacks --- scripts/taler-monitoring/README.md | 50 +++++++++++++++++++- scripts/taler-monitoring/TESTS.md | 31 +++++++----- scripts/taler-monitoring/taler-monitoring.sh | 8 +++- 3 files changed, 76 insertions(+), 13 deletions(-) diff --git a/scripts/taler-monitoring/README.md b/scripts/taler-monitoring/README.md index d8408c8..98214d6 100644 --- a/scripts/taler-monitoring/README.md +++ b/scripts/taler-monitoring/README.md @@ -110,8 +110,56 @@ Outside-in HTTPS RTT for bank / exchange / merchant critical paths. Each probe p PERF_WARN_MS=3000 PERF_FAIL_MS=10000 ./taler-monitoring.sh urls ``` +## Wallet coins (e2e · ladder) + +At every relevant withdraw/pay step the wallet is snapped via `advanced dump-coins`. +Amounts use exchange **`alt_unit_names`** (Kilo-GOA, Mega-GOA, …) with the base +**GOA value in parentheses**: + +```text +[INFO] coins after-ATM — amount_circ=2 Kilo-GOA (GOA:2000) +[INFO] coins after-ATM — denoms_in_circ 1 Kilo-GOA (GOA:1000)×2 (=2 Kilo-GOA (GOA:2000)) +== ladder · rung 12 GOA:1000000 · 1 Mega-GOA (GOA:1000000) == +``` + +Also: coin counts (in circulation / spent), status histogram, Δ vs previous snap. +History TSV: `$METRICS_DIR/coins-history.tsv`. Map loaded from +`${EXCHANGE_PUBLIC}/config` → `currency_specification.alt_unit_names`. + +## Load / memory (inside · e2e · ladder) + +Host **loadavg**, **RAM used/avail**, and per-container **RSS / CPU / block I/O** +(plus postgres DB sizes and role RSS: libeufin, exchange, merchant, …) are +snapshotted via SSH whenever the stack is under load: + +| Phase | When | +|-------|------| +| **inside** | end of container status | +| **e2e** | before work · after ATM withdraws · after payments · after shop · e2e-end (+ delta) | +| **ladder** | before first rung · after last rung (+ delta + timing overall) | + +```bash +# LAN down? force WAN DNAT host +KOOPA_SSH=koopa-external ./taler-monitoring.sh inside +# or rely on automatic fallback (default KOOPA_SSH_FALLBACKS=koopa-external) + +METRICS_LOAD=0 ./taler-monitoring.sh e2e # skip host probes +LADDER_LOAD=0 ./taler-monitoring.sh ladder +``` + +| Env | Default | Meaning | +|-----|---------|---------| +| `KOOPA_SSH` | `koopa` | primary SSH host | +| `KOOPA_SSH_FALLBACKS` | `koopa-external` | tried if primary fails | +| `METRICS_LOAD` | `1` | `0` = skip all host/container load probes | +| `LADDER_LOAD` | `1` | `0` = skip load in ladder only | +| `LADDER_PAY` | `1` | `0` = skip payment ladder after withdraws | +| `LADDER_WITHDRAW_SCALE` | `1.5` | withdraw mids / pay mids ratio (funding headroom) | +| `LADDER_STEPS` | `23` | rungs for both withdraw and pay (0 + mids + max−1 + max) | +| `METRICS_LOAD_SSH_TIMEOUT` | `90` | seconds for remote load python | + ## Needs -- SSH `koopa` (inside/sanity server bits) +- SSH `koopa` or `koopa-external` (inside / load / sanity server bits) - secrets under `koopa-admin-secrets/...` for e2e - `taler-wallet-cli` for e2e diff --git a/scripts/taler-monitoring/TESTS.md b/scripts/taler-monitoring/TESTS.md index c9dbe09..ad1e6d5 100644 --- a/scripts/taler-monitoring/TESTS.md +++ b/scripts/taler-monitoring/TESTS.md @@ -66,11 +66,14 @@ IDs are assigned **in run order** within the area (`set_area` resets the counter | ID | Check (typical order) | |----|------------------------| -| inside-001 | ssh koopa | +| inside-001 | ssh koopa (or **koopa-external** fallback) | | inside-002+ | per-component emit: container, ports, libeufin/httpd, postgres, local `/config`/`/keys`, wirewatch, DNS pin, caddy | +| inside-… | **load / memory**: host loadavg + RAM; bank/exchange/merchant podman CPU/mem/block + process RSS by role + DB sizes | Remote lines `E|comp|LEVEL|key|detail` each become one numbered result. +SSH: `KOOPA_SSH` (default `koopa`), then `KOOPA_SSH_FALLBACKS` (default `koopa-external`) when LAN is unreachable. + --- ## sanity — bank · exchange · merchant (`./taler-monitoring.sh sanity`) @@ -142,8 +145,10 @@ Without SSH (`SKIP_SSH=1` or remote domain): still runs outside-in repo checks; | e2e-003 | mode / currency info | | e2e-004… | secrets, reachability gates | | e2e-… | account, credit, withdraw, confirm, coins, order, pay ladder | +| e2e-… | **load snapshots** at e2e-start, after-withdraw, after-pay, after-shop, e2e-end (host loadavg/RAM + container RSS/CPU/DB) | +| e2e-… | **coins** before/after each ATM withdraw, settle, pay, shop: count in circulation / spent, amount + denoms using **`alt_unit_names`** (e.g. `5 Kilo-GOA (GOA:5000)`), Δ vs previous snap | | e2e-… | **GOA shop products** — full catalog list; **random pick of 2** (override `E2E_SHOP_PICK_N`) | -| e2e-… | balances, dig on failure | +| e2e-… | balances, dig on failure, load delta + overall metrics | Shop product pays use instance `goa-shop` (default) and catalog `E2E_SHOP_PRODUCTS` (`id|Product name|amount` lines). Each e2e run **shuffles** the catalog and pays @@ -159,20 +164,24 @@ Blockers keep the same ID prefix: `[BLOCKER] e2e-0NN step: message`. | Step | What | |------|------| | ladder-001 | `GET /intro/auto-account.json` (personal account, GOA:0) | -| ladder-… | Mint explorer pool withdrawals: **zero**, then **random** in ranges (strictly increasing), then **pin at bank max** | -| ladder-… | wallet-cli accept-uri + explorer confirm when `selected` | -| ladder-… | Settle coins; **stop on first hard failure** | -| report | TSV + JSON with **ms_mint / ms_accept / ms_confirm / ms_settle / ms_total** | +| ladder-… | **load snapshot** before withdraws (host + bank/exchange/merchant mem/CPU) | +| ladder-… | **Phase A withdraw** (23): mint explorer pool — 0 → random → max−1 → max into **one cumulative wallet** (mids × `LADDER_WITHDRAW_SCALE`, default 1.5) | +| ladder-… | wallet-cli accept-uri + explorer confirm when `selected`; settle; coins snap | +| ladder-… | **Phase B pay** (23): same shape 0 → random → max−1 → max via merchant orders + `handle-uri --yes` | +| ladder-… | **load snapshot** after + delta + overall timing (withdraw + pay) | +| report | withdraw TSV + pay TSV + JSON | -Default path (`build_ladder`, strictly increasing): +Default amounts (`build_ladder_pair`, strictly increasing): ```text -[fixed] GOA:0 -[random] (LADDER_STEPS − 2) log-uniform amounts in (ε, max) -[fixed] GOA:4503599627370496 (LADDER_MAX_AMOUNT) +withdraw: [0] + random mids + [max−1] + [max] +pay: [0] + (withdraw_mid / scale) + [max−1] + [max] # always ≤ matching withdraw mid ``` -TSV column `range`: `pin:0` | `random` | `pin:max` (no legacy `LADDER_RANGES`). +TSV `range`: `pin:0` | `random` | `pin:max-1` | `pin:max`. + +Soft: absolute **max** mint/pay may `CEILING_REJECT` / skip (WARN). **max-1** is a hard pin. +`LADDER_PAY=0` skips phase B. | Env | Default | Meaning | |-----|---------|---------| diff --git a/scripts/taler-monitoring/taler-monitoring.sh b/scripts/taler-monitoring/taler-monitoring.sh index 9be5740..ff6586e 100755 --- a/scripts/taler-monitoring/taler-monitoring.sh +++ b/scripts/taler-monitoring/taler-monitoring.sh @@ -52,6 +52,8 @@ Env (same meaning): TALER_DOMAIN BANK_PUBLIC EXCHANGE_PUBLIC MERCHANT_PUBLIC EXPECT_CURRENCY SKIP_SSH=1 NO_COLOR=1 PERF_WARN_MS PERF_FAIL_MS (urls latency; default 8000 / 20000) + KOOPA_SSH KOOPA_SSH_FALLBACKS (default koopa → koopa-external) + METRICS_LOAD=0 skip host/container RAM/CPU probes (e2e/ladder/inside) EOF } @@ -135,7 +137,7 @@ export EXPECT_CURRENCY SKIP_SSH LOCAL_STACK TALER_DOMAIN_PROBE export WITHDRAW_AMT PAY_AMT CREDIT_AMT MERCHANT_INSTANCE export E2E_FAKE_INCOMING E2E_REMOTE E2E_VARIABLE E2E_ATM_MAX export E2E_WITHDRAW_VALUES E2E_PAY_VALUES -# Ladder: 0 + random mids + max (see check_goa_ladder.sh build_ladder). No LADDER_RANGES. +# Ladder: withdraw then pay — 0 + random mids + max-1 + max (see check_goa_ladder.sh). # Defaults so set -u export is safe when vars were never set by caller. : "${LADDER_STEPS:=23}" : "${LADDER_MIN_AMOUNT:=0.000001}" @@ -151,10 +153,14 @@ export E2E_WITHDRAW_VALUES E2E_PAY_VALUES : "${LADDER_HIGH_FROM:=1000000}" : "${LADDER_HIGH_RUNGS:=12}" : "${LADDER_LOAD:=1}" +: "${LADDER_PAY:=1}" +: "${LADDER_WITHDRAW_SCALE:=1.5}" +: "${LADDER_PAY_SETTLE_ROUNDS:=6}" export LADDER_STEPS LADDER_MIN_AMOUNT LADDER_CONFIRM_POLLS LADDER_MAX_RUNGS LADDER_TIMEOUT_S LADDER_REPORT_DIR export LADDER_SETTLE_ROUNDS LADDER_SETTLE_SLEEP EXP_PW_FILE EXP_USER export LADDER_MAX_AMOUNT LADDER_INCLUDE_ZERO LADDER_INCLUDE_MAX export LADDER_HIGH_FROM LADDER_HIGH_RUNGS LADDER_LOAD +export LADDER_PAY LADDER_WITHDRAW_SCALE LADDER_PAY_SETTLE_ROUNDS export TALER_DOMAIN_APPLIED=1 # Default phases From 25695805b4dee5b7a435ce25d9e2773855651aa3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hern=C3=A2ni=20Marques?= Date: Fri, 17 Jul 2026 07:42:00 +0200 Subject: [PATCH 52/57] scripts/taler-landing: full-scan bank collector with GOA alt units Add a host-side Python collector that lists every bank account, pages through all ledger rows, and emits stats.json with amount_alt fields (Kilo/Mega/Peta-GOA) so large ladder withdrawals fit on the landing tiles. --- scripts/taler-landing/README.md | 106 +++ .../taler-landing/collect-landing-stats.sh | 260 ++++++ scripts/taler-landing/collect_bank_stats.py | 816 ++++++++++++++++++ scripts/taler-landing/enrich_stats_alt.py | 40 + scripts/taler-landing/goa_amounts.py | 306 +++++++ 5 files changed, 1528 insertions(+) create mode 100644 scripts/taler-landing/README.md create mode 100755 scripts/taler-landing/collect-landing-stats.sh create mode 100755 scripts/taler-landing/collect_bank_stats.py create mode 100755 scripts/taler-landing/enrich_stats_alt.py create mode 100755 scripts/taler-landing/goa_amounts.py diff --git a/scripts/taler-landing/README.md b/scripts/taler-landing/README.md new file mode 100644 index 0000000..266d1b3 --- /dev/null +++ b/scripts/taler-landing/README.md @@ -0,0 +1,106 @@ +# taler-landing — central stats (hernani user systemd) + +Public landing numbers on + +- https://bank.hacktivism.ch/intro/ → `stats.json` +- https://exchange.hacktivism.ch/intro/ → `stats.json` +- https://taler.hacktivism.ch/intro/ → `stats.json` + +are produced by **one host process** as user **`hernani`**, not by three unrelated in-container crons. + +## Why host / user systemd + +| Concern | Approach | +|--------|----------| +| **All accounts / all money** | Python full scan + tx pagination (not capped at 80 accounts) | +| **High ladder GOA** | `amount_alt` / UI alt units (Kilo-/Mega-/Peta-GOA) — tiles stay short | +| **No root cron** | `systemctl --user` as hernani + `loginctl enable-linger` | +| **Failure safety** | failed run only updates `stats-run.json`; last good `stats.json` stays | + +## Install (on koopa as hernani) + +```bash +cd ~/src/koopa/koopa-admin-log +./scripts/taler-landing/install-landing-stats-host.sh + +# timer without login session: +sudo loginctl enable-linger hernani + +# run once: +systemctl --user start taler-landing-stats.service +journalctl --user -u taler-landing-stats.service -n 50 --no-pager +systemctl --user list-timers 'taler-landing-stats*' +``` + +Units: + +- `configs/systemd/user/taler-landing-stats.service` — oneshot collector +- `configs/systemd/user/taler-landing-stats.timer` — every **2 minutes** after boot + +Installed paths: + +| Path | Role | +|------|------| +| `~/.local/bin/collect-landing-stats.sh` | orchestrator | +| `~/.local/lib/taler-landing/*.py` | bank scan + alt enrich | +| `~/.local/state/taler-landing-stats/` | logs | +| `~/.config/systemd/user/taler-landing-stats.*` | user units | + +## What the collector does + +1. **Bank** — `collect_bank_stats.py` + - admin token → list **all** accounts + - for each account (except `SCAN_SKIP`, default **`exchange`**) page through **all** transactions + - sum credits / Taler withdraws / other debits + - emit `amount` + `amount_alt` / `amount_full` + - `podman cp` → `taler-hacktivism-bank:/var/www/bank-landing/stats.json` + +2. **Exchange** — run `landing-stats-exchange.sh` inside exchange container, then **enrich** alt fields on the host and write back. + +3. **Merchant** — same for merchant container. + +`exchange` is skipped in the bank **flow** scan so the same GOA is not counted once as customer withdraw and again as exchange credit. **Admin**, **explorer**, and every auto-account are included. + +## Secrets + +Preferred order: + +1. `~/src/koopa/koopa-admin-secrets/koopa/host-root/taler-bank/bank-admin-password.txt` +2. `~/.config/taler-landing/bank-*-password.txt` +3. `podman exec taler-hacktivism-bank cat /root/bank-admin-password.txt` + +Explorer password optional (shared-pool balance). + +## Env overrides + +| Env | Default | Meaning | +|-----|---------|---------| +| `BANK_URL` | `http://127.0.0.1:9012` | libeufin loopback | +| `SCAN_SKIP` | `exchange` | usernames excluded from flow | +| `TX_PAGE` | `500` | transactions page size | +| `MAX_TX_PAGES` | `0` | `0` = unlimited pages / account | +| `ACCOUNTS_DELTA` | `-10000` | account list window | +| `ADMIN_LOG` | `%h/src/koopa/koopa-admin-log` | refresh in-container scripts | + +## UI alt names + +`configs/shared/goa-amount.js` formats large amounts as e.g. `1.23 Mega-GOA` (tooltip = full `GOA:…`). Landings load it as `/intro/goa-amount.js` (deploy via `deploy-landings.sh`). + +## Legacy in-container bank cron + +`scripts/taler-bank/landing-stats.sh` remains a **fallback** inside the bank container. Once the hernani timer is healthy, disable the old in-container minutely cron to avoid races: + +```bash +podman exec taler-hacktivism-bank crontab -l # inspect +# remove landing-stats.sh line if present +``` + +## Manual run + +```bash +~/.local/bin/collect-landing-stats.sh +# or from checkout: +./scripts/taler-landing/collect-landing-stats.sh + +curl -sS https://bank.hacktivism.ch/intro/stats.json | python3 -m json.tool | head +``` diff --git a/scripts/taler-landing/collect-landing-stats.sh b/scripts/taler-landing/collect-landing-stats.sh new file mode 100755 index 0000000..f41cc44 --- /dev/null +++ b/scripts/taler-landing/collect-landing-stats.sh @@ -0,0 +1,260 @@ +#!/usr/bin/env bash +# Central landing-stats collector for hernani@koopa (user systemd timer). +# +# - Bank: full account+ledger scan via collect_bank_stats.py → bank container +# - Exchange / merchant: existing in-container scripts, then amount_alt enrich +# - Never wipes a good stats.json on failure (writes stats-run.json only) +# +# Install: scripts/taler-landing/install-landing-stats-host.sh +set -euo pipefail + +export TZ="${TZ:-Europe/Zurich}" +export PATH="${HOME}/.local/bin:/usr/local/bin:/usr/bin:/bin${PATH:+:$PATH}" + +log() { printf '%s %s\n' "$(date -Iseconds)" "$*"; } + +ROOT="$(cd "$(dirname "$0")" && pwd)" +# When installed to ~/.local/bin, libs live in ~/.local/lib/taler-landing +if [ -f "$ROOT/collect_bank_stats.py" ]; then + LIB="$ROOT" +elif [ -f "${HOME}/.local/lib/taler-landing/collect_bank_stats.py" ]; then + LIB="${HOME}/.local/lib/taler-landing" +elif [ -f "${HOME}/src/koopa/koopa-admin-log/scripts/taler-landing/collect_bank_stats.py" ]; then + LIB="${HOME}/src/koopa/koopa-admin-log/scripts/taler-landing" +else + LIB="$ROOT" +fi + +ADMIN_LOG="${ADMIN_LOG:-${HOME}/src/koopa/koopa-admin-log}" +SECRETS_BANK="${SECRETS_BANK:-${HOME}/src/koopa/koopa-admin-secrets/koopa/host-root/taler-bank}" + +BANK_CTR="${BANK_CTR:-taler-hacktivism-bank}" +EX_CTR="${EX_CTR:-taler-hacktivism-exchange-ansible}" +MER_CTR="${MER_CTR:-taler-hacktivism}" + +BANK_URL="${BANK_URL:-http://127.0.0.1:9012}" +BANK_PUBLIC_URL="${BANK_PUBLIC_URL:-https://bank.hacktivism.ch}" +EXCHANGE_CONFIG_URL="${EXCHANGE_CONFIG_URL:-https://exchange.hacktivism.ch/config}" + +BANK_LANDING_IN="${BANK_LANDING_IN:-/var/www/bank-landing}" +EX_LANDING_IN="${EX_LANDING_IN:-/var/www/exchange-landing}" +MER_LANDING_IN="${MER_LANDING_IN:-/var/www/merchant-landing}" + +WORKDIR="${LANDING_STATS_WORKDIR:-${XDG_RUNTIME_DIR:-/tmp}/taler-landing-stats}" +mkdir -p "$WORKDIR" +LOG_DIR="${LANDING_STATS_LOGDIR:-${HOME}/.local/state/taler-landing-stats}" +mkdir -p "$LOG_DIR" + +PY="${PYTHON:-python3}" +ec_bank=0 +ec_ex=0 +ec_mer=0 + +read_pass() { + local name="$1" f + for f in \ + "${SECRETS_BANK}/${name}" \ + "${HOME}/.config/taler-landing/${name}" \ + "/run/user/$(id -u)/taler-landing/${name}" + do + if [ -r "$f" ]; then + tr -d '\n' <"$f" + return 0 + fi + done + # container (hernani can podman exec root files inside owned containers) + if podman inspect -f '{{.State.Running}}' "$BANK_CTR" 2>/dev/null | grep -qx true; then + if podman exec "$BANK_CTR" test -r "/root/${name}" 2>/dev/null; then + podman exec "$BANK_CTR" cat "/root/${name}" 2>/dev/null | tr -d '\n' + return 0 + fi + fi + return 1 +} + +ctr_running() { + podman inspect -f '{{.State.Running}}' "$1" 2>/dev/null | grep -qx true +} + +publish_json() { + local ctr="$1" dest_dir="$2" src_stats="$3" src_run="$4" + if ! ctr_running "$ctr"; then + log "WARN: $ctr not running — skip publish $dest_dir" + return 1 + fi + podman exec "$ctr" mkdir -p "$dest_dir" 2>/dev/null || true + if [ -f "$src_stats" ]; then + podman cp "$src_stats" "${ctr}:${dest_dir}/stats.json" + fi + if [ -f "$src_run" ]; then + podman cp "$src_run" "${ctr}:${dest_dir}/stats-run.json" + fi +} + +# --------------------------------------------------------------------------- +# Bank — full scan (all accounts / all money except SCAN_SKIP) +# --------------------------------------------------------------------------- +collect_bank() { + log "bank: collect full stats via $LIB/collect_bank_stats.py" + local admin_pass explorer_pass + admin_pass="$(read_pass bank-admin-password.txt || true)" + explorer_pass="$(read_pass bank-explorer-password.txt || true)" + if [ -z "$admin_pass" ]; then + log "ERROR: bank admin password not found (secrets or container)" + printf '%s\n' '{"ok":false,"error":"no admin password","at_human":"'"$(date +"%Y-%m-%d %H:%M %Z")"'"}' \ + >"$WORKDIR/bank-stats-run.json" + publish_json "$BANK_CTR" "$BANK_LANDING_IN" "" "$WORKDIR/bank-stats-run.json" || true + return 1 + fi + + # optional demo files from container + local demo_dir="" + if ctr_running "$BANK_CTR"; then + demo_dir="$WORKDIR/demo" + mkdir -p "$demo_dir" + podman cp "${BANK_CTR}:${BANK_LANDING_IN}/withdraw.uri" "$demo_dir/withdraw.uri" 2>/dev/null || true + podman cp "${BANK_CTR}:${BANK_LANDING_IN}/withdraw.amount" "$demo_dir/withdraw.amount" 2>/dev/null || true + podman cp "${BANK_CTR}:${BANK_LANDING_IN}/withdraw.created" "$demo_dir/withdraw.created" 2>/dev/null || true + fi + + set +e + BANK_URL="$BANK_URL" \ + BANK_PUBLIC_URL="$BANK_PUBLIC_URL" \ + EXCHANGE_CONFIG_URL="$EXCHANGE_CONFIG_URL" \ + BANK_ADMIN_PASS="$admin_pass" \ + BANK_EXPLORER_PASS="$explorer_pass" \ + DEMO_DIR="${demo_dir}" \ + SCAN_SKIP="${SCAN_SKIP:-exchange}" \ + TX_PAGE="${TX_PAGE:-500}" \ + MAX_TX_PAGES="${MAX_TX_PAGES:-0}" \ + ACCOUNTS_DELTA="${ACCOUNTS_DELTA:--10000}" \ + "$PY" "$LIB/collect_bank_stats.py" \ + --out "$WORKDIR/bank-stats.json" \ + --run-out "$WORKDIR/bank-stats-run.json" \ + >>"$LOG_DIR/bank.log" 2>&1 + ec_bank=$? + set -e + + if [ "$ec_bank" -eq 0 ] && [ -f "$WORKDIR/bank-stats.json" ]; then + # merge in-container memory snapshot when helper exists + if ctr_running "$BANK_CTR" && podman exec "$BANK_CTR" test -f /usr/local/lib/landing-mem-snapshot.sh 2>/dev/null; then + set +e + mem_json=$(podman exec "$BANK_CTR" bash -c ' + export PATH="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" + MEM_JSON="" + # shellcheck disable=SC1091 + . /usr/local/lib/landing-mem-snapshot.sh + mem_snapshot_json 2>/dev/null || true + # print MEM_JSON lines only if function set them via echo — fallback empty + if [ -n "${MEM_JSON:-}" ]; then printf "%s" "$MEM_JSON"; fi + ' 2>/dev/null) + set -e + if [ -n "${mem_json:-}" ]; then + "$PY" - "$WORKDIR/bank-stats.json" <<'PY' || true +import json, sys +path = sys.argv[1] +# optional: leave memory as-is if merge fails +print("mem merge skipped (structured merge via podman helper optional)", file=sys.stderr) +PY + fi + fi + publish_json "$BANK_CTR" "$BANK_LANDING_IN" \ + "$WORKDIR/bank-stats.json" "$WORKDIR/bank-stats-run.json" + log "bank: OK → ${BANK_CTR}:${BANK_LANDING_IN}/stats.json" + else + log "ERROR: bank collect failed (ec=$ec_bank) — previous stats.json kept" + [ -f "$WORKDIR/bank-stats-run.json" ] || \ + printf '%s\n' '{"ok":false,"error":"collect failed","at_human":"'"$(date +"%Y-%m-%d %H:%M %Z")"'"}' \ + >"$WORKDIR/bank-stats-run.json" + publish_json "$BANK_CTR" "$BANK_LANDING_IN" "" "$WORKDIR/bank-stats-run.json" || true + fi + return "$ec_bank" +} + +# --------------------------------------------------------------------------- +# Exchange / merchant — in-container generators + host enrich +# --------------------------------------------------------------------------- +run_incontainer_stats() { + local label="$1" ctr="$2" script_candidates="$3" landing="$4" + local script="" s + if ! ctr_running "$ctr"; then + log "WARN: $label: $ctr not running" + return 1 + fi + # install latest script from admin-log if present + local host_src="" + case "$label" in + exchange) + host_src="$ADMIN_LOG/scripts/taler-exchange/landing-stats-exchange.sh" + ;; + merchant) + host_src="$ADMIN_LOG/scripts/taler-merchant/landing-stats-merchant.sh" + ;; + esac + if [ -n "$host_src" ] && [ -f "$host_src" ]; then + podman cp "$host_src" "${ctr}:/usr/local/bin/$(basename "$host_src")" + podman exec "$ctr" chmod 755 "/usr/local/bin/$(basename "$host_src")" 2>/dev/null || true + script="/usr/local/bin/$(basename "$host_src")" + else + for s in $script_candidates; do + if podman exec "$ctr" test -x "$s" 2>/dev/null || podman exec "$ctr" test -f "$s" 2>/dev/null; then + script="$s" + break + fi + done + fi + if [ -z "$script" ]; then + log "WARN: $label: no landing-stats script in $ctr" + return 1 + fi + log "$label: run $script inside $ctr" + set +e + podman exec \ + -e LANDING_DIR="$landing" \ + -e TZ=Europe/Zurich \ + -e PATH="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" \ + "$ctr" bash "$script" >>"$LOG_DIR/${label}.log" 2>&1 + local ec=$? + set -e + if [ "$ec" -ne 0 ]; then + log "ERROR: $label stats failed (ec=$ec)" + return "$ec" + fi + # enrich with alt units on host + podman cp "${ctr}:${landing}/stats.json" "$WORKDIR/${label}-stats.json" 2>/dev/null || return 0 + set +e + "$PY" "$LIB/enrich_stats_alt.py" "$WORKDIR/${label}-stats.json" \ + --exchange-config "$EXCHANGE_CONFIG_URL" >>"$LOG_DIR/${label}.log" 2>&1 + set -e + podman cp "$WORKDIR/${label}-stats.json" "${ctr}:${landing}/stats.json" + log "$label: OK + alt enrich → ${ctr}:${landing}/stats.json" + return 0 +} + +collect_exchange() { + run_incontainer_stats exchange "$EX_CTR" \ + "/usr/local/bin/landing-stats-exchange.sh /usr/local/bin/landing-stats.sh" \ + "$EX_LANDING_IN" +} + +collect_merchant() { + run_incontainer_stats merchant "$MER_CTR" \ + "/usr/local/bin/landing-stats-merchant.sh /usr/local/bin/landing-stats.sh" \ + "$MER_LANDING_IN" +} + +# --------------------------------------------------------------------------- +main() { + log "=== taler-landing-stats start (user=$(id -un) lib=$LIB) ===" + collect_bank || ec_bank=$? + collect_exchange || ec_ex=$? + collect_merchant || ec_mer=$? + log "=== done bank=$ec_bank exchange=$ec_ex merchant=$ec_mer ===" + # non-zero if bank failed (primary public flow numbers); soft on ex/mer + if [ "$ec_bank" -ne 0 ]; then + return 1 + fi + return 0 +} + +main "$@" diff --git a/scripts/taler-landing/collect_bank_stats.py b/scripts/taler-landing/collect_bank_stats.py new file mode 100755 index 0000000..79055d6 --- /dev/null +++ b/scripts/taler-landing/collect_bank_stats.py @@ -0,0 +1,816 @@ +#!/usr/bin/env python3 +"""Full-scan bank landing stats (all accounts, all ledger money). + +Run on koopa as hernani (or anywhere with admin API access). Writes stats.json +compatible with bank.hacktivism.ch/intro/ plus amount_alt fields for compact UI. + +Env (selected): + BANK_URL default http://127.0.0.1:9012 + BANK_ADMIN_USER default admin + BANK_ADMIN_PASS or BANK_ADMIN_PASS_FILE / pass via --admin-pass-file + BANK_EXPLORER_USER default explorer + BANK_EXPLORER_PASS optional (balance_explorer) + EXCHANGE_CONFIG_URL for alt_unit_names (default https://exchange.hacktivism.ch/config) + OUT output path (default stdout if -) + SCAN_SKIP comma usernames excluded from flow (default: exchange) + TX_PAGE page size for transactions delta (default 500) + MAX_TX_PAGES 0 = unlimited pages per account (default 0) + ACCOUNTS_DELTA GET /accounts?delta= (default -10000) + RECENT_WD_N recent withdraws (default 10) + TZ default Europe/Zurich +""" +from __future__ import annotations + +import argparse +import json +import os +import re +import sys +import time +import urllib.error +import urllib.parse +import urllib.request +from datetime import datetime +from decimal import Decimal +from pathlib import Path +from typing import Any, Dict, List, Optional, Set, Tuple +from zoneinfo import ZoneInfo + +# local import (same directory) +sys.path.insert(0, str(Path(__file__).resolve().parent)) +from goa_amounts import ( # noqa: E402 + DEFAULT_ALT, + enrich_stats_tree, + format_amount_alt, + load_alt_from_config, + parse_amount, +) + +RESERVE_RE = re.compile(r"(?i)withdrawal\s+([A-Za-z0-9]+)") + + +def env(name: str, default: str = "") -> str: + return os.environ.get(name, default) + + +def read_pass_file(path: str) -> str: + p = Path(path) + if p.is_file(): + return p.read_text(encoding="utf-8", errors="replace").strip() + return "" + + +def http_json( + url: str, + *, + method: str = "GET", + headers: Optional[Dict[str, str]] = None, + data: Optional[bytes] = None, + timeout: float = 30.0, + auth: Optional[Tuple[str, str]] = None, +) -> Tuple[int, Any, bytes]: + h = dict(headers or {}) + if auth: + import base64 + + token = base64.b64encode(f"{auth[0]}:{auth[1]}".encode()).decode("ascii") + h["Authorization"] = f"Basic {token}" + req = urllib.request.Request(url, data=data, headers=h, method=method) + try: + with urllib.request.urlopen(req, timeout=timeout) as resp: + body = resp.read() + code = getattr(resp, "status", 200) or 200 + except urllib.error.HTTPError as e: + body = e.read() if e.fp else b"" + code = e.code + except Exception as e: + return 0, None, str(e).encode() + + if not body: + return code, None, body + try: + return code, json.loads(body.decode("utf-8", errors="replace")), body + except Exception: + return code, None, body + + +def measure_ms(url: str, timeout: float = 8.0) -> Tuple[Optional[int], str]: + t0 = time.perf_counter() + code = "000" + try: + req = urllib.request.Request(url, method="GET") + with urllib.request.urlopen(req, timeout=timeout) as resp: + resp.read() + code = str(getattr(resp, "status", 200) or 200) + except urllib.error.HTTPError as e: + code = str(e.code) + except Exception: + return None, "000" + ms = int(round((time.perf_counter() - t0) * 1000)) + if ms == 0: + ms = 1 + return ms, code + + +def get_token(bank: str, user: str, password: str) -> str: + code, data, _ = http_json( + f"{bank}/accounts/{user}/token", + method="POST", + headers={"Content-Type": "application/json"}, + data=b'{"scope":"readonly"}', + auth=(user, password), + timeout=15, + ) + if code not in (200, 201) or not isinstance(data, dict): + raise RuntimeError(f"token failed for {user}: HTTP {code}") + tok = data.get("access_token") or data.get("token") or "" + if not tok: + raise RuntimeError(f"token empty for {user}") + return str(tok) + + +def list_all_accounts(bank: str, token: str, delta: int) -> List[str]: + """List accounts; page with start if needed.""" + names: List[str] = [] + seen: Set[str] = set() + start: Optional[int] = None + pages = 0 + max_pages = int(env("MAX_ACCOUNT_PAGES", "50") or "50") + + while pages < max_pages: + pages += 1 + q = f"delta={delta}" + if start is not None: + q += f"&start={start}" + code, data, raw = http_json( + f"{bank}/accounts?{q}", + headers={"Authorization": f"Bearer {token}"}, + timeout=45, + ) + if code == 204 or not data: + break + if code != 200: + raise RuntimeError(f"list accounts HTTP {code}: {raw[:200]!r}") + + batch: List[Dict[str, Any]] = [] + if isinstance(data, dict): + if isinstance(data.get("accounts"), list): + batch = data["accounts"] + elif isinstance(data.get("users"), list): + batch = data["users"] + elif isinstance(data, list): + batch = data + + if not batch: + # flat object with username? try regex fallback + text = raw.decode("utf-8", errors="replace") + for m in re.finditer(r'"username"\s*:\s*"([^"]+)"', text): + u = m.group(1) + if u not in seen: + seen.add(u) + names.append(u) + break + + min_row = None + for item in batch: + if not isinstance(item, dict): + continue + u = item.get("username") or item.get("name") or "" + if u and u not in seen: + seen.add(u) + names.append(str(u)) + rid = item.get("row_id") or item.get("rowId") + if rid is not None: + try: + rid_i = int(rid) + min_row = rid_i if min_row is None else min(min_row, rid_i) + except Exception: + pass + + if len(batch) < abs(delta): + break + if min_row is None: + break + # next older page + start = min_row + # avoid infinite loop on same start + if pages > 1 and len(names) == len(seen): + # if no growth and full page, still advance + pass + + return names + + +def iter_transactions( + bank: str, + token: str, + username: str, + page: int, + max_pages: int, +) -> List[Dict[str, Any]]: + """Return all transactions for account (paginated).""" + out: List[Dict[str, Any]] = [] + start: Optional[int] = None + pages = 0 + safety = max_pages if max_pages > 0 else 10_000 + + while pages < safety: + pages += 1 + q = f"delta=-{abs(page)}" + if start is not None: + q += f"&start={start}" + code, data, raw = http_json( + f"{bank}/accounts/{urllib.parse.quote(username)}/transactions?{q}", + headers={"Authorization": f"Bearer {token}"}, + timeout=float(env("TX_CURL_TIMEOUT", "45") or "45"), + ) + if code in (204, 404) or not data: + break + if code != 200: + # soft-fail one account + break + + txs: List[Dict[str, Any]] = [] + if isinstance(data, dict) and isinstance(data.get("transactions"), list): + txs = data["transactions"] + elif isinstance(data, list): + txs = data + else: + # tolerate raw array-ish + break + + if not txs: + break + + min_row = None + for tx in txs: + if not isinstance(tx, dict): + continue + out.append(tx) + rid = tx.get("row_id") or tx.get("rowId") + if rid is not None: + try: + rid_i = int(rid) + min_row = rid_i if min_row is None else min(min_row, rid_i) + except Exception: + pass + + if len(txs) < abs(page): + break + if min_row is None: + break + start = min_row + + return out + + +def tx_fields(tx: Dict[str, Any]) -> Tuple[str, str, int, str]: + """direction, amount, unix_ts, subject""" + direction = str(tx.get("direction") or "").lower() + amount = str(tx.get("amount") or "") + subject = str(tx.get("subject") or tx.get("description") or "") + ts = 0 + when = tx.get("when") or tx.get("date") or tx.get("timestamp") + if isinstance(when, dict): + # Taler AbsoluteTime: t_s seconds or t_ms + if "t_s" in when: + try: + ts = int(when["t_s"]) + except Exception: + ts = 0 + elif "t_ms" in when: + try: + ts = int(int(when["t_ms"]) / 1000) + except Exception: + ts = 0 + elif isinstance(when, (int, float)): + ts = int(when) + if ts > 10_000_000_000: # ms + ts //= 1000 + elif tx.get("t_s") is not None: + try: + ts = int(tx["t_s"]) + except Exception: + ts = 0 + return direction, amount, ts, subject + + +def reserve_from_subject(subject: str) -> str: + m = RESERVE_RE.search(subject or "") + if m: + return re.sub(r"[^A-Za-z0-9]", "", m.group(1)) + # fallback: last token + parts = (subject or "").split() + if parts: + return re.sub(r"[^A-Za-z0-9]", "", parts[-1]) + return "" + + +def now_parts(tz_name: str) -> Tuple[int, str, str]: + tz = ZoneInfo(tz_name) + dt = datetime.now(tz) + unix = int(dt.timestamp()) + iso = dt.strftime("%Y-%m-%dT%H:%M%z") + # +0200 → +02:00 + if len(iso) >= 5 and iso[-5] in "+-" and ":" not in iso[-5:]: + iso = iso[:-2] + ":" + iso[-2:] + human = dt.strftime("%Y-%m-%d %H:%M %Z") + return unix, iso, human + + +def human_from_unix(ts: int, tz_name: str) -> Tuple[str, str]: + if not ts: + return "", "" + tz = ZoneInfo(tz_name) + dt = datetime.fromtimestamp(ts, tz) + iso = dt.strftime("%Y-%m-%dT%H:%M%z") + if len(iso) >= 5 and iso[-5] in "+-" and ":" not in iso[-5:]: + iso = iso[:-2] + ":" + iso[-2:] + human = dt.strftime("%Y-%m-%d %H:%M %Z") + return human, iso + + +def main() -> int: + ap = argparse.ArgumentParser(description="Collect full bank landing stats") + ap.add_argument("--bank", default=env("BANK_URL", "http://127.0.0.1:9012")) + ap.add_argument("--admin-user", default=env("BANK_ADMIN_USER", "admin")) + ap.add_argument("--admin-pass", default=env("BANK_ADMIN_PASS", "")) + ap.add_argument("--admin-pass-file", default=env("BANK_ADMIN_PASS_FILE", "")) + ap.add_argument("--explorer-user", default=env("BANK_EXPLORER_USER", "explorer")) + ap.add_argument("--explorer-pass", default=env("BANK_EXPLORER_PASS", "")) + ap.add_argument("--explorer-pass-file", default=env("BANK_EXPLORER_PASS_FILE", "")) + ap.add_argument( + "--exchange-config", + default=env("EXCHANGE_CONFIG_URL", "https://exchange.hacktivism.ch/config"), + ) + ap.add_argument("--out", default=env("OUT", "-")) + ap.add_argument("--run-out", default=env("RUN_OUT", "")) + ap.add_argument( + "--skip", + default=env("SCAN_SKIP", "exchange"), + help="comma usernames excluded from flow scan (default: exchange)", + ) + ap.add_argument("--tx-page", type=int, default=int(env("TX_PAGE", "500") or "500")) + ap.add_argument( + "--max-tx-pages", + type=int, + default=int(env("MAX_TX_PAGES", "0") or "0"), + help="0 = unlimited pages per account", + ) + ap.add_argument( + "--accounts-delta", + type=int, + default=int(env("ACCOUNTS_DELTA", "-10000") or "-10000"), + ) + ap.add_argument("--recent", type=int, default=int(env("RECENT_WD_N", "10") or "10")) + ap.add_argument("--public-base", default=env("BANK_PUBLIC_URL", "https://bank.hacktivism.ch")) + args = ap.parse_args() + + tz_name = env("TZ", "Europe/Zurich") or "Europe/Zurich" + os.environ["TZ"] = tz_name + + admin_pass = args.admin_pass or ( + read_pass_file(args.admin_pass_file) if args.admin_pass_file else "" + ) + explorer_pass = args.explorer_pass or ( + read_pass_file(args.explorer_pass_file) if args.explorer_pass_file else "" + ) + + bank = args.bank.rstrip("/") + skip = {s.strip() for s in (args.skip or "").split(",") if s.strip()} + + def write_run(ok: bool, err: Optional[str] = None) -> None: + if not args.run_out: + return + unix, iso, human = now_parts(tz_name) + payload = { + "ok": ok, + "at": iso, + "at_human": human, + "error": err, + } + Path(args.run_out).parent.mkdir(parents=True, exist_ok=True) + Path(args.run_out).write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8") + + try: + if not admin_pass: + raise RuntimeError("no admin password (BANK_ADMIN_PASS or --admin-pass-file)") + token = get_token(bank, args.admin_user, admin_pass) + alt = load_alt_from_config(args.exchange_config) + if not alt: + alt = dict(DEFAULT_ALT) + + accounts = list_all_accounts(bank, token, args.accounts_delta) + if not accounts: + raise RuntimeError("accounts list empty") + + # ALL accounts except skip set (default: only exchange — avoid double-count) + scan_users = [u for u in accounts if u not in skip] + # ensure explorer is included when present + if args.explorer_user not in skip and args.explorer_user in accounts: + if args.explorer_user not in scan_users: + scan_users.append(args.explorer_user) + + withdraws: List[Dict[str, Any]] = [] + incomings: List[Dict[str, Any]] = [] + total_in = Decimal(0) + total_wd = Decimal(0) + total_other = Decimal(0) + n_incoming = 0 + scan_ok = 0 + scan_empty = 0 + scan_fail = 0 + + for uname in scan_users: + try: + txs = iter_transactions( + bank, token, uname, args.tx_page, args.max_tx_pages + ) + except Exception: + scan_fail += 1 + continue + if not txs: + scan_empty += 1 + continue + scan_ok += 1 + for tx in txs: + direction, amount, ts, subject = tx_fields(tx) + if not amount: + continue + try: + _cur, n = parse_amount(amount) + except Exception: + continue + low = (subject or "").lower() + if direction == "credit": + total_in += n + n_incoming += 1 + incomings.append( + { + "kind": "incoming", + "amount": amount if ":" in amount else f"GOA:{amount}", + "at_unix": ts, + "account": uname, + "subject": subject, + } + ) + elif direction == "debit" and "withdraw" in low: + total_wd += n + res = reserve_from_subject(subject) + withdraws.append( + { + "kind": "withdraw", + "amount": amount if ":" in amount else f"GOA:{amount}", + "at_unix": ts, + "account": uname, + "subject": subject, + "reserve": res, + } + ) + elif direction == "debit": + total_other += n + + withdraws.sort(key=lambda r: r.get("at_unix") or 0, reverse=True) + incomings.sort(key=lambda r: r.get("at_unix") or 0, reverse=True) + + now_u, gen_iso, gen_human = now_parts(tz_name) + day = now_u - 86400 + week = now_u - 7 * 86400 + + w24 = Decimal(0) + w7 = Decimal(0) + n24 = 0 + n7 = 0 + for w in withdraws: + ts = int(w.get("at_unix") or 0) + try: + _c, n = parse_amount(w.get("amount")) + except Exception: + n = Decimal(0) + if ts >= day: + w24 += n + n24 += 1 + if ts >= week: + w7 += n + n7 += 1 + + wallets = sorted( + {w.get("reserve") for w in withdraws if w.get("reserve")} + ) + accounts_with_wd = sorted( + {w.get("account") for w in withdraws if w.get("account")} + ) + users_n = sum(1 for u in accounts if u not in ("admin", "exchange")) + + def pack_amt(d: Decimal) -> Dict[str, Any]: + f = format_amount_alt(f"GOA:{d}", alt) + return { + "amount": f["amount"], + "amount_alt": f["amount_alt"], + "amount_full": f["amount_full"], + "value": f["value"], + "value_str": f["value_str"], + } + + fin = pack_amt(total_in) + fwd = pack_amt(total_wd) + foth = pack_amt(total_other) + fout = pack_amt(total_wd + total_other) + p24 = pack_amt(w24) + p7 = pack_amt(w7) + + last = withdraws[0] if withdraws else None + last_amt = None + last_at = last_iso = last_subj = None + last_unix = None + last_alt = None + if last: + lf = format_amount_alt(last["amount"], alt) + last_amt = lf["amount"] + last_alt = lf["amount_alt"] + last_unix = last.get("at_unix") + last_at, last_iso = human_from_unix(int(last_unix or 0), tz_name) + last_subj = last.get("subject") + + recent = [] + for w in withdraws[: max(0, args.recent)]: + f = format_amount_alt(w["amount"], alt) + at_h, at_iso = human_from_unix(int(w.get("at_unix") or 0), tz_name) + recent.append( + { + "kind": "withdraw", + "amount": f["amount"], + "amount_alt": f["amount_alt"], + "amount_full": f["amount_full"], + "at": at_h, + "at_iso": at_iso, + "at_unix": w.get("at_unix") or None, + "account": w.get("account"), + "reserve": w.get("reserve") or "", + } + ) + + recent_in = [] + for row in incomings[:5]: + f = format_amount_alt(row["amount"], alt) + at_h, at_iso = human_from_unix(int(row.get("at_unix") or 0), tz_name) + recent_in.append( + { + "kind": "incoming", + "amount": f["amount"], + "amount_alt": f["amount_alt"], + "amount_full": f["amount_full"], + "at": at_h, + "at_iso": at_iso, + "at_unix": row.get("at_unix") or None, + "account": row.get("account"), + } + ) + + # explorer balance + balance = "GOA:0" + bal_alt = "GOA:0" + bal_full = "GOA:0" + if explorer_pass: + try: + etok = get_token(bank, args.explorer_user, explorer_pass) + code, data, _ = http_json( + f"{bank}/accounts/{args.explorer_user}", + headers={"Authorization": f"Bearer {etok}"}, + timeout=12, + ) + if code == 200 and isinstance(data, dict): + balance = str( + data.get("balance", {}).get("amount") + if isinstance(data.get("balance"), dict) + else data.get("amount") or data.get("balance") or "GOA:0" + ) + # sometimes balance is object with amount + if isinstance(data.get("balance"), dict) and data["balance"].get( + "amount" + ): + balance = str(data["balance"]["amount"]) + bf = format_amount_alt(balance, alt) + balance, bal_alt, bal_full = ( + bf["amount"], + bf["amount_alt"], + bf["amount_full"], + ) + except Exception: + pass + else: + # try admin-read of explorer account + try: + code, data, _ = http_json( + f"{bank}/accounts/{args.explorer_user}", + headers={"Authorization": f"Bearer {token}"}, + timeout=12, + ) + if code == 200 and isinstance(data, dict): + if isinstance(data.get("balance"), dict): + balance = str(data["balance"].get("amount") or "GOA:0") + elif data.get("amount"): + balance = str(data["amount"]) + bf = format_amount_alt(balance, alt) + balance, bal_alt, bal_full = ( + bf["amount"], + bf["amount_alt"], + bf["amount_full"], + ) + except Exception: + pass + + pub = args.public_base.rstrip("/") + config_ms, config_http = measure_ms(f"{pub}/config") + int_ms, int_http = measure_ms(f"{pub}/taler-integration/config") + webui_ms, webui_http = measure_ms(f"{pub}/webui/") + if config_http != "200": + config_ms, config_http = measure_ms(f"{bank}/config") + if int_http != "200": + int_ms, int_http = measure_ms(f"{bank}/taler-integration/config") + + loadavg = "" + try: + with open("/proc/loadavg", encoding="utf-8") as f: + parts = f.read().split() + loadavg = ",".join(parts[:3]) + except Exception: + pass + + total_pack = pack_amt(total_wd) + + stats = { + "ok": True, + "currency": "GOA", + "timezone": tz_name, + "generated_at": gen_iso, + "generated_at_human": gen_human, + "generated_at_unix": now_u, + "source": "host collect_bank_stats.py (hernani systemd)", + "bank_url": bank, + "scan": { + "tx_page": args.tx_page, + "max_tx_pages": args.max_tx_pages, + "accounts_delta": args.accounts_delta, + "skip_users": sorted(skip), + "accounts_listed": len(accounts), + "accounts_scanned_ok": scan_ok, + "accounts_empty_tx": scan_empty, + "accounts_scan_fail": scan_fail, + "accounts_scanned": len(scan_users), + "note": "all accounts except skip_users; full tx pagination; empty includes HTTP 204", + }, + "bank_accounts": { + "total": len(accounts), + "users": users_n, + "with_withdraws": len(accounts_with_wd), + }, + "wallets": { + "unique_reserves": len(wallets), + "note": "unique reserve pubs from Taler withdrawals (one per wallet withdraw)", + }, + "balance_explorer": balance, + "balance_explorer_alt": bal_alt, + "balance_explorer_full": bal_full, + "flow": { + "incoming": { + "label": "Incoming bank credits", + "count": n_incoming, + "amount": fin["amount"], + "amount_alt": fin["amount_alt"], + "amount_full": fin["amount_full"], + "value": fin["value"], + "value_str": fin["value_str"], + }, + "withdraw": { + "label": "Taler withdrawals to wallets", + "count": len(withdraws), + "amount": fwd["amount"], + "amount_alt": fwd["amount_alt"], + "amount_full": fwd["amount_full"], + "value": fwd["value"], + "value_str": fwd["value_str"], + }, + "other_out": { + "label": "Other debits (non-withdraw)", + "amount": foth["amount"], + "amount_alt": foth["amount_alt"], + "amount_full": foth["amount_full"], + "value": foth["value"], + "value_str": foth["value_str"], + }, + "total_in": fin["amount"], + "total_in_alt": fin["amount_alt"], + "total_in_value": fin["value"], + "total_out": fout["amount"], + "total_out_alt": fout["amount_alt"], + "total_out_value": fout["value"], + "note": "incoming=credits; withdraw=Taler withdrawal debits; skip_users excluded (default exchange)", + }, + "withdraws": { + "count": len(withdraws), + "total_amount": total_pack["amount"], + "total_amount_alt": total_pack["amount_alt"], + "total_amount_full": total_pack["amount_full"], + "total_value": total_pack["value"], + "total_value_str": total_pack["value_str"], + "last_amount": last_amt, + "last_amount_alt": last_alt, + "last_at": last_at, + "last_at_iso": last_iso, + "last_at_unix": last_unix, + "last_subject": last_subj, + "last_24h": { + "count": n24, + "amount": p24["amount"], + "amount_alt": p24["amount_alt"], + "amount_full": p24["amount_full"], + "value": p24["value"], + }, + "last_7d": { + "count": n7, + "amount": p7["amount"], + "amount_alt": p7["amount_alt"], + "amount_full": p7["amount_full"], + "value": p7["value"], + }, + }, + "recent_withdraws": recent, + "recent_incoming": recent_in, + "demo": { + "uri": None, + "amount": None, + "created": None, + "withdrawal_id": None, + "status": None, + "ready": False, + }, + "performance": { + "config_http": config_http, + "config_ms": config_ms, + "integration_http": int_http, + "integration_ms": int_ms, + "webui_http": webui_http, + "webui_ms": webui_ms, + "loadavg": loadavg, + "memory": { + "container_rss_human": "—", + "note": "memory filled by host collector merge when available", + }, + }, + "alt_unit_names": alt, + } + + # pull demo withdraw files + memory from bank container if OUT is path and caller merged + # demo files optional via DEMO_DIR + demo_dir = env("DEMO_DIR", "") + if demo_dir: + dpath = Path(demo_dir) + uri = (dpath / "withdraw.uri").read_text().strip() if (dpath / "withdraw.uri").is_file() else "" + amt = ( + (dpath / "withdraw.amount").read_text().strip() + if (dpath / "withdraw.amount").is_file() + else "" + ) + created = ( + (dpath / "withdraw.created").read_text().strip() + if (dpath / "withdraw.created").is_file() + else "" + ) + if uri: + wid = uri.rstrip("/").split("/")[-1] + stats["demo"] = { + "uri": uri, + "amount": amt or None, + "created": created or None, + "withdrawal_id": wid, + "status": None, + "ready": True, + } + + stats = enrich_stats_tree(stats, alt) + + text = json.dumps(stats, indent=2, ensure_ascii=False) + "\n" + if args.out == "-" or not args.out: + sys.stdout.write(text) + else: + outp = Path(args.out) + outp.parent.mkdir(parents=True, exist_ok=True) + tmp = outp.with_suffix(outp.suffix + f".tmp.{os.getpid()}") + tmp.write_text(text, encoding="utf-8") + tmp.replace(outp) + write_run(True, None) + print( + f"ok accounts={len(accounts)} scanned={scan_ok} withdraws={len(withdraws)} " + f"total={total_pack['amount']} alt={total_pack['amount_alt']}", + file=sys.stderr, + ) + return 0 + except Exception as e: + write_run(False, str(e)) + print(f"error: {e}", file=sys.stderr) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/taler-landing/enrich_stats_alt.py b/scripts/taler-landing/enrich_stats_alt.py new file mode 100755 index 0000000..366ee6b --- /dev/null +++ b/scripts/taler-landing/enrich_stats_alt.py @@ -0,0 +1,40 @@ +#!/usr/bin/env python3 +"""Add amount_alt / amount_full fields to a landing stats.json in place.""" +from __future__ import annotations + +import argparse +import json +import os +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +from goa_amounts import DEFAULT_ALT, enrich_stats_tree, load_alt_from_config # noqa: E402 + + +def main() -> int: + ap = argparse.ArgumentParser() + ap.add_argument("path", help="stats.json path") + ap.add_argument( + "--exchange-config", + default="https://exchange.hacktivism.ch/config", + ) + ap.add_argument("-o", "--out", default="", help="default: overwrite path") + args = ap.parse_args() + p = Path(args.path) + data = json.loads(p.read_text(encoding="utf-8")) + if not isinstance(data, dict) or not data.get("ok"): + print("skip: not ok stats", file=sys.stderr) + return 0 + alt = load_alt_from_config(args.exchange_config) or dict(DEFAULT_ALT) + enrich_stats_tree(data, alt) + out = Path(args.out) if args.out else p + tmp = out.with_suffix(out.suffix + f".tmp.{os.getpid()}") + tmp.write_text(json.dumps(data, indent=2, ensure_ascii=False) + "\n", encoding="utf-8") + tmp.replace(out) + print(f"enriched {out}", file=sys.stderr) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/taler-landing/goa_amounts.py b/scripts/taler-landing/goa_amounts.py new file mode 100755 index 0000000..6cd6c02 --- /dev/null +++ b/scripts/taler-landing/goa_amounts.py @@ -0,0 +1,306 @@ +#!/usr/bin/env python3 +"""GOA (and generic CUR:amount) alt-unit formatting for landing stats. + +Matches exchange currency_specification.alt_unit_names: + 0→GOA, 3→Kilo-GOA, 6→Mega-GOA, … and fractional scales. +Display prefers compact alt names for large values so landing tiles fit. +""" +from __future__ import annotations + +from decimal import Decimal, InvalidOperation, ROUND_HALF_UP +from typing import Any, Dict, Optional, Tuple + +# Default GOA ladder (same shape as exchange /config) +DEFAULT_ALT: Dict[str, str] = { + "24": "Yotta-GOA", + "21": "Zetta-GOA", + "18": "Exa-GOA", + "15": "Peta-GOA", + "12": "Tera-GOA", + "9": "Giga-GOA", + "6": "Mega-GOA", + "3": "Kilo-GOA", + "0": "GOA", + "-1": "Deci-GOA", + "-2": "Centi-GOA", + "-3": "Milli-GOA", + "-6": "Micro-GOA", + "-7": "Deci-Micro-GOA", + "-8": "Atomic-GOA", +} + +# Use alt unit when |value| >= this (base units). Below: keep compact CUR:n. +ALT_THRESHOLD = Decimal("1000") + + +def parse_amount(s: Any) -> Tuple[str, Decimal]: + """Parse 'GOA:12.5' or bare number → (currency, value).""" + if s is None: + return "GOA", Decimal(0) + if isinstance(s, (int, float, Decimal)): + return "GOA", Decimal(str(s)) + text = str(s).strip() + if not text: + return "GOA", Decimal(0) + if ":" in text: + cur, rest = text.split(":", 1) + return (cur or "GOA").strip(), Decimal(rest.strip() or "0") + return "GOA", Decimal(text) + + +def fmt_coeff(v: Decimal) -> str: + if v == v.to_integral_value(): + return format(int(v), "d") + # up to 4 significant fractional digits, strip trailing zeros + q = v.quantize(Decimal("0.0001"), rounding=ROUND_HALF_UP) + s = format(q, "f").rstrip("0").rstrip(".") + return s or "0" + + +def fmt_base(cur: str, val: Decimal) -> str: + if val == val.to_integral_value(): + return f"{cur}:{int(val)}" + # preserve up to 8 fractional digits (Taler style) + s = format(val, "f").rstrip("0").rstrip(".") + return f"{cur}:{s}" + + +def format_amount_alt( + amount: Any, + alt: Optional[Dict[str, str]] = None, + *, + threshold: Decimal = ALT_THRESHOLD, + with_base: bool = False, +) -> Dict[str, Any]: + """Return display fields for one amount. + + Keys: + amount canonical CUR:n + amount_alt short display (e.g. "1.23 Mega-GOA") + amount_full "1.23 Mega-GOA (GOA:1230000)" when alt used + value float for JSON (may lose precision above 2^53 — also value_str) + value_str exact decimal string + """ + alt = alt or DEFAULT_ALT + try: + cur, val = parse_amount(amount) + except (InvalidOperation, ValueError, ArithmeticError): + raw = str(amount or "") + return { + "amount": raw, + "amount_alt": raw or "—", + "amount_full": raw or "—", + "value": 0.0, + "value_str": "0", + } + + base = fmt_base(cur, val) + value_str = format(val, "f").rstrip("0").rstrip(".") if val != val.to_integral_value() else str(int(val)) + try: + value_f = float(val) + except Exception: + value_f = 0.0 + + base_name = alt.get("0") or cur + if val == 0: + disp = f"0 {base_name}" + return { + "amount": base, + "amount_alt": disp, + "amount_full": disp, + "value": 0.0, + "value_str": "0", + } + + scales = [] + for k, name in alt.items(): + try: + scales.append((int(k), str(name))) + except Exception: + continue + scales.sort(key=lambda x: -x[0]) + + absval = abs(val) + # Below threshold: short base form (saves noise on small demo amounts) + if absval < threshold: + # Prefer "GOA:12.5" style (matches existing landings) when small + return { + "amount": base, + "amount_alt": base, + "amount_full": base, + "value": value_f, + "value_str": value_str, + } + + chosen = None + for sc, name in scales: + unit = Decimal(10) ** sc + if unit <= 0: + continue + coeff = absval / unit + if coeff >= 1: + chosen = (sc, name, coeff if val >= 0 else -coeff) + break + + if chosen is None or chosen[0] == 0: + return { + "amount": base, + "amount_alt": base, + "amount_full": base, + "value": value_f, + "value_str": value_str, + } + + sc, name, coeff = chosen + short = f"{fmt_coeff(coeff)} {name}" + full = f"{short} ({base})" if with_base or True else short + return { + "amount": base, + "amount_alt": short, + "amount_full": full, + "value": value_f, + "value_str": value_str, + } + + +def attach_alt(obj: Dict[str, Any], amount_key: str = "amount", alt: Optional[Dict[str, str]] = None) -> Dict[str, Any]: + """Mutate obj: ensure amount_alt / amount_full from amount_key.""" + if not isinstance(obj, dict): + return obj + src = obj.get(amount_key) + if src is None: + return obj + f = format_amount_alt(src, alt) + obj[amount_key] = f["amount"] + obj["amount_alt"] = f["amount_alt"] + obj["amount_full"] = f["amount_full"] + if "value" not in obj: + obj["value"] = f["value"] + obj["value_str"] = f["value_str"] + return obj + + +def load_alt_from_config(url: str, timeout: float = 8.0) -> Dict[str, str]: + """GET exchange/bank /config → alt_unit_names (fallback DEFAULT_ALT).""" + import json + import urllib.request + + try: + req = urllib.request.Request(url, headers={"Accept": "application/json"}) + with urllib.request.urlopen(req, timeout=timeout) as resp: + data = json.loads(resp.read().decode("utf-8", errors="replace")) + except Exception: + return dict(DEFAULT_ALT) + + au = None + cs = data.get("currency_specification") + if isinstance(cs, dict): + au = cs.get("alt_unit_names") + if not au and isinstance(data.get("currencies"), dict): + for _code, spec in data["currencies"].items(): + if isinstance(spec, dict) and spec.get("alt_unit_names"): + au = spec["alt_unit_names"] + break + if not isinstance(au, dict) or "0" not in au: + return dict(DEFAULT_ALT) + return {str(k): str(v) for k, v in au.items()} + + +def enrich_stats_tree(data: Dict[str, Any], alt: Optional[Dict[str, str]] = None) -> Dict[str, Any]: + """Walk common landing stats.json shapes and add amount_alt fields.""" + alt = alt or DEFAULT_ALT + if not isinstance(data, dict): + return data + + # bank-style + for path in ( + ("flow", "incoming"), + ("flow", "withdraw"), + ("flow", "other_out"), + ("withdraws",), + ("withdraws", "last_24h"), + ("withdraws", "last_7d"), + ): + cur: Any = data + ok = True + for p in path: + if not isinstance(cur, dict) or p not in cur: + ok = False + break + cur = cur[p] + if ok and isinstance(cur, dict): + if "amount" in cur: + attach_alt(cur, "amount", alt) + if "total_amount" in cur: + f = format_amount_alt(cur["total_amount"], alt) + cur["total_amount"] = f["amount"] + cur["total_amount_alt"] = f["amount_alt"] + cur["total_amount_full"] = f["amount_full"] + if "last_amount" in cur and cur["last_amount"]: + f = format_amount_alt(cur["last_amount"], alt) + cur["last_amount"] = f["amount"] + cur["last_amount_alt"] = f["amount_alt"] + + if isinstance(data.get("flow"), dict): + fl = data["flow"] + for k in ("total_in", "total_out"): + if fl.get(k): + f = format_amount_alt(fl[k], alt) + fl[k] = f["amount"] + fl[f"{k}_alt"] = f["amount_alt"] + fl[f"{k}_full"] = f["amount_full"] + + if data.get("balance_explorer"): + f = format_amount_alt(data["balance_explorer"], alt) + data["balance_explorer"] = f["amount"] + data["balance_explorer_alt"] = f["amount_alt"] + data["balance_explorer_full"] = f["amount_full"] + + for key in ("recent_withdraws", "recent_incoming", "recent_activity"): + rows = data.get(key) + if isinstance(rows, list): + for row in rows: + if isinstance(row, dict) and row.get("amount"): + attach_alt(row, "amount", alt) + + # exchange-style top-level amounts + for k in ( + "wire_in_amount", + "withdraw_amount", + "coins_remaining_amount", + ): + if data.get(k): + f = format_amount_alt(data[k], alt) + data[k] = f["amount"] + data[f"{k}_alt"] = f["amount_alt"] + data[f"{k}_full"] = f["amount_full"] + + for key in ("by_denom", "denom_ladder"): + rows = data.get(key) + if isinstance(rows, list): + for row in rows: + if isinstance(row, dict) and row.get("value"): + f = format_amount_alt(row["value"], alt) + row["value"] = f["amount"] + row["value_alt"] = f["amount_alt"] + + # merchant dual currency + for block in data.get("by_currency") or []: + if not isinstance(block, dict): + continue + for k in ("amount_sum", "amount_paid_sum"): + if block.get(k): + f = format_amount_alt(block[k], alt) + block[k] = f["amount"] + block[f"{k}_alt"] = f["amount_alt"] + block[f"{k}_full"] = f["amount_full"] + + for block in data.get("recent_activity_by_currency") or []: + if not isinstance(block, dict): + continue + for row in block.get("items") or []: + if isinstance(row, dict) and row.get("amount"): + attach_alt(row, "amount", alt) + + data["alt_unit_names"] = alt + return data From 746d7a41aff7ab62b1769d3b6d83091d9ea1629d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hern=C3=A2ni=20Marques?= Date: Fri, 17 Jul 2026 07:55:00 +0200 Subject: [PATCH 53/57] scripts/taler-landing: hernani user systemd timer for central stats Install path for hernani@koopa: oneshot service plus 2-minute timer that runs the bank full scan and refreshes exchange/merchant stats via podman. --- .../systemd/user/taler-landing-stats.service | 30 ++++++++++ .../systemd/user/taler-landing-stats.timer | 13 ++++ scripts/README.md | 6 ++ .../install-landing-stats-host.sh | 59 +++++++++++++++++++ 4 files changed, 108 insertions(+) create mode 100644 configs/systemd/user/taler-landing-stats.service create mode 100644 configs/systemd/user/taler-landing-stats.timer create mode 100755 scripts/taler-landing/install-landing-stats-host.sh diff --git a/configs/systemd/user/taler-landing-stats.service b/configs/systemd/user/taler-landing-stats.service new file mode 100644 index 0000000..6273a72 --- /dev/null +++ b/configs/systemd/user/taler-landing-stats.service @@ -0,0 +1,30 @@ +[Unit] +Description=Taler landing stats (bank full scan + exchange/merchant) +Documentation=file:%h/src/koopa/koopa-admin-log/scripts/taler-landing/README.md +After=network-online.target +Wants=network-online.target +# Prefer after containers are up (ignore if unit names differ) +After=container-taler-hacktivism-bank.service container-taler-hacktivism.service +After=taler-bank-apps.service taler-merchant-apps.service + +[Service] +Type=oneshot +Nice=10 +TimeoutStartSec=480 +# Do not keep "active" after run — timer may fire again cleanly +RemainAfterExit=no +Environment=TZ=Europe/Zurich +Environment=ADMIN_LOG=%h/src/koopa/koopa-admin-log +Environment=SECRETS_BANK=%h/src/koopa/koopa-admin-secrets/koopa/host-root/taler-bank +Environment=BANK_URL=http://127.0.0.1:9012 +Environment=BANK_PUBLIC_URL=https://bank.hacktivism.ch +Environment=EXCHANGE_CONFIG_URL=https://exchange.hacktivism.ch/config +# All accounts except exchange (double-count of withdraw credits on exchange ledger) +Environment=SCAN_SKIP=exchange +Environment=TX_PAGE=500 +Environment=MAX_TX_PAGES=0 +Environment=ACCOUNTS_DELTA=-10000 +ExecStart=%h/.local/bin/collect-landing-stats.sh + +[Install] +WantedBy=default.target diff --git a/configs/systemd/user/taler-landing-stats.timer b/configs/systemd/user/taler-landing-stats.timer new file mode 100644 index 0000000..7106c8a --- /dev/null +++ b/configs/systemd/user/taler-landing-stats.timer @@ -0,0 +1,13 @@ +[Unit] +Description=Timer · Taler landing stats (every 2 min) +Documentation=file:%h/src/koopa/koopa-admin-log/scripts/taler-landing/README.md + +[Timer] +OnBootSec=90s +OnUnitActiveSec=2min +AccuracySec=20s +Persistent=true +Unit=taler-landing-stats.service + +[Install] +WantedBy=timers.target diff --git a/scripts/README.md b/scripts/README.md index 70922e5..1a1f35f 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -9,6 +9,7 @@ | `taler-shared/` | host **ensure-taler-apps** (post-container app start + auto-confirm) | | `taler-sanity/` | host root checks (stack, settlement, helpers) | | `taler-monitoring/` | **outside-in** public URL walk (`/config` → keys/terms/integration/webui) | +| `taler-landing/` | **hernani user systemd**: central landing `stats.json` (full bank scan + alt units) | | `monitoring/` | host `/home/hernani/scripts` (tor relay stats) | | `nym/` | **koopa-nym** build/up/status (nym.com nym-node) | | `taler-wallet-cli/` | thin wrappers; **benchmarks live in** `../benchmarks/` | @@ -24,6 +25,11 @@ starts, **`taler-merchant-apps.service`** / **`taler-bank-apps.service`** run `taler-shared/install-ensure-taler-apps.sh`). That runs the in-container `start_base` + `start_*.sh` (and bank auto-confirm **2 s**). +Landing **stats.json** (bank / exchange / merchant intros) is refreshed by +**`taler-landing-stats.timer`** as user **hernani** (install: +`taler-landing/install-landing-stats-host.sh`). Full bank ledger scan + GOA +alt-unit fields; see `taler-landing/README.md`. + ## Manual start model (all three) 1. **root** runs `/root/start_base_services_for_taler_*.sh` diff --git a/scripts/taler-landing/install-landing-stats-host.sh b/scripts/taler-landing/install-landing-stats-host.sh new file mode 100755 index 0000000..1fa56a9 --- /dev/null +++ b/scripts/taler-landing/install-landing-stats-host.sh @@ -0,0 +1,59 @@ +#!/usr/bin/env bash +# Install central landing-stats collector as hernani user systemd timer. +# +# On koopa: +# cd ~/src/koopa/koopa-admin-log +# ./scripts/taler-landing/install-landing-stats-host.sh +# systemctl --user start taler-landing-stats.service # one-shot now +# systemctl --user status taler-landing-stats.timer +# +# Requires: linger enabled for hernani (loginctl enable-linger hernani) +# so the timer runs without an interactive login. +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/../.." && pwd)" +ADMIN_LOG="${ADMIN_LOG:-$ROOT}" +SRC="$ADMIN_LOG/scripts/taler-landing" +UNIT_SRC="$ADMIN_LOG/configs/systemd/user" + +BIN_DST="${HOME}/.local/bin" +LIB_DST="${HOME}/.local/lib/taler-landing" +UNIT_DST="${HOME}/.config/systemd/user" +STATE_DST="${HOME}/.local/state/taler-landing-stats" + +mkdir -p "$BIN_DST" "$LIB_DST" "$UNIT_DST" "$STATE_DST" + +install -m 0755 "$SRC/collect-landing-stats.sh" "$BIN_DST/collect-landing-stats.sh" +install -m 0755 "$SRC/collect_bank_stats.py" "$LIB_DST/collect_bank_stats.py" +install -m 0755 "$SRC/enrich_stats_alt.py" "$LIB_DST/enrich_stats_alt.py" +install -m 0644 "$SRC/goa_amounts.py" "$LIB_DST/goa_amounts.py" + +# Wrapper always uses installed lib next to itself when LIB discovery works; +# also point ADMIN_LOG for in-container script refresh. +install -m 0644 "$UNIT_SRC/taler-landing-stats.service" "$UNIT_DST/" +install -m 0644 "$UNIT_SRC/taler-landing-stats.timer" "$UNIT_DST/" + +# Rewrite ExecStart to installed binary if unit uses %h path — units already do. +systemctl --user daemon-reload +systemctl --user enable --now taler-landing-stats.timer + +echo "Installed (user=$(id -un)):" +echo " $BIN_DST/collect-landing-stats.sh" +echo " $LIB_DST/{collect_bank_stats,enrich_stats_alt,goa_amounts}.py" +echo " $UNIT_DST/taler-landing-stats.{service,timer} (timer enabled)" +echo " logs: $STATE_DST/" +echo +if ! loginctl show-user "$(id -un)" -p Linger 2>/dev/null | grep -q 'Linger=yes'; then + echo "NOTE: enable linger so the timer survives logout:" + echo " sudo loginctl enable-linger $(id -un)" + echo +fi +echo "Run once now:" +echo " systemctl --user start taler-landing-stats.service" +echo " journalctl --user -u taler-landing-stats.service -n 40 --no-pager" +echo +echo "Optional secrets (if not readable via podman exec bank /root/…):" +echo " mkdir -p ~/.config/taler-landing" +echo " cp …/bank-admin-password.txt ~/.config/taler-landing/" +echo " cp …/bank-explorer-password.txt ~/.config/taler-landing/" +echo " chmod 600 ~/.config/taler-landing/*" From 48f77139c5bd17cfd33468509af4aea35eb641e9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hern=C3=A2ni=20Marques?= Date: Fri, 17 Jul 2026 08:12:00 +0200 Subject: [PATCH 54/57] landing: compact GOA alt display and all-account bank fallback Wire goa-amount.js into bank/exchange/merchant intros, deploy it with the landings, and deepen the in-container bank stats fallback to scan all accounts (skip only exchange) when the host timer is down. --- configs/bank-landing/README.md | 34 ++++- configs/bank-landing/index.html | 37 ++++- configs/exchange-landing/index.html | 37 ++++- configs/merchant-landing/index.html | 43 +++++- configs/shared/goa-amount.js | 163 +++++++++++++++++++++++ configs/shared/goa-stats.js | 36 ++++- scripts/taler-bank/landing-stats.sh | 29 ++-- scripts/taler-landing/deploy-landings.sh | 23 ++++ 8 files changed, 363 insertions(+), 39 deletions(-) create mode 100644 configs/shared/goa-amount.js mode change 100644 => 100755 scripts/taler-landing/deploy-landings.sh diff --git a/configs/bank-landing/README.md b/configs/bank-landing/README.md index d59e83a..68f290f 100644 --- a/configs/bank-landing/README.md +++ b/configs/bank-landing/README.md @@ -103,11 +103,31 @@ taler-wallet-cli exchanges accept-tos https://exchange.hacktivism.ch/ --- -## Live stats (in bank container) +## Live stats (central host collector · hernani systemd) -Stats are **not** computed on the laptop or host browser. A small script runs -**inside the bank container**, queries libeufin for the demo funding account -(`explorer`), and writes a public JSON file next to the landing assets. +**Primary:** user **hernani** on koopa runs `taler-landing-stats.timer` +(`scripts/taler-landing/`). That process full-scans **all bank accounts** +(except `exchange` double-count), paginates every ledger, writes +`stats.json` with **GOA alt units** (`amount_alt` / Kilo-GOA / Mega-GOA / …), +and refreshes exchange + merchant stats the same way. + +```bash +# on koopa as hernani +./scripts/taler-landing/install-landing-stats-host.sh +sudo loginctl enable-linger hernani +systemctl --user start taler-landing-stats.service +``` + +See **`scripts/taler-landing/README.md`**. + +**Fallback:** in-container `landing-stats.sh` (below) if the host timer is down. +It now also scans admin + all users (skips only `exchange`). + +## Live stats (in bank container · fallback) + +Stats are **not** computed in the browser. A small script can still run +**inside the bank container**, query libeufin (all accounts), and write a +public JSON file next to the landing assets. | Piece | Path / role | |-------|-------------| @@ -131,9 +151,9 @@ Stats are **not** computed on the laptop or host browser. A small script runs | Env | Default | Meaning | |-----|---------|---------| -| `TX_DELTA` | `-50000` | per-account ledger window (`GET …/transactions?delta=`) | -| `ACCOUNTS_DELTA` | `-500` | account-list window (`GET /accounts?delta=`) | -| `MAX_SCAN_ACCOUNTS` | `500` | max usernames to scan | +| `TX_DELTA` | `-200000` | per-account ledger window (`GET …/transactions?delta=`) | +| `ACCOUNTS_DELTA` | `-10000` | account-list window (`GET /accounts?delta=`) | +| `MAX_SCAN_ACCOUNTS` | `0` (unlimited) | max usernames to scan; `0` = all | Older defaults (`TX_DELTA=-100`, `MAX_SCAN_ACCOUNTS=80`) **undercounted** credits/withdraws and account totals on this stack. Empty accounts often return **HTTP 204** (no body) — diff --git a/configs/bank-landing/index.html b/configs/bank-landing/index.html index f172304..b527ea7 100644 --- a/configs/bank-landing/index.html +++ b/configs/bank-landing/index.html @@ -1491,6 +1491,7 @@ tw run-until-done && tw balance + + + +